Compare commits
6 Commits
6770f6ba0b
...
gitea/feat
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e45ead2ac | |||
| e7470e955e | |||
| 2729408ef3 | |||
| 1cf55126f5 | |||
| ba4ee30122 | |||
| bef54d5f12 |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1,5 +1,4 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
@@ -9,10 +8,8 @@
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-cli-build/
|
||||
@@ -22,28 +19,22 @@ product
|
||||
# production
|
||||
/build
|
||||
.idea/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.bin/*
|
||||
data/
|
||||
logs/*
|
||||
@@ -61,23 +52,18 @@ Thanks.md
|
||||
PUBLIC.en.md
|
||||
PR/*
|
||||
package-lock.json
|
||||
|
||||
|
||||
#Ignore vscode AI rules
|
||||
.github/instructions/codacy.instructions.md
|
||||
README1.md
|
||||
deploy*.sh
|
||||
ecosystem.config.*
|
||||
|
||||
scripts/agSniffer/*
|
||||
gitbooks/*
|
||||
gitbook/README.md
|
||||
|
||||
# Refactor backup reference (do not bundle/lint)
|
||||
open-sse.old/
|
||||
.graphifyignore
|
||||
graphify-out/*
|
||||
|
||||
# Local-only working dirs (notes, vendored repos, scripts, skills)
|
||||
.claude/
|
||||
.docs/
|
||||
@@ -86,7 +72,6 @@ graphify-out/*
|
||||
.codegraph/
|
||||
.PR/
|
||||
.next-analyze/*
|
||||
|
||||
# CommandCode CLI local state (auth/taste/projects)
|
||||
.commandcode/
|
||||
|
||||
|
||||
@@ -37,7 +37,3 @@ Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM cha
|
||||
- `registry/index.js` is an auto-generated static import list; regenerate it (don't hand-edit) after adding a `registry/{id}.js`. REGISTRY_TEMPLATE is excluded by design.
|
||||
- Special binary/protobuf formats (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — handle in their executor.
|
||||
- `rtk/` + `headroom.js` mutate the request body in-place and are **fail-open**: any error returns null and leaves the body untouched — never throw out of them. RTK skips `is_error`/`status:"error"` tool results to preserve traces.
|
||||
- **HTTP 200 in-stream errors**: some upstreams signal failure INSIDE a 200 stream (AI SDK v5 `{"type":"error"}` events, error text in content). HTTP-level success checks miss these → no fallback, `Status: success` in logs. Three hook points + one config escape hatch:
|
||||
1. **Translator** — never map an error event to content. Emit an OpenAI-shaped `chunk.error = { message, type }` + terminal chunk (`translator/response/commandcode-to-openai.js` is the worked example). Downstream `parseSSEToOpenAIResponse` already detects `chunk?.error`.
|
||||
2. **Executor early-peek** — for streaming fallback, read the first events BEFORE returning the response; an error → non-ok Response (`executors/commandcode.js` `peekForUpstreamError`).
|
||||
3. **Config escape hatch (no code)** — per-provider `streamErrorPatterns` setting (UI: provider page → Stream Error Patterns). Patterns matched against the first ~8KB of the stream and the assembled non-streaming content; see `utils/streamErrorPeek.js` + `utils/streamErrorPatterns.js`.
|
||||
|
||||
@@ -2,7 +2,6 @@ import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry, FET
|
||||
import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { dbg } from "../utils/debugLog.js";
|
||||
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
|
||||
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
|
||||
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
|
||||
|
||||
@@ -134,14 +133,13 @@ export class BaseExecutor {
|
||||
|
||||
// Abort if upstream doesn't return response headers within connection timeout
|
||||
const connectCtrl = new AbortController();
|
||||
const timeoutMs = await resolveProviderTimeoutMs(this.provider, this.config?.timeoutMs, FETCH_CONNECT_TIMEOUT_MS);
|
||||
const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
|
||||
const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
|
||||
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
|
||||
let fetchT0 = 0;
|
||||
|
||||
try {
|
||||
const bodyStr = JSON.stringify(transformedBody);
|
||||
fetchT0 = Date.now();
|
||||
const fetchT0 = Date.now();
|
||||
dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`);
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: "POST",
|
||||
@@ -167,11 +165,6 @@ export class BaseExecutor {
|
||||
clearTimeout(connectTimer);
|
||||
lastError = error;
|
||||
const isConnectTimeout = connectCtrl.signal.aborted && error.name === "AbortError";
|
||||
// Error diagnostic — only logs on actual upstream failure. Distinguishes
|
||||
// undici connect timeout (UND_ERR_CONNECT_TIMEOUT), DNS (ENOTFOUND),
|
||||
// refused (ECONNREFUSED) vs our own connectCtrl abort (AbortError).
|
||||
const cause = error?.cause || {};
|
||||
console.log(`[FETCH-DIAG] ${this.provider} fetch error | name=${error.name} | code=${error.code ?? cause?.code ?? "none"} | msg=${String(error.message).slice(0, 120)} | connectTimeout=${timeoutMs}ms | elapsed=${Date.now() - fetchT0}ms`);
|
||||
dbg("FETCH", `${this.provider.toUpperCase()} ✖ ${error.name}: ${error.message}${isConnectTimeout ? " (connect timeout)" : ""}`);
|
||||
// Connect timeout is internal — convert to retryable network error, don't propagate AbortError
|
||||
if (error.name === "AbortError" && !isConnectTimeout) throw error;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { commandCodeToOpenAIResponse } from "../translator/response/commandcode-to-openai.js";
|
||||
import { SSE_DONE } from "../utils/sseConstants.js";
|
||||
|
||||
@@ -15,271 +14,81 @@ import { SSE_DONE } from "../utils/sseConstants.js";
|
||||
* We translate each event to an OpenAI chat.completion.chunk and emit it as SSE so
|
||||
* both the streaming and non-streaming (forced SSE → JSON) downstream handlers in
|
||||
* 9router can consume it without further format translation.
|
||||
*
|
||||
* Terminal upstream failures arrive as `{"type":"error"}` events inside the HTTP
|
||||
* 200 stream, so a plain `response.ok` check cannot see them. We peek the first
|
||||
* events before committing the response (see peekForUpstreamError) so a stream
|
||||
* that starts with an error fails fast — the normal `!response.ok` path then
|
||||
* triggers account/model fallback instead of streaming fake success content.
|
||||
*/
|
||||
export class CommandCodeExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("commandcode", PROVIDERS.commandcode);
|
||||
}
|
||||
constructor() {
|
||||
super("commandcode", PROVIDERS.commandcode);
|
||||
}
|
||||
|
||||
transformRequest(_model, body, _stream, _credentials) {
|
||||
body.stream = true;
|
||||
return body;
|
||||
}
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
body.stream = true;
|
||||
return body;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(this.config.headers || {}),
|
||||
"x-session-id": randomUUID(),
|
||||
};
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(this.config.headers || {}),
|
||||
"x-session-id": randomUUID(),
|
||||
};
|
||||
|
||||
const token = credentials?.apiKey || credentials?.accessToken;
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const token = credentials?.apiKey || credentials?.accessToken;
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
return headers;
|
||||
}
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
return headers;
|
||||
}
|
||||
|
||||
async execute(opts) {
|
||||
const result = await super.execute(opts);
|
||||
if (!result?.response?.ok || !result.response.body) return result;
|
||||
result.response = await peekForUpstreamError(result.response, opts.model, {
|
||||
signal: opts.signal,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// How long to hold the response open while peeking the first upstream events.
|
||||
// An upstream error event ("Network connection lost") is emitted at stream
|
||||
// start, so the peek is fast; the bound just prevents a slow-started stream
|
||||
// from being held hostage. Env: COMMANDCODE_PEEK_TIMEOUT_MS.
|
||||
const PEEK_TIMEOUT_MS = (() => {
|
||||
const raw = process.env.COMMANDCODE_PEEK_TIMEOUT_MS;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : 10 * 1000;
|
||||
})();
|
||||
|
||||
// Event types that count as "the stream has started producing". Everything
|
||||
// else (start, start-step, reasoning-start, text-start, ...) is metadata and
|
||||
// does not end the peek.
|
||||
const MEANINGFUL_EVENT_TYPES = new Set([
|
||||
"text-delta",
|
||||
"reasoning-delta",
|
||||
"tool-input-start",
|
||||
"tool-input-delta",
|
||||
"tool-input-end",
|
||||
"tool-call",
|
||||
"finish-step",
|
||||
"finish",
|
||||
]);
|
||||
|
||||
function makeAbortError(reason) {
|
||||
const error = new Error(reason?.message || reason || "Request aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function tryParseEvent(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
const json = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
|
||||
if (!json || json === "[DONE]") return null;
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorValue(errVal) {
|
||||
const errStr =
|
||||
typeof errVal === "string"
|
||||
? errVal
|
||||
: typeof errVal?.message === "string"
|
||||
? errVal.message
|
||||
: JSON.stringify(errVal);
|
||||
const errType =
|
||||
typeof errVal === "string"
|
||||
? "upstream_error"
|
||||
: errVal?.type || "upstream_error";
|
||||
return { message: errStr, type: errType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first upstream events before committing the response.
|
||||
*
|
||||
* - `{"type":"error"}` as the first meaningful event → return a 502 Response so
|
||||
* chatCore's `!response.ok` path parses the error and triggers fallback.
|
||||
* - Otherwise → re-emit the buffered bytes + the rest of the stream through the
|
||||
* normal NDJSON → OpenAI SSE wrapper and return it untouched in spirit.
|
||||
*
|
||||
* Bounded by `timeoutMs` (default PEEK_TIMEOUT_MS): if no meaningful event
|
||||
* arrives in time, or the request signal aborts, we commit whatever we have and
|
||||
* let the regular stream pipeline (stall detection, abort handling) take over.
|
||||
*/
|
||||
export async function peekForUpstreamError(
|
||||
originalResponse,
|
||||
model,
|
||||
{ signal = null, timeoutMs = PEEK_TIMEOUT_MS } = {},
|
||||
) {
|
||||
const reader = originalResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const abortController = new AbortController();
|
||||
const forwardAbort = () => abortController.abort(signal?.reason);
|
||||
if (signal?.aborted) abortController.abort(signal?.reason);
|
||||
else if (signal)
|
||||
signal.addEventListener("abort", forwardAbort, { once: true });
|
||||
|
||||
// Raw bytes for lossless re-emission; decoded text is only used for line
|
||||
// parsing / error detection. Never re-encode decoded text: TextDecoder
|
||||
// holds a split multi-byte char internally and flush() would replace it
|
||||
// with U+FFFD, corrupting the stream.
|
||||
const rawChunks = [];
|
||||
let peeked = "";
|
||||
let errorEvent = null;
|
||||
let committed = false;
|
||||
|
||||
const readWithTimeout = (ms) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return Promise.reject(makeAbortError(abortController.signal.reason));
|
||||
}
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
const t = setTimeout(() => reject(new Error("peek timeout")), ms);
|
||||
t.unref?.();
|
||||
});
|
||||
const abortPromise = new Promise((_, reject) => {
|
||||
abortController.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(makeAbortError(abortController.signal.reason)),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return Promise.race([reader.read(), timeoutPromise, abortPromise]);
|
||||
};
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!errorEvent && !committed && Date.now() < deadline) {
|
||||
const { done, value } = await readWithTimeout(
|
||||
Math.max(deadline - Date.now(), 1),
|
||||
);
|
||||
if (done) break;
|
||||
rawChunks.push(value);
|
||||
peeked += decoder.decode(value, { stream: true });
|
||||
const lines = peeked.split("\n");
|
||||
// The last segment may be a partial line — only parse complete ones.
|
||||
for (const line of lines.slice(0, -1)) {
|
||||
const event = tryParseEvent(line);
|
||||
if (!event?.type) continue;
|
||||
if (event.type === "error") {
|
||||
errorEvent = event;
|
||||
break;
|
||||
}
|
||||
if (MEANINGFUL_EVENT_TYPES.has(event.type)) {
|
||||
committed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// timeout / abort / read failure during the peek → commit whatever we have;
|
||||
// the downstream stream pipeline (stall detection, abort handling) takes over.
|
||||
}
|
||||
|
||||
if (signal) signal.removeEventListener("abort", forwardAbort);
|
||||
|
||||
if (errorEvent) {
|
||||
await reader.cancel("commandcode early error detected").catch(() => {});
|
||||
const { message, type } = formatErrorValue(
|
||||
errorEvent.error ?? errorEvent.message ?? "unknown",
|
||||
);
|
||||
return new Response(JSON.stringify({ error: { message, type } }), {
|
||||
status: HTTP_STATUS.BAD_GATEWAY,
|
||||
statusText: message.slice(0, 200),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const remaining = new ReadableStream({
|
||||
start(controller) {
|
||||
(async () => {
|
||||
try {
|
||||
// Re-emit RAW bytes (never re-encoded decoded text) so split
|
||||
// multi-byte UTF-8 sequences survive the peek untouched.
|
||||
for (const c of rawChunks) controller.enqueue(c);
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
controller.enqueue(value);
|
||||
}
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
})();
|
||||
},
|
||||
cancel() {
|
||||
reader.cancel("commandcode stream cancelled").catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
const combined = new Response(remaining, {
|
||||
status: originalResponse.status,
|
||||
statusText: originalResponse.statusText,
|
||||
headers: originalResponse.headers,
|
||||
});
|
||||
return wrapNdjsonAsOpenAISse(combined, model);
|
||||
async execute(opts) {
|
||||
const result = await super.execute(opts);
|
||||
if (!result?.response?.ok || !result.response.body) return result;
|
||||
result.response = wrapNdjsonAsOpenAISse(result.response, opts.model);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapNdjsonAsOpenAISse(originalResponse, model) {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
const state = { model };
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
const state = { model };
|
||||
|
||||
const emitChunks = (chunks, controller) => {
|
||||
if (!chunks) return;
|
||||
const list = Array.isArray(chunks) ? chunks : [chunks];
|
||||
for (const c of list) {
|
||||
if (c == null) continue;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(c)}\n\n`));
|
||||
}
|
||||
};
|
||||
const emitChunks = (chunks, controller) => {
|
||||
if (!chunks) return;
|
||||
const list = Array.isArray(chunks) ? chunks : [chunks];
|
||||
for (const c of list) {
|
||||
if (c == null) continue;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(c)}\n\n`));
|
||||
}
|
||||
};
|
||||
|
||||
const transform = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
// Translate AI SDK v5 NDJSON line to one or more OpenAI chunks
|
||||
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
const trimmed = buffer.trim();
|
||||
if (trimmed) {
|
||||
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
|
||||
}
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
},
|
||||
});
|
||||
const transform = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
// Translate AI SDK v5 NDJSON line to one or more OpenAI chunks
|
||||
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
const trimmed = buffer.trim();
|
||||
if (trimmed) {
|
||||
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
|
||||
}
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
},
|
||||
});
|
||||
|
||||
const newBody = originalResponse.body.pipeThrough(transform);
|
||||
return new Response(newBody, {
|
||||
status: originalResponse.status,
|
||||
statusText: originalResponse.statusText,
|
||||
headers: originalResponse.headers,
|
||||
});
|
||||
const newBody = originalResponse.body.pipeThrough(transform);
|
||||
return new Response(newBody, {
|
||||
status: originalResponse.status,
|
||||
statusText: originalResponse.statusText,
|
||||
headers: originalResponse.headers,
|
||||
});
|
||||
}
|
||||
|
||||
export default CommandCodeExecutor;
|
||||
|
||||
@@ -30,7 +30,6 @@ import { PROVIDERS } from "../config/providers.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { SSE_DONE } from "../utils/sseConstants.js";
|
||||
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
|
||||
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
|
||||
import {
|
||||
QODER_CHAT_URL_ENCODED,
|
||||
QODER_CHAT_BASE_ALT,
|
||||
@@ -537,7 +536,7 @@ export class QoderExecutor extends BaseExecutor {
|
||||
};
|
||||
|
||||
// Abort if upstream doesn't return response headers within connect timeout.
|
||||
const timeoutMs = await resolveProviderTimeoutMs(this.provider, this.config?.timeoutMs, FETCH_CONNECT_TIMEOUT_MS);
|
||||
const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
|
||||
const connectCtrl = new AbortController();
|
||||
const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
|
||||
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
|
||||
|
||||
@@ -372,7 +372,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
||||
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId, apiKey,
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
|
||||
@@ -58,21 +58,11 @@ export function extractUsageFromResponse(responseBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mask API keys before they reach the requestDetails data blob / DB column.
|
||||
// Only the prefix is kept — enough to distinguish keys without leaking them.
|
||||
export function maskApiKey(key) {
|
||||
if (!key || typeof key !== "string") return undefined;
|
||||
const trimmed = key.trim();
|
||||
if (trimmed.length <= 8) return trimmed.charAt(0) + "***";
|
||||
return trimmed.slice(0, 8) + "***";
|
||||
}
|
||||
|
||||
export function buildRequestDetail(base, overrides = {}) {
|
||||
return {
|
||||
provider: base.provider || "unknown",
|
||||
model: base.model || "unknown",
|
||||
connectionId: base.connectionId || undefined,
|
||||
apiKey: maskApiKey(base.apiKey),
|
||||
timestamp: new Date().toISOString(),
|
||||
latency: base.latency || { ttft: 0, total: 0 },
|
||||
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
|
||||
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
|
||||
import { createErrorResult } from "../../utils/error.js";
|
||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||
import { FORMATS } from "../../translator/formats.js";
|
||||
@@ -8,17 +7,16 @@ import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLin
|
||||
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
|
||||
|
||||
// Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape
|
||||
const isResponsesProvider = (p) =>
|
||||
PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
|
||||
const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
|
||||
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
|
||||
|
||||
function textFromResponsesMessageItem(item) {
|
||||
if (!item?.content || !Array.isArray(item.content)) return "";
|
||||
const byType = item.content.find((c) => c.type === "output_text");
|
||||
if (typeof byType?.text === "string") return byType.text;
|
||||
const anyText = item.content.find((c) => typeof c.text === "string");
|
||||
if (typeof anyText?.text === "string") return anyText.text;
|
||||
return "";
|
||||
if (!item?.content || !Array.isArray(item.content)) return "";
|
||||
const byType = item.content.find((c) => c.type === "output_text");
|
||||
if (typeof byType?.text === "string") return byType.text;
|
||||
const anyText = item.content.find((c) => typeof c.text === "string");
|
||||
if (typeof anyText?.text === "string") return anyText.text;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,15 +24,15 @@ function textFromResponsesMessageItem(item) {
|
||||
* Early message blocks often have empty output_text; the user-visible answer is usually in the last non-empty message.
|
||||
*/
|
||||
function pickAssistantMessageForChatCompletion(output) {
|
||||
if (!Array.isArray(output)) return { msgItem: null, textContent: null };
|
||||
const messages = output.filter((item) => item?.type === "message");
|
||||
if (messages.length === 0) return { msgItem: null, textContent: null };
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const text = textFromResponsesMessageItem(messages[i]);
|
||||
if (text.length > 0) return { msgItem: messages[i], textContent: text };
|
||||
}
|
||||
const last = messages[messages.length - 1];
|
||||
return { msgItem: last, textContent: textFromResponsesMessageItem(last) };
|
||||
if (!Array.isArray(output)) return { msgItem: null, textContent: null };
|
||||
const messages = output.filter((item) => item?.type === "message");
|
||||
if (messages.length === 0) return { msgItem: null, textContent: null };
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const text = textFromResponsesMessageItem(messages[i]);
|
||||
if (text.length > 0) return { msgItem: messages[i], textContent: text };
|
||||
}
|
||||
const last = messages[messages.length - 1];
|
||||
return { msgItem: last, textContent: textFromResponsesMessageItem(last) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,414 +110,250 @@ function chatCompletionToResponses(responseBody, customToolNames = null) {
|
||||
* Used when provider forces streaming but client wants non-streaming.
|
||||
*/
|
||||
export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
const chunks = [];
|
||||
let streamError = null;
|
||||
const chunks = [];
|
||||
let streamError = null;
|
||||
|
||||
for (const line of String(rawSSE || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
try {
|
||||
const chunk = JSON.parse(payload);
|
||||
if (chunk?.error) streamError = chunk.error;
|
||||
else chunks.push(chunk);
|
||||
} catch {
|
||||
/* ignore malformed lines */
|
||||
}
|
||||
}
|
||||
for (const line of String(rawSSE || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
try {
|
||||
const chunk = JSON.parse(payload);
|
||||
if (chunk?.error) streamError = chunk.error;
|
||||
else chunks.push(chunk);
|
||||
} catch { /* ignore malformed lines */ }
|
||||
}
|
||||
|
||||
if (streamError) return { error: streamError };
|
||||
if (chunks.length === 0) return null;
|
||||
if (streamError) return { error: streamError };
|
||||
if (chunks.length === 0) return null;
|
||||
|
||||
const first = chunks[0];
|
||||
const contentParts = [];
|
||||
const reasoningParts = [];
|
||||
const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } }
|
||||
let finishReason = "stop";
|
||||
let usage = null;
|
||||
const first = chunks[0];
|
||||
const contentParts = [];
|
||||
const reasoningParts = [];
|
||||
const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } }
|
||||
let finishReason = "stop";
|
||||
let usage = null;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const choice = chunk?.choices?.[0];
|
||||
const delta = choice?.delta || {};
|
||||
if (typeof delta.content === "string" && delta.content.length > 0)
|
||||
contentParts.push(delta.content);
|
||||
if (
|
||||
typeof delta.reasoning_content === "string" &&
|
||||
delta.reasoning_content.length > 0
|
||||
)
|
||||
reasoningParts.push(delta.reasoning_content);
|
||||
if (choice?.finish_reason) finishReason = choice.finish_reason;
|
||||
if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage;
|
||||
for (const chunk of chunks) {
|
||||
const choice = chunk?.choices?.[0];
|
||||
const delta = choice?.delta || {};
|
||||
if (typeof delta.content === "string" && delta.content.length > 0) contentParts.push(delta.content);
|
||||
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) reasoningParts.push(delta.reasoning_content);
|
||||
if (choice?.finish_reason) finishReason = choice.finish_reason;
|
||||
if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage;
|
||||
|
||||
// Accumulate tool_calls from streaming deltas
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
if (!toolCallMap.has(idx)) {
|
||||
toolCallMap.set(idx, {
|
||||
id: tc.id || "",
|
||||
type: "function",
|
||||
function: { name: "", arguments: "" },
|
||||
});
|
||||
}
|
||||
const existing = toolCallMap.get(idx);
|
||||
if (tc.id) existing.id = tc.id;
|
||||
if (tc.function?.name) existing.function.name += tc.function.name;
|
||||
if (tc.function?.arguments)
|
||||
existing.function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Accumulate tool_calls from streaming deltas
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
if (!toolCallMap.has(idx)) {
|
||||
toolCallMap.set(idx, { id: tc.id || "", type: "function", function: { name: "", arguments: "" } });
|
||||
}
|
||||
const existing = toolCallMap.get(idx);
|
||||
if (tc.id) existing.id = tc.id;
|
||||
if (tc.function?.name) existing.function.name += tc.function.name;
|
||||
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: contentParts.join("") || (toolCallMap.size > 0 ? null : ""),
|
||||
};
|
||||
if (reasoningParts.length > 0)
|
||||
message.reasoning_content = reasoningParts.join("");
|
||||
if (toolCallMap.size > 0) {
|
||||
message.tool_calls = [...toolCallMap.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, tc]) => tc);
|
||||
}
|
||||
const message = { role: "assistant", content: contentParts.join("") || (toolCallMap.size > 0 ? null : "") };
|
||||
if (reasoningParts.length > 0) message.reasoning_content = reasoningParts.join("");
|
||||
if (toolCallMap.size > 0) {
|
||||
message.tool_calls = [...toolCallMap.entries()].sort((a, b) => a[0] - b[0]).map(([, tc]) => tc);
|
||||
}
|
||||
|
||||
const result = {
|
||||
id: first.id || `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: first.created || Math.floor(Date.now() / 1000),
|
||||
model: first.model || fallbackModel || "unknown",
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||
};
|
||||
if (usage) result.usage = usage;
|
||||
return result;
|
||||
const result = {
|
||||
id: first.id || `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: first.created || Math.floor(Date.now() / 1000),
|
||||
model: first.model || fallbackModel || "unknown",
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }]
|
||||
};
|
||||
if (usage) result.usage = usage;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle case: provider forced streaming but client wants JSON.
|
||||
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
|
||||
*/
|
||||
export async function handleForcedSSEToJson({
|
||||
providerResponse,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
provider,
|
||||
model,
|
||||
body,
|
||||
stream,
|
||||
translatedBody,
|
||||
finalBody,
|
||||
requestStartTime,
|
||||
connectionId,
|
||||
apiKey,
|
||||
clientRawRequest,
|
||||
onRequestSuccess,
|
||||
customToolNames,
|
||||
trackDone,
|
||||
appendLog,
|
||||
reqTag,
|
||||
log,
|
||||
streamErrorPatterns,
|
||||
}) {
|
||||
const contentType = providerResponse.headers.get("content-type") || "";
|
||||
const isSSE =
|
||||
contentType.includes("text/event-stream") ||
|
||||
(contentType === "" && isResponsesProvider(provider));
|
||||
if (!isSSE) return null; // not handled here
|
||||
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, targetFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, customToolNames, trackDone, appendLog, reqTag, log }) {
|
||||
const contentType = providerResponse.headers.get("content-type") || "";
|
||||
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
|
||||
if (!isSSE) return null; // not handled here
|
||||
|
||||
trackDone();
|
||||
trackDone();
|
||||
|
||||
const ctx = {
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
};
|
||||
const ctx = {
|
||||
provider, model, connectionId,
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null
|
||||
};
|
||||
|
||||
// Codex/Responses API SSE path
|
||||
// Branch on the UPSTREAM format (targetFormat = format we spoke to the provider in),
|
||||
// not the client format: a Responses-API client behind a chat-native forced-streaming
|
||||
// provider still receives chat SSE chunks, which must go through the standard path.
|
||||
const isCodexResponsesApi =
|
||||
isResponsesProvider(provider) || targetFormat === FORMATS.OPENAI_RESPONSES;
|
||||
if (isCodexResponsesApi) {
|
||||
try {
|
||||
const jsonResponse = await convertResponsesStreamToJson(
|
||||
providerResponse.body,
|
||||
);
|
||||
if (onRequestSuccess) await onRequestSuccess();
|
||||
// Codex/Responses API SSE path
|
||||
// Branch on the UPSTREAM format (targetFormat = format we spoke to the provider in),
|
||||
// not the client format: a Responses-API client behind a chat-native forced-streaming
|
||||
// provider still receives chat SSE chunks, which must go through the standard path.
|
||||
const isCodexResponsesApi = isResponsesProvider(provider) || targetFormat === FORMATS.OPENAI_RESPONSES;
|
||||
if (isCodexResponsesApi) {
|
||||
try {
|
||||
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);
|
||||
if (onRequestSuccess) await onRequestSuccess();
|
||||
|
||||
const usage = jsonResponse.usage || {};
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({
|
||||
provider,
|
||||
model,
|
||||
tokens: usage,
|
||||
connectionId,
|
||||
apiKey,
|
||||
endpoint: clientRawRequest?.endpoint,
|
||||
silent: true,
|
||||
});
|
||||
if (log?.line)
|
||||
log.line(
|
||||
reqTag,
|
||||
"📊",
|
||||
formatDoneLine({
|
||||
usage,
|
||||
latency: { total: Date.now() - requestStartTime },
|
||||
}),
|
||||
);
|
||||
const usage = jsonResponse.usage || {};
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
|
||||
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
|
||||
|
||||
// Same cache-inclusive total for the recorded detail, so the DB and the
|
||||
// client-facing usage can never disagree.
|
||||
const inTokensForLog = (usage.input_tokens || 0)
|
||||
+ (usage.cache_read_input_tokens || usage.cached_tokens || 0)
|
||||
+ (usage.cache_creation_input_tokens || 0);
|
||||
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(
|
||||
jsonResponse.output,
|
||||
);
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
// Same cache-inclusive total for the recorded detail, so the DB and the
|
||||
// client-facing usage can never disagree.
|
||||
const inTokensForLog = (usage.input_tokens || 0)
|
||||
+ (usage.cache_read_input_tokens || usage.cached_tokens || 0)
|
||||
+ (usage.cache_creation_input_tokens || 0);
|
||||
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
|
||||
saveRequestDetail(
|
||||
buildRequestDetail(
|
||||
{
|
||||
...ctx,
|
||||
apiKey,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: {
|
||||
prompt_tokens: inTokensForLog,
|
||||
completion_tokens: usage.output_tokens || 0,
|
||||
},
|
||||
response: {
|
||||
content: textContent,
|
||||
thinking: null,
|
||||
finish_reason: jsonResponse.status || "unknown",
|
||||
},
|
||||
status: "success",
|
||||
},
|
||||
{ endpoint: clientRawRequest?.endpoint || null },
|
||||
),
|
||||
).catch(() => {});
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
...ctx,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: { prompt_tokens: inTokensForLog, completion_tokens: usage.output_tokens || 0 },
|
||||
response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" },
|
||||
status: "success"
|
||||
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {});
|
||||
|
||||
// Client is Responses API → return as-is
|
||||
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(jsonResponse), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Client is Responses API → return as-is
|
||||
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||
}
|
||||
|
||||
// Build client-format response.
|
||||
// input_tokens EXCLUDES cached tokens on cache-capable upstreams, so summing
|
||||
// only input+output under-reports prompt_tokens — measured: 2012 reported
|
||||
// where the real prompt was ~5344 with 5332 served from cache. Fold the cache
|
||||
// counters in, and keep them visible in prompt_tokens_details so a client can
|
||||
// tell a cache hit from a small prompt.
|
||||
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || 0;
|
||||
const cacheCreate = usage.cache_creation_input_tokens || 0;
|
||||
const inTokens = (usage.input_tokens || 0) + cacheRead + cacheCreate;
|
||||
const outTokens = usage.output_tokens || 0;
|
||||
const cacheDetails = (cacheRead > 0 || cacheCreate > 0)
|
||||
? {
|
||||
prompt_tokens_details: {
|
||||
...(cacheRead > 0 ? { cached_tokens: cacheRead } : {}),
|
||||
...(cacheCreate > 0 ? { cache_creation_tokens: cacheCreate } : {}) } }
|
||||
: {};
|
||||
let finalResp;
|
||||
// Build client-format response.
|
||||
// input_tokens EXCLUDES cached tokens on cache-capable upstreams, so summing
|
||||
// only input+output under-reports prompt_tokens — measured: 2012 reported
|
||||
// where the real prompt was ~5344 with 5332 served from cache. Fold the cache
|
||||
// counters in, and keep them visible in prompt_tokens_details so a client can
|
||||
// tell a cache hit from a small prompt.
|
||||
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || 0;
|
||||
const cacheCreate = usage.cache_creation_input_tokens || 0;
|
||||
const inTokens = (usage.input_tokens || 0) + cacheRead + cacheCreate;
|
||||
const outTokens = usage.output_tokens || 0;
|
||||
const cacheDetails = (cacheRead > 0 || cacheCreate > 0)
|
||||
? { prompt_tokens_details: {
|
||||
...(cacheRead > 0 ? { cached_tokens: cacheRead } : {}),
|
||||
...(cacheCreate > 0 ? { cache_creation_tokens: cacheCreate } : {}) } }
|
||||
: {};
|
||||
let finalResp;
|
||||
|
||||
// Extract tool calls from Responses API output (function_call items)
|
||||
const funcCallItems = (jsonResponse.output || []).filter(
|
||||
(item) => item.type === "function_call",
|
||||
);
|
||||
const toolCalls = funcCallItems.map((item, idx) => ({
|
||||
id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: item.name,
|
||||
arguments:
|
||||
typeof item.arguments === "string"
|
||||
? item.arguments
|
||||
: JSON.stringify(item.arguments || {}),
|
||||
},
|
||||
}));
|
||||
const hasToolCalls = toolCalls.length > 0;
|
||||
// Extract tool calls from Responses API output (function_call items)
|
||||
const funcCallItems = (jsonResponse.output || []).filter(item => item.type === "function_call");
|
||||
const toolCalls = funcCallItems.map((item, idx) => ({
|
||||
id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: item.name,
|
||||
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {})
|
||||
}
|
||||
}));
|
||||
const hasToolCalls = toolCalls.length > 0;
|
||||
|
||||
if (
|
||||
sourceFormat === FORMATS.ANTIGRAVITY ||
|
||||
sourceFormat === FORMATS.GEMINI ||
|
||||
sourceFormat === FORMATS.GEMINI_CLI
|
||||
) {
|
||||
finalResp = {
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [{ text: textContent || "" }],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: inTokens,
|
||||
candidatesTokenCount: outTokens,
|
||||
totalTokenCount: inTokens + outTokens,
|
||||
},
|
||||
modelVersion: model,
|
||||
responseId: jsonResponse.id || `resp_${Date.now()}`,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: textContent || (hasToolCalls ? null : ""),
|
||||
};
|
||||
if (hasToolCalls) message.tool_calls = toolCalls;
|
||||
const responseDone =
|
||||
jsonResponse.status === "completed" || jsonResponse.status === "done";
|
||||
const finishReason = hasToolCalls
|
||||
? "tool_calls"
|
||||
: responseDone
|
||||
? "stop"
|
||||
: jsonResponse.status || "stop";
|
||||
finalResp = {
|
||||
id: jsonResponse.id || `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: jsonResponse.created_at || Math.floor(Date.now() / 1000),
|
||||
model: jsonResponse.model || model,
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||
usage: {
|
||||
prompt_tokens: inTokens,
|
||||
completion_tokens: outTokens,
|
||||
total_tokens: inTokens + outTokens,
|
||||
...cacheDetails,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI) {
|
||||
finalResp = {
|
||||
response: {
|
||||
candidates: [{ content: { role: "model", parts: [{ text: textContent || "" }] }, finishReason: "STOP", index: 0 }],
|
||||
usageMetadata: { promptTokenCount: inTokens, candidatesTokenCount: outTokens, totalTokenCount: inTokens + outTokens },
|
||||
modelVersion: model,
|
||||
responseId: jsonResponse.id || `resp_${Date.now()}`
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") };
|
||||
if (hasToolCalls) message.tool_calls = toolCalls;
|
||||
const responseDone = jsonResponse.status === "completed" || jsonResponse.status === "done";
|
||||
const finishReason = hasToolCalls ? "tool_calls" : (responseDone ? "stop" : (jsonResponse.status || "stop"));
|
||||
finalResp = {
|
||||
id: jsonResponse.id || `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: jsonResponse.created_at || Math.floor(Date.now() / 1000),
|
||||
model: jsonResponse.model || model,
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||
usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens, ...cacheDetails }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(finalResp), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[ChatCore] Responses API SSE→JSON failed:", err);
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
"Failed to convert streaming response to JSON",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { success: true, response: new Response(JSON.stringify(finalResp), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||
} catch (err) {
|
||||
console.error("[ChatCore] Responses API SSE→JSON failed:", err);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
||||
}
|
||||
}
|
||||
|
||||
// Standard Chat Completions SSE path
|
||||
try {
|
||||
const sseText = await providerResponse.text();
|
||||
const parsed = parseSSEToOpenAIResponse(sseText, model);
|
||||
if (!parsed)
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
"Invalid SSE response for non-streaming request",
|
||||
);
|
||||
if (parsed.error) {
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
parsed.error.message || "Upstream SSE stream failed",
|
||||
);
|
||||
}
|
||||
// Standard Chat Completions SSE path
|
||||
try {
|
||||
const sseText = await providerResponse.text();
|
||||
const parsed = parseSSEToOpenAIResponse(sseText, model);
|
||||
if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
||||
if (parsed.error) {
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
parsed.error.message || "Upstream SSE stream failed"
|
||||
);
|
||||
}
|
||||
|
||||
// Config-driven in-stream error detection: the request "succeeded" at the
|
||||
// HTTP level, but the content signals an upstream failure — treat it as an
|
||||
// error so account/combo fallback and FAILED logging kick in.
|
||||
const matchedPattern = matchStreamErrorPatterns(
|
||||
streamErrorPatterns?.[provider],
|
||||
parsed.choices?.[0]?.message?.content || "",
|
||||
);
|
||||
if (matchedPattern) {
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
`Stream error pattern matched: ${matchedPattern}`,
|
||||
);
|
||||
}
|
||||
if (onRequestSuccess) await onRequestSuccess();
|
||||
|
||||
if (onRequestSuccess) await onRequestSuccess();
|
||||
const usage = parsed.usage || {};
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
|
||||
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
|
||||
|
||||
const usage = parsed.usage || {};
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({
|
||||
provider,
|
||||
model,
|
||||
tokens: usage,
|
||||
connectionId,
|
||||
apiKey,
|
||||
endpoint: clientRawRequest?.endpoint,
|
||||
silent: true,
|
||||
});
|
||||
if (log?.line)
|
||||
log.line(
|
||||
reqTag,
|
||||
"📊",
|
||||
formatDoneLine({
|
||||
usage,
|
||||
latency: { total: Date.now() - requestStartTime },
|
||||
}),
|
||||
);
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
...ctx,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: usage,
|
||||
response: {
|
||||
content: parsed.choices?.[0]?.message?.content || null,
|
||||
thinking: parsed.choices?.[0]?.message?.reasoning_content || null,
|
||||
finish_reason: parsed.choices?.[0]?.finish_reason || "unknown"
|
||||
},
|
||||
status: "success"
|
||||
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {});
|
||||
|
||||
// Re-attach usage explicitly. This handler already HAS the correct usage — it is
|
||||
// the same object written to the usage DB, and for a cached Claude request that DB
|
||||
// row reads cache_read_input_tokens: 11022 — yet the client was observed receiving
|
||||
// no usage field at all (verified 2026-08-04 with a fingerprinted payload matched
|
||||
// on both sides). Whatever drops it between assembly and serialisation, the client
|
||||
// must not be left unable to account for its own token spend: a caller cannot tell
|
||||
// a 90%-cached request from a cheap one without this.
|
||||
if (usage && Object.keys(usage).length > 0) parsed.usage = usage;
|
||||
// Re-attach usage explicitly. This handler already HAS the correct usage — it is
|
||||
// the same object written to the usage DB, and for a cached Claude request that DB
|
||||
// row reads cache_read_input_tokens: 11022 — yet the client was observed receiving
|
||||
// no usage field at all (verified 2026-08-04 with a fingerprinted payload matched
|
||||
// on both sides). Whatever drops it between assembly and serialisation, the client
|
||||
// must not be left unable to account for its own token spend: a caller cannot tell
|
||||
// a 90%-cached request from a cheap one without this.
|
||||
if (usage && Object.keys(usage).length > 0) parsed.usage = usage;
|
||||
|
||||
// Strip reasoning_content only when content is non-empty.
|
||||
// When content is empty (e.g. thinking models that used all tokens for reasoning),
|
||||
// reasoning_content is the only useful output and must be preserved.
|
||||
// Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc.
|
||||
if (parsed?.choices) {
|
||||
for (const choice of parsed.choices) {
|
||||
if (choice?.message?.reasoning_content && choice.message.content) {
|
||||
delete choice.message.reasoning_content;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strip reasoning_content only when content is non-empty.
|
||||
// When content is empty (e.g. thinking models that used all tokens for reasoning),
|
||||
// reasoning_content is the only useful output and must be preserved.
|
||||
// Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc.
|
||||
if (parsed?.choices) {
|
||||
for (const choice of parsed.choices) {
|
||||
if (choice?.message?.reasoning_content && choice.message.content) {
|
||||
delete choice.message.reasoning_content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Responses-format client (e.g. Codex) forced this provider to stream,
|
||||
// but wants JSON back. parseSSEToOpenAIResponse yields a Chat Completions
|
||||
// body; convert it to the Responses `output` shape so tool_calls are not
|
||||
// lost on the non-streaming return path. Inlined (not imported from
|
||||
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
|
||||
// already imports parseSSEToOpenAIResponse from this module.
|
||||
const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||
? chatCompletionToResponses(parsed, customToolNames)
|
||||
: parsed;
|
||||
// A Responses-format client (e.g. Codex) forced this provider to stream,
|
||||
// but wants JSON back. parseSSEToOpenAIResponse yields a Chat Completions
|
||||
// body; convert it to the Responses `output` shape so tool_calls are not
|
||||
// lost on the non-streaming return path. Inlined (not imported from
|
||||
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
|
||||
// already imports parseSSEToOpenAIResponse from this module.
|
||||
const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||
? chatCompletionToResponses(parsed, customToolNames)
|
||||
: parsed;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(finalBody), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
"Failed to convert streaming response to JSON",
|
||||
);
|
||||
}
|
||||
return { success: true, response: new Response(JSON.stringify(finalBody), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||
} catch (err) {
|
||||
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,343 +1,144 @@
|
||||
import { FORMATS } from "../../translator/formats.js";
|
||||
import { needsTranslation } from "../../translator/index.js";
|
||||
import {
|
||||
createSSETransformStreamWithLogger,
|
||||
createPassthroughStreamWithLogger,
|
||||
} from "../../utils/stream.js";
|
||||
import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js";
|
||||
import { pipeWithDisconnect } from "../../utils/streamHandler.js";
|
||||
import { PROVIDERS } from "../../config/providers.js";
|
||||
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
|
||||
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
|
||||
import {
|
||||
buildRequestDetail,
|
||||
extractRequestConfig,
|
||||
saveUsageStats,
|
||||
formatDoneLine,
|
||||
} from "./requestDetail.js";
|
||||
import { streamStatusForContent } from "../../utils/streamErrorPatterns.js";
|
||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
||||
import { saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
|
||||
|
||||
// Codex returns Responses API SSE → which client format to translate INTO, by request sourceFormat.
|
||||
// Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI.
|
||||
const CODEX_SOURCE_TO_TARGET = {
|
||||
[FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES,
|
||||
[FORMATS.CLAUDE]: FORMATS.CLAUDE,
|
||||
[FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY,
|
||||
[FORMATS.GEMINI]: FORMATS.ANTIGRAVITY,
|
||||
[FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY,
|
||||
[FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES,
|
||||
[FORMATS.CLAUDE]: FORMATS.CLAUDE,
|
||||
[FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY,
|
||||
[FORMATS.GEMINI]: FORMATS.ANTIGRAVITY,
|
||||
[FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY,
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine which SSE transform stream to use based on provider/format.
|
||||
*/
|
||||
function buildTransformStream({
|
||||
provider,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
userAgent,
|
||||
reqLogger,
|
||||
toolNameMap,
|
||||
customToolNames,
|
||||
model,
|
||||
connectionId,
|
||||
body,
|
||||
onStreamComplete,
|
||||
apiKey,
|
||||
}) {
|
||||
const isDroidCLI =
|
||||
userAgent?.toLowerCase().includes("droid") ||
|
||||
userAgent?.toLowerCase().includes("codex-cli");
|
||||
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
|
||||
const isResponsesProvider =
|
||||
PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
||||
const needsCodexTranslation =
|
||||
isResponsesProvider &&
|
||||
targetFormat === FORMATS.OPENAI_RESPONSES &&
|
||||
!isDroidCLI;
|
||||
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey }) {
|
||||
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
|
||||
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
|
||||
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
||||
const needsCodexTranslation = isResponsesProvider && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI;
|
||||
|
||||
if (needsCodexTranslation) {
|
||||
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
|
||||
return createSSETransformStreamWithLogger(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
codexTarget,
|
||||
provider,
|
||||
reqLogger,
|
||||
toolNameMap,
|
||||
model,
|
||||
connectionId,
|
||||
body,
|
||||
onStreamComplete,
|
||||
apiKey,
|
||||
customToolNames,
|
||||
);
|
||||
}
|
||||
if (needsCodexTranslation) {
|
||||
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
|
||||
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
|
||||
}
|
||||
|
||||
if (needsTranslation(targetFormat, sourceFormat)) {
|
||||
return createSSETransformStreamWithLogger(
|
||||
targetFormat,
|
||||
sourceFormat,
|
||||
provider,
|
||||
reqLogger,
|
||||
toolNameMap,
|
||||
model,
|
||||
connectionId,
|
||||
body,
|
||||
onStreamComplete,
|
||||
apiKey,
|
||||
customToolNames,
|
||||
);
|
||||
}
|
||||
if (needsTranslation(targetFormat, sourceFormat)) {
|
||||
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
|
||||
}
|
||||
|
||||
return createPassthroughStreamWithLogger(
|
||||
provider,
|
||||
reqLogger,
|
||||
model,
|
||||
connectionId,
|
||||
body,
|
||||
onStreamComplete,
|
||||
apiKey,
|
||||
);
|
||||
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle streaming response — pipe provider SSE through transform stream to client.
|
||||
*/
|
||||
export async function handleStreamingResponse({
|
||||
providerResponse,
|
||||
provider,
|
||||
model,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
userAgent,
|
||||
body,
|
||||
stream,
|
||||
translatedBody,
|
||||
finalBody,
|
||||
requestStartTime,
|
||||
connectionId,
|
||||
apiKey,
|
||||
clientRawRequest,
|
||||
onRequestSuccess,
|
||||
reqLogger,
|
||||
toolNameMap,
|
||||
customToolNames,
|
||||
streamController,
|
||||
onStreamComplete,
|
||||
streamDetailId,
|
||||
pxpipe,
|
||||
reqTag,
|
||||
log,
|
||||
}) {
|
||||
if (onRequestSuccess) {
|
||||
Promise.resolve()
|
||||
.then(onRequestSuccess)
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
"[ChatCore] onRequestSuccess failed:",
|
||||
err?.message || err,
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) {
|
||||
if (onRequestSuccess) {
|
||||
Promise.resolve()
|
||||
.then(onRequestSuccess)
|
||||
.catch(err => {
|
||||
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
|
||||
});
|
||||
}
|
||||
|
||||
// When upstream returns HTML/text instead of SSE (e.g. Cloudflare 5xx error
|
||||
// page), piping it through the SSE transform stream causes Next.js
|
||||
// "failed to pipe response" and crashes the chat router. Read the body,
|
||||
// pull a short human-readable message from the <title>, sanitize it, and
|
||||
// return a clean JSON error instead. The message is stripped of HTML tags
|
||||
// and clamped so untrusted upstream text never reaches the client verbatim
|
||||
// (the UI may render error.message as HTML).
|
||||
const upstreamContentType = (
|
||||
providerResponse.headers.get("content-type") || ""
|
||||
).toLowerCase();
|
||||
if (
|
||||
upstreamContentType &&
|
||||
!upstreamContentType.includes("text/event-stream") &&
|
||||
!upstreamContentType.includes("application/json")
|
||||
) {
|
||||
const bodyText = await providerResponse.text().catch(() => "");
|
||||
const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i);
|
||||
const sanitizedTitle = (titleMatch?.[1] || "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 160);
|
||||
const shortMsg =
|
||||
sanitizedTitle ||
|
||||
(bodyText.length < 200
|
||||
? bodyText
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.trim()
|
||||
.slice(0, 160)
|
||||
: `Upstream returned non-SSE response (${upstreamContentType})`);
|
||||
const status = providerResponse.status || 502;
|
||||
if (log?.errorLine)
|
||||
log.errorLine(
|
||||
reqTag,
|
||||
"✗",
|
||||
`BLOCKED ${status} · ${provider}/${model} · non-SSE (${upstreamContentType})\n ${shortMsg}`,
|
||||
);
|
||||
else
|
||||
console.warn(
|
||||
`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`,
|
||||
);
|
||||
streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`));
|
||||
return {
|
||||
success: false,
|
||||
response: new Response(
|
||||
JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }),
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
// When upstream returns HTML/text instead of SSE (e.g. Cloudflare 5xx error
|
||||
// page), piping it through the SSE transform stream causes Next.js
|
||||
// "failed to pipe response" and crashes the chat router. Read the body,
|
||||
// pull a short human-readable message from the <title>, sanitize it, and
|
||||
// return a clean JSON error instead. The message is stripped of HTML tags
|
||||
// and clamped so untrusted upstream text never reaches the client verbatim
|
||||
// (the UI may render error.message as HTML).
|
||||
const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase();
|
||||
if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) {
|
||||
const bodyText = await providerResponse.text().catch(() => '');
|
||||
const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i);
|
||||
const sanitizedTitle = (titleMatch?.[1] || '').replace(/<[^>]*>/g, '').replace(/[\r\n]+/g, ' ').trim().slice(0, 160);
|
||||
const shortMsg = sanitizedTitle
|
||||
|| (bodyText.length < 200 ? bodyText.replace(/<[^>]*>/g, '').trim().slice(0, 160) : `Upstream returned non-SSE response (${upstreamContentType})`);
|
||||
const status = providerResponse.status || 502;
|
||||
if (log?.errorLine) log.errorLine(reqTag, "✗", `BLOCKED ${status} · ${provider}/${model} · non-SSE (${upstreamContentType})\n ${shortMsg}`);
|
||||
else console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`);
|
||||
streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`));
|
||||
return {
|
||||
success: false,
|
||||
response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const transformStream = buildTransformStream({
|
||||
provider,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
userAgent,
|
||||
reqLogger,
|
||||
toolNameMap,
|
||||
customToolNames,
|
||||
model,
|
||||
connectionId,
|
||||
body,
|
||||
onStreamComplete,
|
||||
apiKey,
|
||||
});
|
||||
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey });
|
||||
|
||||
// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
|
||||
const isResponsesPassthrough =
|
||||
sourceFormat === FORMATS.OPENAI_RESPONSES &&
|
||||
targetFormat === FORMATS.OPENAI_RESPONSES;
|
||||
const onAbortTerminal = isResponsesPassthrough
|
||||
? buildAbortedResponsesTerminalBytes
|
||||
: null;
|
||||
const stallTimeoutMs =
|
||||
PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS;
|
||||
const transformedBody = pipeWithDisconnect(
|
||||
providerResponse,
|
||||
transformStream,
|
||||
streamController,
|
||||
onAbortTerminal,
|
||||
stallTimeoutMs,
|
||||
);
|
||||
// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
|
||||
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
|
||||
const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null;
|
||||
const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS;
|
||||
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs);
|
||||
|
||||
saveRequestDetail(
|
||||
buildRequestDetail(
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
apiKey,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: "[Streaming - raw response not captured]",
|
||||
response: {
|
||||
content: "[Streaming in progress...]",
|
||||
thinking: null,
|
||||
type: "streaming",
|
||||
},
|
||||
pxpipe,
|
||||
status: "success",
|
||||
},
|
||||
{ id: streamDetailId },
|
||||
),
|
||||
).catch((err) => {
|
||||
console.error(
|
||||
"[RequestDetail] Failed to save streaming request:",
|
||||
err.message,
|
||||
);
|
||||
});
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: "[Streaming - raw response not captured]",
|
||||
response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" },
|
||||
pxpipe,
|
||||
status: "success"
|
||||
}, { id: streamDetailId })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to save streaming request:", err.message);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(transformedBody, { headers: SSE_HEADERS }),
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(transformedBody, { headers: SSE_HEADERS })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build onStreamComplete callback for streaming usage tracking.
|
||||
*/
|
||||
export function buildOnStreamComplete({
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
apiKey,
|
||||
requestStartTime,
|
||||
body,
|
||||
stream,
|
||||
finalBody,
|
||||
translatedBody,
|
||||
clientRawRequest,
|
||||
pxpipe,
|
||||
reqTag,
|
||||
log,
|
||||
streamErrorPatterns,
|
||||
}) {
|
||||
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, pxpipe, reqTag, log }) {
|
||||
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const onStreamComplete = (contentObj, usage, ttftAt) => {
|
||||
const latency = {
|
||||
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
|
||||
total: Date.now() - requestStartTime,
|
||||
};
|
||||
const safeContent = contentObj?.content || "[Empty streaming response]";
|
||||
const safeThinking = contentObj?.thinking || null;
|
||||
const onStreamComplete = (contentObj, usage, ttftAt) => {
|
||||
const latency = {
|
||||
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
|
||||
total: Date.now() - requestStartTime
|
||||
};
|
||||
const safeContent = contentObj?.content || "[Empty streaming response]";
|
||||
const safeThinking = contentObj?.thinking || null;
|
||||
|
||||
saveRequestDetail(
|
||||
buildRequestDetail(
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
apiKey,
|
||||
latency,
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: safeContent,
|
||||
response: {
|
||||
content: safeContent,
|
||||
thinking: safeThinking,
|
||||
type: "streaming",
|
||||
},
|
||||
pxpipe,
|
||||
status: streamStatusForContent(
|
||||
streamErrorPatterns?.[provider],
|
||||
safeContent,
|
||||
),
|
||||
},
|
||||
{ id: streamDetailId },
|
||||
),
|
||||
).catch((err) => {
|
||||
console.error(
|
||||
"[RequestDetail] Failed to update streaming content:",
|
||||
err.message,
|
||||
);
|
||||
});
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency,
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: safeContent,
|
||||
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
|
||||
pxpipe,
|
||||
status: "success"
|
||||
}, { id: streamDetailId })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
||||
});
|
||||
|
||||
// Persist stream usage to DB (no console line; the "📊 done" line below is authoritative)
|
||||
saveUsageStats({
|
||||
provider,
|
||||
model,
|
||||
tokens: usage,
|
||||
connectionId,
|
||||
apiKey,
|
||||
endpoint: clientRawRequest?.endpoint,
|
||||
label: "STREAM USAGE",
|
||||
silent: true,
|
||||
});
|
||||
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency }));
|
||||
};
|
||||
// Persist stream usage to DB (no console line; the "📊 done" line below is authoritative)
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE", silent: true });
|
||||
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency }));
|
||||
};
|
||||
|
||||
return { onStreamComplete, streamDetailId };
|
||||
return { onStreamComplete, streamDetailId };
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function handleImageGenerationCore({
|
||||
let requestBody;
|
||||
|
||||
try {
|
||||
url = adapter.buildUrl(model, credentials, body);
|
||||
url = adapter.buildUrl(model, credentials);
|
||||
requestBody = await adapter.buildBody(model, body);
|
||||
headers = adapter.buildHeaders(credentials, requestBody, model, body);
|
||||
} catch (error) {
|
||||
@@ -140,7 +140,7 @@ export async function handleImageGenerationCore({
|
||||
try {
|
||||
const retryBody = await adapter.buildBody(model, body);
|
||||
const retryHeaders = adapter.buildHeaders(credentials, retryBody, model, body);
|
||||
const retryUrl = adapter.buildUrl(model, credentials, body);
|
||||
const retryUrl = adapter.buildUrl(model, credentials);
|
||||
providerResponse = await fetch(retryUrl, {
|
||||
method: "POST",
|
||||
headers: retryHeaders,
|
||||
|
||||
@@ -12,7 +12,6 @@ import blackForestLabs from "./blackForestLabs.js";
|
||||
import runwayml from "./runwayml.js";
|
||||
import cloudflareAi from "./cloudflareAi.js";
|
||||
import antigravity from "./antigravity.js";
|
||||
import xai from "./xai.js";
|
||||
|
||||
const ADAPTERS = {
|
||||
openai: createOpenAIAdapter("openai"),
|
||||
@@ -20,7 +19,7 @@ const ADAPTERS = {
|
||||
openrouter: createOpenAIAdapter("openrouter"),
|
||||
recraft: createOpenAIAdapter("recraft"),
|
||||
"vercel-ai-gateway": createOpenAIAdapter("vercel-ai-gateway"),
|
||||
xai,
|
||||
xai: createOpenAIAdapter("xai"),
|
||||
gemini,
|
||||
codex,
|
||||
sdwebui,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// xAI Grok Imagine — text-to-image + single/multi image editing
|
||||
// Docs:
|
||||
// https://docs.x.ai/developers/model-capabilities/images/generation
|
||||
// https://docs.x.ai/developers/model-capabilities/images/editing
|
||||
// https://docs.x.ai/developers/model-capabilities/images/multi-image-editing
|
||||
import { sizeToAspectRatio } from "./_base.js";
|
||||
import { PROVIDER_MEDIA } from "../../providers/index.js";
|
||||
|
||||
const IMG_CFG = PROVIDER_MEDIA["xai"]?.imageConfig || {};
|
||||
const GENERATIONS_URL = IMG_CFG.baseUrl || "https://api.x.ai/v1/images/generations";
|
||||
const EDITS_URL = IMG_CFG.editsUrl || "https://api.x.ai/v1/images/edits";
|
||||
|
||||
const ASPECT_RATIOS = new Set([
|
||||
"auto",
|
||||
"1:1",
|
||||
"16:9",
|
||||
"9:16",
|
||||
"4:3",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"9:19.5",
|
||||
"20:9",
|
||||
]);
|
||||
|
||||
function hasEditInput(body) {
|
||||
if (!body || typeof body !== "object") return false;
|
||||
if (body.image) return true;
|
||||
return Array.isArray(body.images) && body.images.some(Boolean);
|
||||
}
|
||||
|
||||
/** Normalize client image input → xAI image ref object */
|
||||
function toXaiImageRef(input) {
|
||||
if (!input) return null;
|
||||
|
||||
if (typeof input === "object") {
|
||||
// Already xAI-shaped or partial
|
||||
if (input.file_id) {
|
||||
return {
|
||||
type: input.type || "image_url",
|
||||
file_id: input.file_id,
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
};
|
||||
}
|
||||
if (input.url) {
|
||||
return { type: input.type || "image_url", url: input.url };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof input !== "string") return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Public URL or data URI
|
||||
if (/^https?:\/\//i.test(trimmed) || /^data:image\//i.test(trimmed)) {
|
||||
return { type: "image_url", url: trimmed };
|
||||
}
|
||||
|
||||
// Raw base64 → data URI
|
||||
return { type: "image_url", url: `data:image/png;base64,${trimmed}` };
|
||||
}
|
||||
|
||||
function collectImageRefs(body) {
|
||||
const refs = [];
|
||||
if (Array.isArray(body.images)) {
|
||||
for (const item of body.images) {
|
||||
const ref = toXaiImageRef(item);
|
||||
if (ref) refs.push(ref);
|
||||
}
|
||||
}
|
||||
if (body.image) {
|
||||
const ref = toXaiImageRef(body.image);
|
||||
if (ref) refs.push(ref);
|
||||
}
|
||||
// xAI multi-edit supports up to 3 source images
|
||||
return refs.slice(0, 3);
|
||||
}
|
||||
|
||||
function resolveAspectRatio(body) {
|
||||
if (typeof body.aspect_ratio === "string" && body.aspect_ratio.trim()) {
|
||||
const ratio = body.aspect_ratio.trim();
|
||||
if (ASPECT_RATIOS.has(ratio)) return ratio;
|
||||
// Pass through unknown ratio strings (upstream will validate)
|
||||
return ratio;
|
||||
}
|
||||
// OpenAI-style size → aspect ratio (skip auto)
|
||||
if (body.size && body.size !== "auto") {
|
||||
return sizeToAspectRatio(body.size);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveResolution(body) {
|
||||
if (typeof body.resolution !== "string") return undefined;
|
||||
const value = body.resolution.trim().toLowerCase();
|
||||
if (!value || value === "auto") return undefined;
|
||||
return value; // "1k" | "2k"
|
||||
}
|
||||
|
||||
export default {
|
||||
buildUrl: (_model, _credentials, body) => (hasEditInput(body) ? EDITS_URL : GENERATIONS_URL),
|
||||
|
||||
buildHeaders: (creds) => {
|
||||
const headers = { "Content-Type": "application/json", ...(IMG_CFG.headers || {}) };
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
if (key) headers["Authorization"] = `Bearer ${key}`;
|
||||
return headers;
|
||||
},
|
||||
|
||||
buildBody: (model, body) => {
|
||||
const req = {
|
||||
model,
|
||||
prompt: body.prompt,
|
||||
};
|
||||
|
||||
if (body.n != null) req.n = body.n;
|
||||
if (body.response_format) req.response_format = body.response_format;
|
||||
|
||||
const aspectRatio = resolveAspectRatio(body);
|
||||
if (aspectRatio) req.aspect_ratio = aspectRatio;
|
||||
|
||||
const resolution = resolveResolution(body);
|
||||
if (resolution) req.resolution = resolution;
|
||||
|
||||
const refs = collectImageRefs(body);
|
||||
if (refs.length === 1) {
|
||||
req.image = refs[0];
|
||||
} else if (refs.length > 1) {
|
||||
req.images = refs;
|
||||
}
|
||||
|
||||
return req;
|
||||
},
|
||||
|
||||
// xAI already returns OpenAI-compatible { created, data: [{ url | b64_json }] }
|
||||
normalize: (responseBody) => responseBody,
|
||||
};
|
||||
@@ -23,24 +23,9 @@ export default {
|
||||
format: "commandcode",
|
||||
forceStream: true,
|
||||
headers: {
|
||||
"x-command-code-version": "1.10.0",
|
||||
"x-command-code-version": "0.25.7",
|
||||
"x-cli-environment": "cli",
|
||||
"User-Agent": "cli",
|
||||
},
|
||||
// Quota/billing endpoints (same alpha API the official CLI /usage calls).
|
||||
// whoami resolves orgId; credits+subscription+usage/summary then report the
|
||||
// 5-hour/weekly windows, plan, and period credits. See services/usage/commandcode.js.
|
||||
usage: {
|
||||
baseUrl: "https://api.commandcode.ai",
|
||||
whoamiUrl: "/alpha/whoami",
|
||||
creditsUrl: "/alpha/billing/credits",
|
||||
subscriptionsUrl: "/alpha/billing/subscriptions",
|
||||
summaryUrl: "/alpha/usage/summary",
|
||||
},
|
||||
},
|
||||
features: {
|
||||
usage: true,
|
||||
usageApikey: true,
|
||||
},
|
||||
models: [
|
||||
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
|
||||
@@ -25,48 +25,17 @@ export default {
|
||||
clientId: "b1a00492-073a-47ea-816f-4c329264a828",
|
||||
tokenUrl: "https://auth.x.ai/oauth2/token",
|
||||
refreshUrl: "https://auth.x.ai/oauth2/token",
|
||||
// OAuth-only SuperGrok quota surfaces:
|
||||
// - url: monthly API usage allotment (JSON)
|
||||
// - creditsUrl: weekly SuperGrok limit (grpc-web)
|
||||
// - settingsUrl: plan label (subscription_tier_display)
|
||||
usage: {
|
||||
url: "https://cli-chat-proxy.grok.com/v1/billing",
|
||||
creditsUrl: "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig",
|
||||
settingsUrl: "https://cli-chat-proxy.grok.com/v1/settings",
|
||||
},
|
||||
},
|
||||
models: [
|
||||
{ id: "grok-4", name: "Grok 4" },
|
||||
{ id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" },
|
||||
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
|
||||
{ id: "grok-3", name: "Grok 3" },
|
||||
{
|
||||
id: "grok-imagine-image-quality",
|
||||
name: "Grok Imagine Image Quality",
|
||||
capabilities: ["text2img", "edit"],
|
||||
params: ["n", "aspect_ratio", "resolution", "response_format", "size"],
|
||||
kind: "image",
|
||||
},
|
||||
{
|
||||
id: "grok-2-image-1212",
|
||||
name: "Grok 2 Image",
|
||||
capabilities: ["text2img", "edit"],
|
||||
params: ["n", "aspect_ratio", "resolution", "response_format", "size"],
|
||||
kind: "image",
|
||||
},
|
||||
{
|
||||
id: "grok-imagine-video",
|
||||
name: "Grok Imagine Video",
|
||||
params: ["duration", "aspect_ratio", "resolution"],
|
||||
kind: "video",
|
||||
},
|
||||
{ id: "grok-2-image-1212", name: "Grok 2 Image", params: ["n","response_format"], kind: "image" },
|
||||
{ id: "grok-imagine-video", name: "Grok Imagine Video", params: ["duration","aspect_ratio","resolution"], kind: "video" },
|
||||
],
|
||||
serviceKinds: ["llm", "imageToText", "webSearch", "image", "video"],
|
||||
imageConfig: {
|
||||
baseUrl: "https://api.x.ai/v1/images/generations",
|
||||
editsUrl: "https://api.x.ai/v1/images/edits",
|
||||
bodyFields: ["model", "prompt", "n", "response_format", "aspect_ratio", "resolution", "image", "images"],
|
||||
},
|
||||
serviceKinds: ["llm","imageToText","webSearch","image","video"],
|
||||
imageConfig: { baseUrl: "https://api.x.ai/v1/images/generations", bodyFields: ["model","prompt","n","response_format"] },
|
||||
// Async video jobs (POST returns { request_id }, GET polls until done/failed).
|
||||
// Docs: https://docs.x.ai/developers/rest-api-reference/inference/videos
|
||||
videoConfig: { baseUrl: "https://api.x.ai/v1/videos" },
|
||||
@@ -75,7 +44,4 @@ export default {
|
||||
endpoint: "https://api.x.ai/v1/responses",
|
||||
pricingUrl: "https://x.ai/api#pricing",
|
||||
},
|
||||
features: {
|
||||
usage: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Per-provider connect timeout overrides from user settings.
|
||||
* Settings are read from the DB lazily and cached with a short TTL
|
||||
* so UI changes take effect without requiring a restart.
|
||||
*/
|
||||
|
||||
let cached = {};
|
||||
let cacheTs = 0;
|
||||
const CACHE_TTL_MS = 10_000; // 10s — responsive enough for dashboard changes
|
||||
|
||||
async function refreshCache() {
|
||||
const now = Date.now();
|
||||
if (now - cacheTs < CACHE_TTL_MS && Object.keys(cached).length > 0) return cached;
|
||||
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/localDb");
|
||||
// Return full settings so we can read providerTimeouts + globalTimeoutMs
|
||||
cached = await getSettings();
|
||||
cacheTs = now;
|
||||
} catch {
|
||||
// If DB is unavailable, keep stale cache — don't throw on hot path
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective connect timeout for a provider.
|
||||
* Priority: per-provider override > global default timeout (settings) > registry config > env default.
|
||||
* @param {string} providerId
|
||||
* @param {number} configTimeoutMs - timeoutMs from the static provider registry config
|
||||
* @param {number} envDefaultMs - global default from env (FETCH_CONNECT_TIMEOUT_MS)
|
||||
* @returns {number} timeout in milliseconds
|
||||
*/
|
||||
export async function resolveProviderTimeoutMs(providerId, configTimeoutMs, envDefaultMs) {
|
||||
const overrides = await refreshCache();
|
||||
|
||||
// 1. Per-provider override (set in provider detail page)
|
||||
const providerOverride = overrides.providerTimeouts?.[providerId];
|
||||
if (providerOverride?.timeoutMs && Number.isFinite(providerOverride.timeoutMs) && providerOverride.timeoutMs > 0) {
|
||||
return providerOverride.timeoutMs;
|
||||
}
|
||||
|
||||
// 2. Global default timeout (set in Profile / Settings page)
|
||||
const globalDefault = overrides.defaultTimeoutMs;
|
||||
if (globalDefault && Number.isFinite(globalDefault) && globalDefault > 0) {
|
||||
return globalDefault;
|
||||
}
|
||||
|
||||
// 3. Registry per-provider config
|
||||
if (configTimeoutMs && Number.isFinite(configTimeoutMs) && configTimeoutMs > 0) {
|
||||
return configTimeoutMs;
|
||||
}
|
||||
|
||||
// 4. Env default
|
||||
return envDefaultMs;
|
||||
}
|
||||
@@ -11,11 +11,9 @@ export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits };
|
||||
import { getKiroUsage } from "./usage/kiro.js";
|
||||
import { getMiniMaxUsage } from "./usage/minimax.js";
|
||||
import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn.js";
|
||||
import { getXaiUsage } from "./usage/xai.js";
|
||||
import { getGrokCliUsage } from "./usage/grok-cli.js";
|
||||
import { getKimiUsage } from "./usage/kimi.js";
|
||||
import { getDeepseekUsage } from "./usage/deepseek.js";
|
||||
import { getCommandCodeUsage } from "./usage/commandcode.js";
|
||||
import { resolveQoderCredentials } from "./qoderModels.js";
|
||||
import {
|
||||
getIflowUsage,
|
||||
@@ -52,12 +50,10 @@ const USAGE_HANDLERS = {
|
||||
"minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
|
||||
"vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions),
|
||||
"codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||
xai: (c) => getXaiUsage(c.accessToken, c.proxyOptions),
|
||||
"codebuddy-intl": (c) => getCodeBuddyIntlUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
|
||||
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
||||
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions),
|
||||
};
|
||||
|
||||
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* CommandCode usage handler
|
||||
*
|
||||
* Mirrors the official command-code CLI /usage command: it calls the alpha API
|
||||
* to surface the 5-hour + weekly usage windows, the subscription plan, and the
|
||||
* credits consumed in the current billing period.
|
||||
*
|
||||
* GET /alpha/whoami → org.id (org-scoped billing; null for personal)
|
||||
* GET /alpha/billing/credits → { credits: { monthlyCredits, purchasedCredits,
|
||||
* freeCredits }, windowLimits: { fiveHour, weekly } }
|
||||
* GET /alpha/billing/subscriptions → { data: { planId, currentPeriodStart, ... } }
|
||||
* GET /alpha/usage/summary?since= → period token/cost totals
|
||||
*
|
||||
* The CLI fetches whoami first (for orgId), then credits + subscription in
|
||||
* parallel, then the summary with since = currentPeriodStart. We keep the same
|
||||
* order/dependencies: window limits live on credits, and the plan period start
|
||||
* determines the summary window.
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { U, parseResetTime } from "./shared.js";
|
||||
|
||||
const USAGE = U("commandcode");
|
||||
const BASE = USAGE.baseUrl || "https://api.commandcode.ai";
|
||||
const WHOAMI_URL = BASE + (USAGE.whoamiUrl || "/alpha/whoami");
|
||||
const CREDITS_URL = BASE + (USAGE.creditsUrl || "/alpha/billing/credits");
|
||||
const SUBSCRIPTIONS_URL =
|
||||
BASE + (USAGE.subscriptionsUrl || "/alpha/billing/subscriptions");
|
||||
const SUMMARY_URL = BASE + (USAGE.summaryUrl || "/alpha/usage/summary");
|
||||
|
||||
function buildHeaders(token) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a normalized quota row. `unit` is "$" — the API reports currency credits. */
|
||||
function makeQuota({ used, total, resetAt, unlimited = false, unit = "$" }) {
|
||||
const safeTotal = Math.max(0, Number(total) || 0);
|
||||
const safeUsed = Math.max(0, Number(used) || 0);
|
||||
if (unlimited || safeTotal === 0) {
|
||||
return {
|
||||
used: safeUsed,
|
||||
total: 0,
|
||||
remainingPercentage: unlimited ? 100 : 0,
|
||||
resetAt: resetAt || null,
|
||||
unit,
|
||||
unlimited: true,
|
||||
};
|
||||
}
|
||||
const remaining = Math.max(0, safeTotal - safeUsed);
|
||||
const remainingPercentage = (remaining / safeTotal) * 100;
|
||||
return {
|
||||
used: safeUsed,
|
||||
total: safeTotal,
|
||||
remainingPercentage,
|
||||
resetAt: resetAt || null,
|
||||
unit,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} apiKey - commandcode API key (user_...)
|
||||
* @param {object|null} proxyOptions
|
||||
*/
|
||||
export async function getCommandCodeUsage(apiKey, proxyOptions = null) {
|
||||
if (!apiKey) {
|
||||
return { message: "CommandCode credential not available." };
|
||||
}
|
||||
|
||||
const headers = buildHeaders(apiKey);
|
||||
|
||||
try {
|
||||
// whoami resolves the org id (billing is org-scoped; null for personal).
|
||||
const whoamiRes = await proxyAwareFetch(
|
||||
WHOAMI_URL,
|
||||
{ method: "GET", headers },
|
||||
proxyOptions,
|
||||
);
|
||||
if (whoamiRes.status === 401 || whoamiRes.status === 403) {
|
||||
return { message: "CommandCode credential invalid or expired." };
|
||||
}
|
||||
if (!whoamiRes.ok) {
|
||||
return { message: `CommandCode whoami API error (${whoamiRes.status}).` };
|
||||
}
|
||||
const whoami = await whoamiRes.json().catch(() => null);
|
||||
const orgId = whoami?.org?.id ?? null;
|
||||
|
||||
const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : "";
|
||||
|
||||
const [creditsRes, subsRes] = await Promise.all([
|
||||
proxyAwareFetch(
|
||||
CREDITS_URL + orgQuery,
|
||||
{ method: "GET", headers },
|
||||
proxyOptions,
|
||||
),
|
||||
proxyAwareFetch(
|
||||
SUBSCRIPTIONS_URL + orgQuery,
|
||||
{ method: "GET", headers },
|
||||
proxyOptions,
|
||||
),
|
||||
]);
|
||||
|
||||
if (
|
||||
creditsRes.status === 401 ||
|
||||
creditsRes.status === 403 ||
|
||||
subsRes.status === 401 ||
|
||||
subsRes.status === 403
|
||||
) {
|
||||
return { message: "CommandCode credential invalid or expired." };
|
||||
}
|
||||
if (!creditsRes.ok) {
|
||||
return {
|
||||
message: `CommandCode credits API error (${creditsRes.status}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const credits = await creditsRes.json().catch(() => null);
|
||||
const subs = await subsRes.json().catch(() => null);
|
||||
|
||||
const subData = subs?.data;
|
||||
const planId = subData?.planId ?? null;
|
||||
const periodStart = subData?.currentPeriodStart ?? null;
|
||||
|
||||
// Summary needs `since`; the CLI falls back to first-of-month when the
|
||||
// subscription period start is unavailable.
|
||||
const since = periodStart || firstOfMonth();
|
||||
const summaryRes = await proxyAwareFetch(
|
||||
`${SUMMARY_URL}?since=${encodeURIComponent(since)}`,
|
||||
{ method: "GET", headers },
|
||||
proxyOptions,
|
||||
);
|
||||
const summary = summaryRes.ok
|
||||
? await summaryRes.json().catch(() => null)
|
||||
: null;
|
||||
|
||||
const quotas = {};
|
||||
const windowLimits = credits?.windowLimits || {};
|
||||
|
||||
const fiveHour = windowLimits.fiveHour;
|
||||
if (fiveHour && Number(fiveHour.cap) > 0) {
|
||||
quotas["5-hour window"] = makeQuota({
|
||||
used: fiveHour.used,
|
||||
total: fiveHour.cap,
|
||||
resetAt: parseResetTime(fiveHour.resetAt),
|
||||
});
|
||||
}
|
||||
|
||||
const weekly = windowLimits.weekly;
|
||||
if (weekly && Number(weekly.cap) > 0) {
|
||||
quotas["Weekly window"] = makeQuota({
|
||||
used: weekly.used,
|
||||
total: weekly.cap,
|
||||
resetAt: parseResetTime(weekly.resetAt),
|
||||
});
|
||||
}
|
||||
|
||||
// Monthly credits consumed this billing period (from summary when present,
|
||||
// else the credits object's monthlyCredits as a fallback).
|
||||
const monthlyUsed =
|
||||
typeof summary?.totalCredits === "number"
|
||||
? summary.totalCredits
|
||||
: typeof credits?.credits?.monthlyCredits === "number"
|
||||
? credits.credits.monthlyCredits
|
||||
: 0;
|
||||
|
||||
const monthlyTotal =
|
||||
typeof credits?.credits?.monthlyCredits === "number"
|
||||
? credits.credits.monthlyCredits
|
||||
: 0;
|
||||
|
||||
if (monthlyTotal > 0 || monthlyUsed > 0) {
|
||||
quotas["Monthly credits"] = makeQuota({
|
||||
used: monthlyUsed,
|
||||
total: monthlyTotal,
|
||||
resetAt: periodStart ? undefined : null,
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
return {
|
||||
plan: planId || "CommandCode",
|
||||
message: "CommandCode connected, but no quota was reported.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: planId || "CommandCode",
|
||||
quotas,
|
||||
periodBasis: summary?.periodBasis || "billing-period",
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `CommandCode usage error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function firstOfMonth() {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* xAI (Grok) OAuth usage handler
|
||||
*
|
||||
* SuperGrok quota is split across two upstream surfaces (OAuth only):
|
||||
*
|
||||
* 1) Monthly / API usage allotment (JSON)
|
||||
* GET https://cli-chat-proxy.grok.com/v1/billing
|
||||
* {
|
||||
* "config": {
|
||||
* "monthlyLimit": { "val": 15000 },
|
||||
* "used": { "val": 733 },
|
||||
* "onDemandCap": { "val": 0 },
|
||||
* "billingPeriodStart": "...",
|
||||
* "billingPeriodEnd": "..."
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* 2) Weekly SuperGrok limit (grpc-web protobuf)
|
||||
* POST https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig
|
||||
* Empty request frame; response message1 contains:
|
||||
* usedPercent (float32), window start/end timestamps, nested windows.
|
||||
* This is what the grok.com usage page labels "Weekly limit" / "Resets …".
|
||||
*
|
||||
* Plan label comes from cli-chat-proxy settings:
|
||||
* GET https://cli-chat-proxy.grok.com/v1/settings → subscription_tier_display
|
||||
*
|
||||
* Note: grok.com/rest/rate-limits is a short chat-window (e.g. 2h query count)
|
||||
* behind Cloudflare browser cookies — not usable with pure OAuth bearer.
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { U, parseResetTime, toFiniteNumber } from "./shared.js";
|
||||
|
||||
// Empty grpc-web request frame: flag(0) + length(0) + no payload.
|
||||
const GRPC_WEB_EMPTY_FRAME = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||
|
||||
function moneyVal(wrapper) {
|
||||
if (wrapper == null) return null;
|
||||
if (typeof wrapper === "number") return toFiniteNumber(wrapper, null);
|
||||
if (typeof wrapper === "object" && wrapper.val != null) {
|
||||
return toFiniteNumber(wrapper.val, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function authHeaders(accessToken, extra = {}) {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function readVarint(buf, offset) {
|
||||
let val = 0;
|
||||
let shift = 0;
|
||||
let i = offset;
|
||||
while (i < buf.length) {
|
||||
const b = buf[i++];
|
||||
val |= (b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) return { value: val >>> 0, offset: i };
|
||||
shift += 7;
|
||||
if (shift > 35) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal protobuf decoder for GetGrokCreditsConfig.
|
||||
* Only understands varint / fixed32 / fixed64 / length-delimited.
|
||||
*/
|
||||
function parseProtobufFields(buf) {
|
||||
const fields = [];
|
||||
let i = 0;
|
||||
while (i < buf.length) {
|
||||
const key = readVarint(buf, i);
|
||||
if (!key) break;
|
||||
i = key.offset;
|
||||
const field = key.value >>> 3;
|
||||
const wt = key.value & 7;
|
||||
|
||||
if (wt === 0) {
|
||||
const v = readVarint(buf, i);
|
||||
if (!v) break;
|
||||
i = v.offset;
|
||||
fields.push({ field, type: "varint", value: v.value });
|
||||
} else if (wt === 1) {
|
||||
if (i + 8 > buf.length) break;
|
||||
fields.push({ field, type: "fixed64", value: buf.subarray(i, i + 8) });
|
||||
i += 8;
|
||||
} else if (wt === 5) {
|
||||
if (i + 4 > buf.length) break;
|
||||
fields.push({
|
||||
field,
|
||||
type: "fixed32",
|
||||
value: buf.readFloatLE(i),
|
||||
});
|
||||
i += 4;
|
||||
} else if (wt === 2) {
|
||||
const ln = readVarint(buf, i);
|
||||
if (!ln) break;
|
||||
i = ln.offset;
|
||||
if (i + ln.value > buf.length) break;
|
||||
fields.push({
|
||||
field,
|
||||
type: "bytes",
|
||||
value: buf.subarray(i, i + ln.value),
|
||||
});
|
||||
i += ln.value;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseTimestamp(bytes) {
|
||||
if (!bytes || !bytes.length) return null;
|
||||
const fields = parseProtobufFields(bytes);
|
||||
const seconds = fields.find((f) => f.field === 1 && f.type === "varint")?.value;
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return new Date(seconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse grpc-web response bytes from GetGrokCreditsConfig.
|
||||
* Returns { usedPercent, resetAt, periodStart } or null.
|
||||
*/
|
||||
export function parseGrokCreditsConfig(raw) {
|
||||
if (!raw || !raw.length) return null;
|
||||
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
|
||||
// grpc-web data frame: 1-byte flag + 4-byte big-endian length + message
|
||||
if (buf.length < 5) return null;
|
||||
const flag = buf[0];
|
||||
// Data frames have flag 0; ignore trailer frames (flag 0x80).
|
||||
if (flag !== 0) return null;
|
||||
const msgLen = buf.readUInt32BE(1);
|
||||
if (msgLen <= 0 || 5 + msgLen > buf.length) return null;
|
||||
const msg = buf.subarray(5, 5 + msgLen);
|
||||
|
||||
// Response is typically { 1: CreditsConfig }
|
||||
const top = parseProtobufFields(msg);
|
||||
const configBytes = top.find((f) => f.field === 1 && f.type === "bytes")?.value || msg;
|
||||
const fields = parseProtobufFields(configBytes);
|
||||
|
||||
const usedPercentRaw = fields.find((f) => f.field === 1 && f.type === "fixed32")?.value;
|
||||
const periodStart = parseTimestamp(fields.find((f) => f.field === 4 && f.type === "bytes")?.value);
|
||||
const periodEnd = parseTimestamp(fields.find((f) => f.field === 5 && f.type === "bytes")?.value);
|
||||
|
||||
if (usedPercentRaw == null || !Number.isFinite(usedPercentRaw)) return null;
|
||||
|
||||
const usedPercent = Math.max(0, Math.min(100, usedPercentRaw));
|
||||
return {
|
||||
usedPercent,
|
||||
remainingPercent: Math.max(0, 100 - usedPercent),
|
||||
periodStart,
|
||||
resetAt: periodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchBilling(accessToken, billingUrl, proxyOptions) {
|
||||
const response = await proxyAwareFetch(billingUrl, {
|
||||
method: "GET",
|
||||
headers: authHeaders(accessToken, { Accept: "application/json" }),
|
||||
}, proxyOptions);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { error: "auth", status: response.status };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { error: "http", status: response.status };
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || typeof data !== "object") {
|
||||
return { error: "json" };
|
||||
}
|
||||
return { data };
|
||||
}
|
||||
|
||||
async function fetchWeeklyCredits(accessToken, creditsUrl, proxyOptions) {
|
||||
if (!creditsUrl) return null;
|
||||
try {
|
||||
const response = await proxyAwareFetch(creditsUrl, {
|
||||
method: "POST",
|
||||
headers: authHeaders(accessToken, {
|
||||
"Content-Type": "application/grpc-web+proto",
|
||||
"x-grpc-web": "1",
|
||||
"x-user-agent": "connect-es/2.1.1",
|
||||
Accept: "*/*",
|
||||
Origin: "https://grok.com",
|
||||
Referer: "https://grok.com/?_s=usage",
|
||||
}),
|
||||
body: GRPC_WEB_EMPTY_FRAME,
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const ab = await response.arrayBuffer();
|
||||
return parseGrokCreditsConfig(Buffer.from(ab));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlanLabel(accessToken, settingsUrl, proxyOptions) {
|
||||
if (!settingsUrl) return null;
|
||||
try {
|
||||
const response = await proxyAwareFetch(settingsUrl, {
|
||||
method: "GET",
|
||||
headers: authHeaders(accessToken, { Accept: "application/json" }),
|
||||
}, proxyOptions);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json().catch(() => null);
|
||||
const label = data?.subscription_tier_display;
|
||||
return typeof label === "string" && label.trim() ? label.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accessToken - xAI OAuth access token
|
||||
* @param {object|null} proxyOptions
|
||||
*/
|
||||
export async function getXaiUsage(accessToken, proxyOptions = null) {
|
||||
if (!accessToken) {
|
||||
return { message: "xAI usage unavailable: no access token. Re-authorize the connection." };
|
||||
}
|
||||
|
||||
const cfg = U("xai") || {};
|
||||
const billingUrl = cfg.url;
|
||||
if (!billingUrl) {
|
||||
return { message: "xAI usage endpoint is not configured." };
|
||||
}
|
||||
|
||||
try {
|
||||
const [billingResult, weekly, planLabel] = await Promise.all([
|
||||
fetchBilling(accessToken, billingUrl, proxyOptions),
|
||||
fetchWeeklyCredits(accessToken, cfg.creditsUrl, proxyOptions),
|
||||
fetchPlanLabel(accessToken, cfg.settingsUrl, proxyOptions),
|
||||
]);
|
||||
|
||||
if (billingResult.error === "auth") {
|
||||
return { message: "xAI OAuth token expired or unauthorized. Please re-authorize." };
|
||||
}
|
||||
|
||||
const quotas = {};
|
||||
let periodStart = null;
|
||||
let periodEnd = null;
|
||||
let onDemandCap = 0;
|
||||
|
||||
if (billingResult.data) {
|
||||
const config =
|
||||
billingResult.data.config && typeof billingResult.data.config === "object"
|
||||
? billingResult.data.config
|
||||
: billingResult.data;
|
||||
const monthlyLimit = moneyVal(config.monthlyLimit);
|
||||
const used = moneyVal(config.used);
|
||||
onDemandCap = moneyVal(config.onDemandCap) ?? 0;
|
||||
periodEnd = parseResetTime(config.billingPeriodEnd);
|
||||
periodStart = parseResetTime(config.billingPeriodStart);
|
||||
|
||||
// Absolute credit counts — do NOT put remaining credits on `remaining`
|
||||
// (QuotaTable treats remaining as a 0-100 percentage; same pitfall as Qoder).
|
||||
if (monthlyLimit != null && monthlyLimit > 0) {
|
||||
const usedSafe = Math.max(0, used ?? 0);
|
||||
quotas.api_usage = {
|
||||
used: usedSafe,
|
||||
total: monthlyLimit,
|
||||
remainingCredits: Math.max(0, monthlyLimit - usedSafe),
|
||||
unit: "credits",
|
||||
resetAt: periodEnd,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (onDemandCap > 0) {
|
||||
quotas.on_demand = {
|
||||
used: 0,
|
||||
total: onDemandCap,
|
||||
remainingCredits: onDemandCap,
|
||||
unit: "credits",
|
||||
resetAt: periodEnd,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly SuperGrok window — percentage-based like Claude/Codex windows.
|
||||
if (weekly) {
|
||||
quotas.weekly = {
|
||||
used: weekly.usedPercent,
|
||||
total: 100,
|
||||
remaining: weekly.remainingPercent,
|
||||
remainingPercentage: weekly.remainingPercent,
|
||||
resetAt: weekly.resetAt || null,
|
||||
unlimited: false,
|
||||
};
|
||||
if (!periodStart && weekly.periodStart) periodStart = weekly.periodStart;
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
const statusHint =
|
||||
billingResult.error === "http"
|
||||
? ` Billing API temporarily unavailable (${billingResult.status}).`
|
||||
: "";
|
||||
return {
|
||||
plan: planLabel || "xAI",
|
||||
message: `xAI connected. No quota allotment reported for this account.${statusHint}`,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: planLabel || "xAI",
|
||||
periodStart,
|
||||
periodEnd,
|
||||
onDemandCap,
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `xAI connected. Unable to fetch billing: ${error.message}` };
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,33 @@ export function anchorClaudeCache(body) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
|
||||
// so foreign signatures leak into history and Anthropic rejects them).
|
||||
const thinkingEnabled = body.thinking?.type === "enabled";
|
||||
if (Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) continue;
|
||||
let hasToolUse = false;
|
||||
let hasKeptThinking = false;
|
||||
const kept = [];
|
||||
for (const block of msg.content) {
|
||||
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) {
|
||||
if (isValidClaudeSignature(block.signature)) {
|
||||
hasKeptThinking = true;
|
||||
kept.push(block);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true;
|
||||
kept.push(block);
|
||||
}
|
||||
msg.content = kept;
|
||||
if (thinkingEnabled && !hasKeptThinking && hasToolUse) {
|
||||
msg.content.unshift(buildThinkingPlaceholder("claude"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,204 +13,160 @@ import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { randomUUID } from "crypto";
|
||||
import { ROLE, OPENAI_BLOCK } from "../schema/index.js";
|
||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
||||
import { parseDataUri } from "../concerns/image.js";
|
||||
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
|
||||
|
||||
function flattenText(content) {
|
||||
if (content == null) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = [];
|
||||
for (const p of content) {
|
||||
if (typeof p === "string") parts.push(p);
|
||||
else if (p && typeof p === "object" && typeof p.text === "string")
|
||||
parts.push(p.text);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
return String(content);
|
||||
if (content == null) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = [];
|
||||
for (const p of content) {
|
||||
if (typeof p === "string") parts.push(p);
|
||||
else if (p && typeof p === "object" && typeof p.text === "string") parts.push(p.text);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
return String(content);
|
||||
}
|
||||
|
||||
function toContentBlocks(content) {
|
||||
if (content == null) return [{ type: OPENAI_BLOCK.TEXT, text: "" }];
|
||||
if (typeof content === "string")
|
||||
return [{ type: OPENAI_BLOCK.TEXT, text: content }];
|
||||
if (Array.isArray(content)) {
|
||||
const blocks = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "string") {
|
||||
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part });
|
||||
} else if (part && typeof part === "object") {
|
||||
if (part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string") {
|
||||
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
|
||||
} else if (
|
||||
part.type === OPENAI_BLOCK.IMAGE_URL ||
|
||||
part.type === OPENAI_BLOCK.IMAGE
|
||||
) {
|
||||
// CommandCode `/alpha/generate` accepts {type:"image", image:"<data URI | url>"} —
|
||||
// same shape the official command-code CLI sends (verified from CLI source).
|
||||
const src = part.source;
|
||||
let raw = part.image_url?.url || src?.data || src?.url || "";
|
||||
let parsed = parseDataUri(raw);
|
||||
if (!parsed && src?.type === "base64" && src?.data) {
|
||||
// Claude-style base64 source without a data-URI prefix → wrap it.
|
||||
raw = `data:${src.media_type || DEFAULT_IMAGE_MIME};base64,${src.data}`;
|
||||
parsed = parseDataUri(raw);
|
||||
}
|
||||
if (parsed) {
|
||||
blocks.push({
|
||||
type: "image",
|
||||
image: `data:${parsed.mimeType};base64,${parsed.base64}`,
|
||||
});
|
||||
} else if (raw) {
|
||||
blocks.push({ type: "image", image: raw });
|
||||
}
|
||||
} else if (typeof part.text === "string") {
|
||||
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }];
|
||||
}
|
||||
return [{ type: OPENAI_BLOCK.TEXT, text: String(content) }];
|
||||
if (content == null) return [{ type: OPENAI_BLOCK.TEXT, text: "" }];
|
||||
if (typeof content === "string") return [{ type: OPENAI_BLOCK.TEXT, text: content }];
|
||||
if (Array.isArray(content)) {
|
||||
const blocks = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "string") {
|
||||
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part });
|
||||
} else if (part && typeof part === "object") {
|
||||
if (part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string") {
|
||||
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
|
||||
} else if (part.type === OPENAI_BLOCK.IMAGE_URL || part.type === OPENAI_BLOCK.IMAGE) {
|
||||
blocks.push({ type: OPENAI_BLOCK.TEXT, text: "[image omitted]" });
|
||||
} else if (typeof part.text === "string") {
|
||||
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }];
|
||||
}
|
||||
return [{ type: OPENAI_BLOCK.TEXT, text: String(content) }];
|
||||
}
|
||||
|
||||
function safeParseJson(s) {
|
||||
if (s == null) return {};
|
||||
if (typeof s !== "string") return s;
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (s == null) return {};
|
||||
if (typeof s !== "string") return s;
|
||||
try { return JSON.parse(s); } catch { return {}; }
|
||||
}
|
||||
|
||||
function convertMessages(messages = []) {
|
||||
const out = [];
|
||||
const systemTexts = [];
|
||||
const out = [];
|
||||
const systemTexts = [];
|
||||
|
||||
for (const m of messages) {
|
||||
if (!m) continue;
|
||||
const role = m.role;
|
||||
for (const m of messages) {
|
||||
if (!m) continue;
|
||||
const role = m.role;
|
||||
|
||||
if (role === ROLE.SYSTEM) {
|
||||
const t = flattenText(m.content);
|
||||
if (t) systemTexts.push(t);
|
||||
continue;
|
||||
}
|
||||
if (role === ROLE.SYSTEM) {
|
||||
const t = flattenText(m.content);
|
||||
if (t) systemTexts.push(t);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === ROLE.TOOL) {
|
||||
const value =
|
||||
typeof m.content === "string" ? m.content : flattenText(m.content);
|
||||
out.push({
|
||||
role: ROLE.TOOL,
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: m.tool_call_id || "",
|
||||
toolName: m.name || "",
|
||||
output: { type: "text", value },
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (role === ROLE.TOOL) {
|
||||
const value = typeof m.content === "string" ? m.content : flattenText(m.content);
|
||||
out.push({
|
||||
role: ROLE.TOOL,
|
||||
content: [{
|
||||
type: "tool-result",
|
||||
toolCallId: m.tool_call_id || "",
|
||||
toolName: m.name || "",
|
||||
output: { type: "text", value },
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === ROLE.ASSISTANT) {
|
||||
const blocks = [];
|
||||
const text = flattenText(m.content);
|
||||
if (text) blocks.push({ type: OPENAI_BLOCK.TEXT, text });
|
||||
if (Array.isArray(m.tool_calls)) {
|
||||
for (const tc of m.tool_calls) {
|
||||
const fn = tc.function || {};
|
||||
blocks.push({
|
||||
type: "tool-call",
|
||||
toolCallId: tc.id || "",
|
||||
toolName: fn.name || "",
|
||||
input: safeParseJson(fn.arguments),
|
||||
});
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
role: ROLE.ASSISTANT,
|
||||
content: blocks.length
|
||||
? blocks
|
||||
: [{ type: OPENAI_BLOCK.TEXT, text: "" }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (role === ROLE.ASSISTANT) {
|
||||
const blocks = [];
|
||||
const text = flattenText(m.content);
|
||||
if (text) blocks.push({ type: OPENAI_BLOCK.TEXT, text });
|
||||
if (Array.isArray(m.tool_calls)) {
|
||||
for (const tc of m.tool_calls) {
|
||||
const fn = tc.function || {};
|
||||
blocks.push({
|
||||
type: "tool-call",
|
||||
toolCallId: tc.id || "",
|
||||
toolName: fn.name || "",
|
||||
input: safeParseJson(fn.arguments),
|
||||
});
|
||||
}
|
||||
}
|
||||
out.push({ role: ROLE.ASSISTANT, content: blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }] });
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({ role: ROLE.USER, content: toContentBlocks(m.content) });
|
||||
}
|
||||
out.push({ role: ROLE.USER, content: toContentBlocks(m.content) });
|
||||
}
|
||||
|
||||
return { messages: out, system: systemTexts.join("\n\n") };
|
||||
return { messages: out, system: systemTexts.join("\n\n") };
|
||||
}
|
||||
|
||||
function convertTools(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return undefined;
|
||||
const result = [];
|
||||
for (const t of tools) {
|
||||
if (!t) continue;
|
||||
if (t.type === OPENAI_BLOCK.FUNCTION && t.function) {
|
||||
result.push({
|
||||
name: t.function.name,
|
||||
description: t.function.description,
|
||||
input_schema: t.function.parameters || { type: "object" },
|
||||
});
|
||||
} else if (t.name && (t.input_schema || t.parameters)) {
|
||||
result.push({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input_schema: t.input_schema || t.parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result.length ? result : undefined;
|
||||
if (!Array.isArray(tools) || tools.length === 0) return undefined;
|
||||
const result = [];
|
||||
for (const t of tools) {
|
||||
if (!t) continue;
|
||||
if (t.type === OPENAI_BLOCK.FUNCTION && t.function) {
|
||||
result.push({
|
||||
name: t.function.name,
|
||||
description: t.function.description,
|
||||
input_schema: t.function.parameters || { type: "object" },
|
||||
});
|
||||
} else if (t.name && (t.input_schema || t.parameters)) {
|
||||
result.push({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input_schema: t.input_schema || t.parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result.length ? result : undefined;
|
||||
}
|
||||
|
||||
export function openaiToCommandCodeRequest(
|
||||
model,
|
||||
body,
|
||||
stream /* , credentials */,
|
||||
) {
|
||||
const { messages, system } = convertMessages(body.messages);
|
||||
const params = {
|
||||
model,
|
||||
messages,
|
||||
stream: stream !== false,
|
||||
max_tokens: body.max_tokens ?? body.max_output_tokens ?? DEFAULT_MAX_TOKENS,
|
||||
temperature: body.temperature ?? 0.3,
|
||||
};
|
||||
export function openaiToCommandCodeRequest(model, body, stream /* , credentials */) {
|
||||
const { messages, system } = convertMessages(body.messages);
|
||||
const params = {
|
||||
model,
|
||||
messages,
|
||||
stream: stream !== false,
|
||||
max_tokens: body.max_tokens ?? body.max_output_tokens ?? DEFAULT_MAX_TOKENS,
|
||||
temperature: body.temperature ?? 0.3,
|
||||
};
|
||||
|
||||
if (system) params.system = system;
|
||||
if (system) params.system = system;
|
||||
|
||||
const tools = convertTools(body.tools);
|
||||
if (tools) params.tools = tools;
|
||||
if (body.top_p != null) params.top_p = body.top_p;
|
||||
const tools = convertTools(body.tools);
|
||||
if (tools) params.tools = tools;
|
||||
if (body.top_p != null) params.top_p = body.top_p;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// environment format mirrors the official command-code CLI (getEnvironmentInfo):
|
||||
// `${platform}-${arch}, Node.js ${version}` (e.g. "darwin-arm64, Node.js v24.16.0").
|
||||
const environment = `${process.platform}-${process.arch}, Node.js ${process.version}`;
|
||||
|
||||
return {
|
||||
threadId: randomUUID(),
|
||||
memory: "",
|
||||
config: {
|
||||
workingDir: process.cwd(),
|
||||
date: today,
|
||||
environment,
|
||||
structure: [],
|
||||
isGitRepo: false,
|
||||
currentBranch: "",
|
||||
mainBranch: "",
|
||||
gitStatus: "",
|
||||
recentCommits: [],
|
||||
},
|
||||
params,
|
||||
};
|
||||
return {
|
||||
threadId: randomUUID(),
|
||||
memory: "",
|
||||
config: {
|
||||
workingDir: process.cwd(),
|
||||
date: today,
|
||||
environment: process.platform,
|
||||
structure: [],
|
||||
isGitRepo: false,
|
||||
currentBranch: "",
|
||||
mainBranch: "",
|
||||
gitStatus: "",
|
||||
recentCommits: [],
|
||||
},
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
register(FORMATS.OPENAI, FORMATS.COMMANDCODE, openaiToCommandCodeRequest, null);
|
||||
|
||||
@@ -25,198 +25,160 @@ import { fallbackToolCallId } from "../concerns/toolCall.js";
|
||||
import { toOpenAIFinish } from "../concerns/finishReason.js";
|
||||
|
||||
function ensureState(state, model) {
|
||||
if (!state.responseId) {
|
||||
state.responseId = `chatcmpl-${Date.now()}`;
|
||||
state.created = Math.floor(Date.now() / 1000);
|
||||
state.model = state.model || model || "commandcode";
|
||||
state.chunkIndex = 0;
|
||||
state.toolIndex = 0;
|
||||
state.toolIndexById = new Map();
|
||||
state.openTools = new Set();
|
||||
state.openText = false;
|
||||
state.finishReason = null;
|
||||
state.usage = null;
|
||||
}
|
||||
if (!state.responseId) {
|
||||
state.responseId = `chatcmpl-${Date.now()}`;
|
||||
state.created = Math.floor(Date.now() / 1000);
|
||||
state.model = state.model || model || "commandcode";
|
||||
state.chunkIndex = 0;
|
||||
state.toolIndex = 0;
|
||||
state.toolIndexById = new Map();
|
||||
state.openTools = new Set();
|
||||
state.openText = false;
|
||||
state.finishReason = null;
|
||||
state.usage = null;
|
||||
}
|
||||
}
|
||||
|
||||
function makeChunk(state, delta, finishReason = null) {
|
||||
return buildChunk(
|
||||
{ id: state.responseId, created: state.created, model: state.model },
|
||||
delta,
|
||||
finishReason,
|
||||
);
|
||||
return buildChunk(
|
||||
{ id: state.responseId, created: state.created, model: state.model },
|
||||
delta,
|
||||
finishReason
|
||||
);
|
||||
}
|
||||
|
||||
const mapFinishReason = (reason) => toOpenAIFinish(reason, "commandcode");
|
||||
|
||||
export function commandCodeToOpenAIResponse(chunk, state) {
|
||||
if (!chunk) return null;
|
||||
if (!chunk) return null;
|
||||
|
||||
// Already-OpenAI chunk: pass through
|
||||
if (
|
||||
chunk &&
|
||||
typeof chunk === "object" &&
|
||||
chunk.object === "chat.completion.chunk"
|
||||
) {
|
||||
return chunk;
|
||||
}
|
||||
// Already-OpenAI chunk: pass through
|
||||
if (chunk && typeof chunk === "object" && chunk.object === "chat.completion.chunk") {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
// Parse string lines coming out of upstream
|
||||
let event = chunk;
|
||||
if (typeof chunk === "string") {
|
||||
const line = chunk.trim();
|
||||
if (!line) return null;
|
||||
// Tolerate raw "data: {...}" framing if the upstream wrapper inserts it
|
||||
const json = line.startsWith("data:") ? line.slice(5).trim() : line;
|
||||
if (!json || json === "[DONE]") return null;
|
||||
try {
|
||||
event = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Parse string lines coming out of upstream
|
||||
let event = chunk;
|
||||
if (typeof chunk === "string") {
|
||||
const line = chunk.trim();
|
||||
if (!line) return null;
|
||||
// Tolerate raw "data: {...}" framing if the upstream wrapper inserts it
|
||||
const json = line.startsWith("data:") ? line.slice(5).trim() : line;
|
||||
if (!json || json === "[DONE]") return null;
|
||||
try {
|
||||
event = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event || typeof event !== "object" || !event.type) return null;
|
||||
if (!event || typeof event !== "object" || !event.type) return null;
|
||||
|
||||
ensureState(state, event.model);
|
||||
const out = [];
|
||||
ensureState(state, event.model);
|
||||
const out = [];
|
||||
|
||||
switch (event.type) {
|
||||
case "text-delta": {
|
||||
const text = event.text || event.delta || "";
|
||||
if (!text) break;
|
||||
const delta =
|
||||
state.chunkIndex === 0
|
||||
? { role: ROLE.ASSISTANT, content: text }
|
||||
: { content: text };
|
||||
state.chunkIndex++;
|
||||
state.openText = true;
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "reasoning-delta": {
|
||||
const text = event.text || "";
|
||||
if (!text) break;
|
||||
// Map reasoning to OpenAI "reasoning_content" field (used by deepseek-reasoner-style clients).
|
||||
const delta = reasoningDelta(text, state.chunkIndex === 0);
|
||||
state.chunkIndex++;
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "tool-input-start": {
|
||||
const id =
|
||||
event.id || event.toolCallId || fallbackToolCallId(state.toolIndex);
|
||||
let idx = state.toolIndexById.get(id);
|
||||
if (idx == null) {
|
||||
idx = state.toolIndex++;
|
||||
state.toolIndexById.set(id, idx);
|
||||
}
|
||||
state.openTools.add(id);
|
||||
const delta = {
|
||||
...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}),
|
||||
tool_calls: [
|
||||
{
|
||||
index: idx,
|
||||
id,
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: { name: event.toolName || "", arguments: "" },
|
||||
},
|
||||
],
|
||||
};
|
||||
state.chunkIndex++;
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "tool-input-delta": {
|
||||
const id = event.id || event.toolCallId;
|
||||
const idx = state.toolIndexById.get(id);
|
||||
if (idx == null) break;
|
||||
const delta = {
|
||||
tool_calls: [
|
||||
{
|
||||
index: idx,
|
||||
function: { arguments: event.delta || event.inputTextDelta || "" },
|
||||
},
|
||||
],
|
||||
};
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "tool-call": {
|
||||
// Final consolidated tool call — only emit if we never saw tool-input-* deltas.
|
||||
const id = event.toolCallId;
|
||||
if (state.toolIndexById.has(id)) break;
|
||||
const idx = state.toolIndex++;
|
||||
state.toolIndexById.set(id, idx);
|
||||
const argsStr =
|
||||
typeof event.input === "string"
|
||||
? event.input
|
||||
: JSON.stringify(event.input ?? {});
|
||||
const delta = {
|
||||
...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}),
|
||||
tool_calls: [
|
||||
{
|
||||
index: idx,
|
||||
id,
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: { name: event.toolName || "", arguments: argsStr },
|
||||
},
|
||||
],
|
||||
};
|
||||
state.chunkIndex++;
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "finish-step": {
|
||||
state.finishReason = mapFinishReason(event.finishReason);
|
||||
if (event.usage) state.usage = event.usage;
|
||||
break;
|
||||
}
|
||||
case "finish": {
|
||||
const finishReason =
|
||||
state.finishReason || mapFinishReason(event.finishReason || "stop");
|
||||
const finalChunk = makeChunk(state, {}, finishReason);
|
||||
const totalUsage = event.totalUsage || state.usage;
|
||||
const usage = toOpenAIUsage(totalUsage, "commandcode");
|
||||
if (usage) finalChunk.usage = usage;
|
||||
out.push(finalChunk);
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
// Terminal upstream failure (AI SDK v5 error event) — NOT content. Emit an
|
||||
// OpenAI-shaped error chunk (chunk.error) so downstream — parseSSEToOpenAIResponse
|
||||
// for non-streaming, OpenAI SDK clients for streaming — treats the request as
|
||||
// failed instead of surfacing fake success content like "[CommandCode error: ...]".
|
||||
state.finishReason = OPENAI_FINISH.STOP;
|
||||
const errVal = event.error ?? event.message ?? "unknown";
|
||||
const errStr =
|
||||
typeof errVal === "string"
|
||||
? errVal
|
||||
: typeof errVal?.message === "string"
|
||||
? errVal.message
|
||||
: JSON.stringify(errVal);
|
||||
const errType =
|
||||
typeof errVal === "string"
|
||||
? "upstream_error"
|
||||
: errVal?.type || "upstream_error";
|
||||
const errChunk = makeChunk(state, {});
|
||||
errChunk.error = { message: errStr, type: errType };
|
||||
out.push(errChunk);
|
||||
out.push(makeChunk(state, {}, OPENAI_FINISH.STOP));
|
||||
break;
|
||||
}
|
||||
// Silently ignore: start, start-step, reasoning-start, reasoning-end, text-start, text-end,
|
||||
// provider-metadata, message-metadata, etc. They carry no client-visible content.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case "text-delta": {
|
||||
const text = event.text || event.delta || "";
|
||||
if (!text) break;
|
||||
const delta = state.chunkIndex === 0 ? { role: ROLE.ASSISTANT, content: text } : { content: text };
|
||||
state.chunkIndex++;
|
||||
state.openText = true;
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "reasoning-delta": {
|
||||
const text = event.text || "";
|
||||
if (!text) break;
|
||||
// Map reasoning to OpenAI "reasoning_content" field (used by deepseek-reasoner-style clients).
|
||||
const delta = reasoningDelta(text, state.chunkIndex === 0);
|
||||
state.chunkIndex++;
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "tool-input-start": {
|
||||
const id = event.id || event.toolCallId || fallbackToolCallId(state.toolIndex);
|
||||
let idx = state.toolIndexById.get(id);
|
||||
if (idx == null) {
|
||||
idx = state.toolIndex++;
|
||||
state.toolIndexById.set(id, idx);
|
||||
}
|
||||
state.openTools.add(id);
|
||||
const delta = {
|
||||
...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}),
|
||||
tool_calls: [{
|
||||
index: idx,
|
||||
id,
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: { name: event.toolName || "", arguments: "" },
|
||||
}],
|
||||
};
|
||||
state.chunkIndex++;
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "tool-input-delta": {
|
||||
const id = event.id || event.toolCallId;
|
||||
const idx = state.toolIndexById.get(id);
|
||||
if (idx == null) break;
|
||||
const delta = {
|
||||
tool_calls: [{
|
||||
index: idx,
|
||||
function: { arguments: event.delta || event.inputTextDelta || "" },
|
||||
}],
|
||||
};
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "tool-call": {
|
||||
// Final consolidated tool call — only emit if we never saw tool-input-* deltas.
|
||||
const id = event.toolCallId;
|
||||
if (state.toolIndexById.has(id)) break;
|
||||
const idx = state.toolIndex++;
|
||||
state.toolIndexById.set(id, idx);
|
||||
const argsStr = typeof event.input === "string" ? event.input : JSON.stringify(event.input ?? {});
|
||||
const delta = {
|
||||
...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}),
|
||||
tool_calls: [{
|
||||
index: idx,
|
||||
id,
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: { name: event.toolName || "", arguments: argsStr },
|
||||
}],
|
||||
};
|
||||
state.chunkIndex++;
|
||||
out.push(makeChunk(state, delta));
|
||||
break;
|
||||
}
|
||||
case "finish-step": {
|
||||
state.finishReason = mapFinishReason(event.finishReason);
|
||||
if (event.usage) state.usage = event.usage;
|
||||
break;
|
||||
}
|
||||
case "finish": {
|
||||
const finishReason = state.finishReason || mapFinishReason(event.finishReason || "stop");
|
||||
const finalChunk = makeChunk(state, {}, finishReason);
|
||||
const totalUsage = event.totalUsage || state.usage;
|
||||
const usage = toOpenAIUsage(totalUsage, "commandcode");
|
||||
if (usage) finalChunk.usage = usage;
|
||||
out.push(finalChunk);
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
state.finishReason = OPENAI_FINISH.STOP;
|
||||
const errVal = event.error ?? event.message ?? "unknown";
|
||||
const errStr = typeof errVal === "string" ? errVal : JSON.stringify(errVal);
|
||||
out.push(makeChunk(state, { content: `\n\n[CommandCode error: ${errStr}]` }));
|
||||
out.push(makeChunk(state, {}, OPENAI_FINISH.STOP));
|
||||
break;
|
||||
}
|
||||
// Silently ignore: start, start-step, reasoning-start, reasoning-end, text-start, text-end,
|
||||
// provider-metadata, message-metadata, etc. They carry no client-visible content.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
register(
|
||||
FORMATS.COMMANDCODE,
|
||||
FORMATS.OPENAI,
|
||||
null,
|
||||
commandCodeToOpenAIResponse,
|
||||
);
|
||||
register(FORMATS.COMMANDCODE, FORMATS.OPENAI, null, commandCodeToOpenAIResponse);
|
||||
|
||||
@@ -6,12 +6,95 @@ const originalFetch = globalThis.fetch;
|
||||
const proxyDispatchers = new Map();
|
||||
|
||||
// ─── TLS fingerprinting via got-scraping (browser-like JA3) ───────────────
|
||||
// Disabled: not in use.
|
||||
// Disabled: not in use. Kept commented for future re-enable.
|
||||
// Restore the original block to re-enable per-host JA3 spoofing.
|
||||
/*
|
||||
let _gotScraping = null;
|
||||
let _gotScrapingChecked = false;
|
||||
async function getGotScraping() { return null; }
|
||||
async function tryGotScrapingFetch() { return null; }
|
||||
const _gotScrapingLoggedHosts = new Set();
|
||||
|
||||
async function getGotScraping() {
|
||||
if (_gotScrapingChecked) return _gotScraping;
|
||||
_gotScrapingChecked = true;
|
||||
try {
|
||||
const mod = await import("got-scraping");
|
||||
_gotScraping = typeof mod.gotScraping === "function" ? mod.gotScraping : null;
|
||||
if (_gotScraping) dbg("TLS", "got-scraping loaded (browser-like JA3 enabled)");
|
||||
} catch (e) {
|
||||
console.warn(`[ProxyFetch] got-scraping unavailable, falling back to native fetch: ${e.message}`);
|
||||
_gotScraping = null;
|
||||
}
|
||||
return _gotScraping;
|
||||
}
|
||||
|
||||
async function gotScrapingFetch(url, options) {
|
||||
const gs = await getGotScraping();
|
||||
if (!gs) return null;
|
||||
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headersInit = options.headers || {};
|
||||
const headers = headersInit instanceof Headers
|
||||
? Object.fromEntries(headersInit.entries())
|
||||
: { ...headersInit };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const stream = gs.stream({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body: method === "GET" || method === "HEAD" ? undefined : options.body,
|
||||
throwHttpErrors: false,
|
||||
retry: { limit: 0 },
|
||||
timeout: { request: undefined },
|
||||
followRedirect: false,
|
||||
decompress: true,
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { } };
|
||||
if (options.signal.aborted) onAbort();
|
||||
else options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
stream.once("response", (res) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const resHeaders = new Headers();
|
||||
for (const [k, v] of Object.entries(res.headers || {})) {
|
||||
if (Array.isArray(v)) v.forEach((x) => resHeaders.append(k, String(x)));
|
||||
else if (v != null) resHeaders.set(k, String(v));
|
||||
}
|
||||
const body = Readable.toWeb(stream);
|
||||
resolve(new Response(body, { status: res.statusCode, statusText: res.statusMessage || "", headers: resHeaders }));
|
||||
});
|
||||
|
||||
stream.once("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function tryGotScrapingFetch(url, options) {
|
||||
try {
|
||||
const res = await gotScrapingFetch(url, options);
|
||||
if (res) {
|
||||
try {
|
||||
const host = new URL(typeof url === "string" ? url : url.toString()).hostname;
|
||||
if (!_gotScrapingLoggedHosts.has(host)) {
|
||||
_gotScrapingLoggedHosts.add(host);
|
||||
dbg("TLS", `using got-scraping for ${host}`);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.warn(`[ProxyFetch] got-scraping request failed, fallback to native fetch: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// DNS cache — use Map to avoid prototype pollution via malformed hostnames
|
||||
@@ -266,6 +349,7 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
||||
}
|
||||
|
||||
// got-scraping disabled — use native fetch directly
|
||||
// (Re-enable per-host by wrapping with tryGotScrapingFetch when needed)
|
||||
return originalFetch(url, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Config-driven in-stream error pattern matching.
|
||||
* See docs/2026-08-04-stream-error-patterns-design.md.
|
||||
*
|
||||
* Settings shape: streamErrorPatterns: { [provider]: string[] }
|
||||
* Pattern syntax per entry:
|
||||
* - "plain text" → case-insensitive substring match
|
||||
* - "/regex/flags" → RegExp match (flags after the last "/")
|
||||
*/
|
||||
|
||||
export function parsePatterns(patterns) {
|
||||
if (!Array.isArray(patterns)) return [];
|
||||
const out = [];
|
||||
for (const entry of patterns) {
|
||||
if (typeof entry !== "string" || !entry.trim()) continue;
|
||||
const raw = entry.trim();
|
||||
const m = raw.match(/^\/(.*)\/([a-z]*)$/s);
|
||||
if (m) {
|
||||
try {
|
||||
out.push({ regex: new RegExp(m[1], m[2]), raw });
|
||||
} catch {
|
||||
// invalid regex → skip; config errors must never break requests
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push({ text: raw.toLowerCase(), raw });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function matchStreamErrorPatterns(patterns, text) {
|
||||
if (!Array.isArray(patterns) || patterns.length === 0) return null;
|
||||
if (typeof text !== "string" || !text) return null;
|
||||
const lower = text.toLowerCase();
|
||||
for (const p of parsePatterns(patterns)) {
|
||||
if (p.text && lower.includes(p.text)) return p.raw;
|
||||
if (p.regex) {
|
||||
p.regex.lastIndex = 0; // stateless even with /g
|
||||
if (p.regex.test(text)) return p.raw;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function streamStatusForContent(patterns, content) {
|
||||
return matchStreamErrorPatterns(patterns, content) ? "error" : "success";
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { matchStreamErrorPatterns } from "./streamErrorPatterns.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = (() => {
|
||||
const raw = process.env.STREAM_ERROR_PEEK_TIMEOUT_MS;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : 3000;
|
||||
})();
|
||||
|
||||
const DEFAULT_MAX_BYTES = (() => {
|
||||
const raw = process.env.STREAM_ERROR_PEEK_MAX_BYTES;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : 8192;
|
||||
})();
|
||||
|
||||
function makeAbortError(reason) {
|
||||
const error = new Error(reason?.message || reason || "Request aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first bytes of a 200 response and reject it with a 502 when a
|
||||
* configured stream-error pattern matches — BEFORE any byte reaches the
|
||||
* client, so account/model fallback can still kick in for streaming too.
|
||||
* On no-match/timeout/abort the response is re-emitted (buffered bytes +
|
||||
* rest of stream) unchanged in spirit. Fail-open: never throws.
|
||||
*/
|
||||
export async function maybeRejectEarlyStreamError(
|
||||
response,
|
||||
patterns,
|
||||
{
|
||||
signal = null,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
maxBytes = DEFAULT_MAX_BYTES,
|
||||
} = {},
|
||||
) {
|
||||
if (!Array.isArray(patterns) || patterns.length === 0) return response;
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const abortController = new AbortController();
|
||||
const forwardAbort = () => abortController.abort(signal?.reason);
|
||||
if (signal?.aborted) abortController.abort(signal?.reason);
|
||||
else if (signal)
|
||||
signal.addEventListener("abort", forwardAbort, { once: true });
|
||||
|
||||
// Raw bytes for lossless re-emission; decoded text ONLY for pattern matching.
|
||||
// Never re-encode decoded text: TextDecoder holds a split multi-byte char
|
||||
// internally and flush() would replace it with U+FFFD, corrupting the stream.
|
||||
const rawChunks = [];
|
||||
let peekedText = "";
|
||||
let total = 0;
|
||||
|
||||
const readWithTimeout = (ms) => {
|
||||
if (abortController.signal.aborted)
|
||||
return Promise.reject(makeAbortError(abortController.signal.reason));
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
const t = setTimeout(
|
||||
() => reject(new Error("stream error peek timeout")),
|
||||
ms,
|
||||
);
|
||||
t.unref?.();
|
||||
});
|
||||
const abortPromise = new Promise((_, reject) => {
|
||||
abortController.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(makeAbortError(abortController.signal.reason)),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return Promise.race([reader.read(), timeoutPromise, abortPromise]);
|
||||
};
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (total < maxBytes && Date.now() < deadline) {
|
||||
const { done, value } = await readWithTimeout(
|
||||
Math.max(deadline - Date.now(), 1),
|
||||
);
|
||||
if (done) break;
|
||||
rawChunks.push(value);
|
||||
peekedText += decoder.decode(value, { stream: true });
|
||||
total += value.byteLength;
|
||||
const matched = matchStreamErrorPatterns(patterns, peekedText);
|
||||
if (matched) {
|
||||
await reader.cancel("stream error pattern matched").catch(() => {});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Stream error pattern matched: ${matched}`,
|
||||
type: "upstream_error",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: HTTP_STATUS.BAD_GATEWAY,
|
||||
statusText: String(matched).slice(0, 200),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// timeout / abort / read failure → commit; downstream stall/abort handling takes over.
|
||||
}
|
||||
|
||||
if (signal) signal.removeEventListener("abort", forwardAbort);
|
||||
|
||||
const remaining = new ReadableStream({
|
||||
start(controller) {
|
||||
(async () => {
|
||||
try {
|
||||
// Re-emit RAW bytes (never re-encoded decoded text) so split
|
||||
// multi-byte UTF-8 sequences survive the peek untouched.
|
||||
for (const c of rawChunks) controller.enqueue(c);
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
controller.enqueue(value);
|
||||
}
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
})();
|
||||
},
|
||||
cancel() {
|
||||
reader.cancel("stream cancelled during peek commit").catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(remaining, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
@@ -397,7 +397,6 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI
|
||||
targetFormat
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log usage with cache info (green color)
|
||||
*/
|
||||
|
||||
@@ -75,7 +75,6 @@ Common fields above work everywhere. These add/override:
|
||||
| Provider | Extra/changed fields | Notes |
|
||||
|---|---|---|
|
||||
| `openai`, `minimax`, `openrouter`, `recraft` | `quality`, `style`, `response_format` | Standard OpenAI shape |
|
||||
| `xai` (Grok Imagine) | `aspect_ratio`, `resolution`, `image`, `images[]` | Generate → `/images/generations`; edit/multi-edit → `/images/edits` (auto when `image`/`images` present). `size` maps to `aspect_ratio`. Up to 3 source images. |
|
||||
| `gemini` (nano-banana) | — | Only `prompt`; ignores `size`/`n` |
|
||||
| `codex` (gpt-5.4-image) | `image`, `images[]`, `image_detail`, `output_format`, `background` | SSE stream; **ChatGPT Plus/Pro required** |
|
||||
| `huggingface` | — | Only `prompt`; returns single image |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal, ApiExplorerModal } from "@/shared/components";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import {
|
||||
TUNNEL_BENEFITS,
|
||||
@@ -21,12 +21,6 @@ export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showApiExplorer, setShowApiExplorer] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importKeyValue, setImportKeyValue] = useState("");
|
||||
const [importKeyName, setImportKeyName] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState(null);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
@@ -726,20 +720,10 @@ export default function APIPageClient({ machineId }) {
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Endpoint Card */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">api</span>
|
||||
API Endpoint
|
||||
</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="science"
|
||||
onClick={() => setShowApiExplorer(true)}
|
||||
>
|
||||
API Explorer
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">api</span>
|
||||
API Endpoint
|
||||
</h2>
|
||||
|
||||
{/* Endpoint rows */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -986,14 +970,9 @@ export default function APIPageClient({ machineId }) {
|
||||
<span className="material-symbols-outlined text-primary">vpn_key</span>
|
||||
API Keys
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
<Button icon="file_open" variant="secondary" onClick={() => setShowImportModal(true)}>
|
||||
Import Key
|
||||
</Button>
|
||||
</div>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-border">
|
||||
@@ -1131,100 +1110,6 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Import Key Modal */}
|
||||
<Modal
|
||||
isOpen={showImportModal}
|
||||
title="Import Existing API Key"
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
setImportKeyValue("");
|
||||
setImportKeyName("");
|
||||
setImportError(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-surface-2 border border-border-subtle rounded-lg p-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
Paste an existing API key to add it to this instance.
|
||||
Useful for transferring keys from another 9Router instance or adding externally generated keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{importError && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-red-300 dark:border-red-800 bg-red-500/10 text-sm text-red-600 dark:text-red-400">
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="API Key"
|
||||
value={importKeyValue}
|
||||
onChange={(e) => {
|
||||
setImportKeyValue(e.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
placeholder="Paste your API key here"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
label="Key Name (optional)"
|
||||
value={importKeyName}
|
||||
onChange={(e) => setImportKeyName(e.target.value)}
|
||||
placeholder="Imported Key"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!importKeyValue.trim()) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const res = await fetch("/api/keys/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: importKeyValue.trim(),
|
||||
name: importKeyName.trim() || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
setShowImportModal(false);
|
||||
setImportKeyValue("");
|
||||
setImportKeyName("");
|
||||
} else {
|
||||
setImportError(data.error || "Failed to import key");
|
||||
}
|
||||
} catch (error) {
|
||||
setImportError("Network error. Please try again.");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
disabled={!importKeyValue.trim() || importing}
|
||||
>
|
||||
{importing ? "Importing..." : "Import"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowImportModal(false);
|
||||
setImportKeyValue("");
|
||||
setImportKeyName("");
|
||||
setImportError(null);
|
||||
}}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
disabled={importing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Created Key Modal */}
|
||||
<Modal
|
||||
isOpen={!!createdKey}
|
||||
@@ -1415,12 +1300,6 @@ export default function APIPageClient({ machineId }) {
|
||||
message={confirmState?.message}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* API Explorer — list + test all public AI endpoints */}
|
||||
<ApiExplorerModal
|
||||
isOpen={showApiExplorer}
|
||||
onClose={() => setShowApiExplorer(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,8 +43,6 @@ export const KIND_EXAMPLE_CONFIG = {
|
||||
extraFields: [
|
||||
{ key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 },
|
||||
{ key: "size", label: "Size", type: "select", default: "auto", options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"] },
|
||||
{ key: "aspect_ratio", label: "Aspect", type: "select", default: "", options: ["", "auto", "1:1", "16:9", "9:16", "4:3", "3:2", "2:3", "9:19.5", "20:9"] },
|
||||
{ key: "resolution", label: "Resolution", type: "select", default: "", options: ["", "1k", "2k"] },
|
||||
{ key: "quality", label: "Quality", type: "select", default: "auto", options: ["auto", "low", "medium", "high", "standard", "hd"] },
|
||||
{ key: "background", label: "Background", type: "select", default: "auto", options: ["auto", "transparent", "opaque"] },
|
||||
{ key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] },
|
||||
|
||||
@@ -286,25 +286,6 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalTimeoutChange = async (e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, "");
|
||||
const numTimeout = parseInt(raw, 10);
|
||||
const patchValue = (raw !== "" && Number.isFinite(numTimeout) && numTimeout > 0) ? numTimeout : null;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ defaultTimeoutMs: patchValue }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings(prev => ({ ...prev, defaultTimeoutMs: patchValue }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update default timeout:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStickyLimit = async (limit) => {
|
||||
const numLimit = parseInt(limit);
|
||||
if (isNaN(numLimit) || numLimit < 1) return;
|
||||
@@ -662,6 +643,21 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateLogLevel = async (logLevel) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ logLevel }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings(prev => ({ ...prev, logLevel }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update logLevel:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
@@ -1539,43 +1535,6 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Default Timeout — global default for all providers */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[20px]">timer</span>
|
||||
</div>
|
||||
<h3 className="text-base sm:text-lg font-semibold">Default Connect Timeout</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm sm:text-base">All Providers</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted">
|
||||
Timeout for upstream connect (applies globally unless overridden per provider). Set to 0 or leave empty for system default (60s).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="60000"
|
||||
value={settings.defaultTimeoutMs != null ? String(settings.defaultTimeoutMs) : ""}
|
||||
onChange={handleGlobalTimeoutChange}
|
||||
disabled={loading}
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
<span className="text-xs text-text-muted shrink-0">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted italic">
|
||||
{settings.defaultTimeoutMs
|
||||
? `All providers will wait up to ${settings.defaultTimeoutMs}ms for a connection.`
|
||||
: "Using system default (60s) — configure per-provider timeout on each provider's detail page for fine-grained control."}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Network */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -1671,6 +1630,39 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Logging Settings */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-slate-500/10 text-slate-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[20px]">terminal</span>
|
||||
</div>
|
||||
<h3 className="text-base sm:text-lg font-semibold">Logging</h3>
|
||||
</div>
|
||||
<div className="flex items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm sm:text-base">Log level</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted">
|
||||
Controls how much the server prints to console. In production,
|
||||
set to <span className="font-medium">Error</span> to only show
|
||||
important errors, hiding the per-request INFO lines (▶ POST,
|
||||
📊 DONE, [COMBO], [CHAT]). Applied immediately, no restart
|
||||
needed.
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={settings.logLevel || "info"}
|
||||
onChange={(e) => updateLogLevel(e.target.value)}
|
||||
disabled={loading}
|
||||
className="shrink-0 rounded-md border border-border bg-background px-2 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="debug">Debug</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warn">Warn</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Account actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
|
||||
@@ -3,44 +3,14 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
||||
import PropTypes from "prop-types";
|
||||
import { Badge, Toggle, Tooltip, Modal, Button } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { Badge, Toggle, Tooltip } from "@/shared/components";
|
||||
import CooldownTimer from "./CooldownTimer";
|
||||
|
||||
export default function ConnectionRow({
|
||||
connection,
|
||||
proxyPools,
|
||||
isOAuth,
|
||||
isFirst,
|
||||
isLast,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onToggleActive,
|
||||
onUpdateProxy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
oneByOneStatus = null,
|
||||
autoPing = null,
|
||||
testModels = [],
|
||||
providerAlias = null,
|
||||
}) {
|
||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
|
||||
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||
const [revealedKey, setRevealedKey] = useState("");
|
||||
const [loadingKey, setLoadingKey] = useState(false);
|
||||
const [keyError, setKeyError] = useState(null);
|
||||
const [showTestModelModal, setShowTestModelModal] = useState(false);
|
||||
const [selectedTestModelId, setSelectedTestModelId] = useState("");
|
||||
const [manualTestModelId, setManualTestModelId] = useState("");
|
||||
const [testingModel, setTestingModel] = useState(false);
|
||||
const [testModelResult, setTestModelResult] = useState(null);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const proxyDropdownRef = useRef(null);
|
||||
|
||||
const canTestModel = !!providerAlias && (connection.isActive !== false);
|
||||
const hasCatalogModels = Array.isArray(testModels) && testModels.length > 0;
|
||||
|
||||
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
|
||||
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
|
||||
const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null;
|
||||
@@ -99,58 +69,6 @@ export default function ConnectionRow({
|
||||
}
|
||||
};
|
||||
|
||||
const openTestModelModal = () => {
|
||||
setTestModelResult(null);
|
||||
setManualTestModelId("");
|
||||
setSelectedTestModelId(hasCatalogModels ? (testModels[0]?.id || "") : "");
|
||||
setShowTestModelModal(true);
|
||||
};
|
||||
|
||||
const resolveTestModelId = () => {
|
||||
if (hasCatalogModels) return selectedTestModelId?.trim() || "";
|
||||
return manualTestModelId?.trim() || "";
|
||||
};
|
||||
|
||||
const handleTestModel = async () => {
|
||||
if (!canTestModel || testingModel) return;
|
||||
const modelId = resolveTestModelId();
|
||||
if (!modelId) {
|
||||
setTestModelResult({ ok: false, error: "Select or enter a model id" });
|
||||
return;
|
||||
}
|
||||
setTestingModel(true);
|
||||
setTestModelResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: `${providerAlias}/${modelId}`,
|
||||
connectionId: connection.id,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setTestModelResult({
|
||||
ok: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
latencyMs: data.latencyMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTestModelResult({
|
||||
ok: !!data.ok,
|
||||
error: data.error || null,
|
||||
latencyMs: data.latencyMs,
|
||||
status: data.status,
|
||||
});
|
||||
} catch (error) {
|
||||
setTestModelResult({ ok: false, error: error.message || "Network error" });
|
||||
} finally {
|
||||
setTestingModel(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rowAuthType = connection.authType || (isOAuth ? "oauth" : "apikey");
|
||||
const isOAuthConnection = rowAuthType === "oauth";
|
||||
const isCookieConnection = rowAuthType === "cookie";
|
||||
@@ -339,46 +257,6 @@ export default function ConnectionRow({
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{connection.authType === "apikey" && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setLoadingKey(true);
|
||||
setKeyError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connection.id}/api-key`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setRevealedKey(data.apiKey || "");
|
||||
setShowKeyModal(true);
|
||||
} else {
|
||||
setKeyError(data.error || "Failed to fetch key");
|
||||
setShowKeyModal(true);
|
||||
}
|
||||
} catch {
|
||||
setKeyError("Network error");
|
||||
setShowKeyModal(true);
|
||||
} finally {
|
||||
setLoadingKey(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5"
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[18px] ${loadingKey ? "animate-spin" : ""}`}>
|
||||
{loadingKey ? "progress_activity" : "key"}
|
||||
</span>
|
||||
<span className="text-[10px] leading-tight">Key</span>
|
||||
</button>
|
||||
)}
|
||||
{canTestModel && (
|
||||
<button
|
||||
onClick={openTestModelModal}
|
||||
className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5"
|
||||
title="Test a model using only this account"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">science</span>
|
||||
<span className="text-[10px] leading-tight">Test</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onEdit} className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5">
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
<span className="text-[10px] leading-tight">Edit</span>
|
||||
@@ -395,153 +273,6 @@ export default function ConnectionRow({
|
||||
title={(connection.isActive ?? true) ? "Disable connection" : "Enable connection"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show Key Modal */}
|
||||
<Modal
|
||||
isOpen={showKeyModal}
|
||||
title="API Key"
|
||||
onClose={() => {
|
||||
setShowKeyModal(false);
|
||||
setRevealedKey("");
|
||||
setKeyError(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{keyError ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-red-300 dark:border-red-800 bg-red-500/10 text-sm text-red-600 dark:text-red-400">
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
{keyError}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
|
||||
<span className="material-symbols-outlined text-[14px] align-middle mr-1">warning</span>
|
||||
This key provides full access to your endpoint. Keep it secure.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={revealedKey}
|
||||
className="flex-1 px-3 py-2 text-sm font-mono bg-surface-2 rounded-lg border border-border focus:outline-none"
|
||||
onClick={(e) => e.target.select()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => copy(revealedKey, connection.id)}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg border border-border text-sm text-text-muted hover:text-primary hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{copied === connection.id ? "check" : "content_copy"}</span>
|
||||
{copied === connection.id ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => { setShowKeyModal(false); setRevealedKey(""); setKeyError(null); }} fullWidth>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Test Model Modal — pins this connection via x-connection-id */}
|
||||
<Modal
|
||||
isOpen={showTestModelModal}
|
||||
title="Test Model"
|
||||
onClose={() => {
|
||||
if (testingModel) return;
|
||||
setShowTestModelModal(false);
|
||||
setTestModelResult(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-text-muted">
|
||||
Call one model using only this account:
|
||||
<span className="ml-1 font-medium text-text-main">{displayName}</span>
|
||||
</p>
|
||||
|
||||
{hasCatalogModels ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-text-muted">Model</label>
|
||||
<select
|
||||
value={selectedTestModelId}
|
||||
onChange={(e) => {
|
||||
setSelectedTestModelId(e.target.value);
|
||||
setTestModelResult(null);
|
||||
}}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
disabled={testingModel}
|
||||
>
|
||||
{testModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name && model.name !== model.id ? `${model.name} (${model.id})` : model.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-text-muted">Model ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={manualTestModelId}
|
||||
onChange={(e) => {
|
||||
setManualTestModelId(e.target.value);
|
||||
setTestModelResult(null);
|
||||
}}
|
||||
placeholder="e.g. gpt-4o-mini"
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none"
|
||||
disabled={testingModel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testModelResult && (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
testModelResult.ok
|
||||
? "border-green-300 bg-green-500/10 text-green-700 dark:border-green-800 dark:text-green-400"
|
||||
: "border-red-300 bg-red-500/10 text-red-600 dark:border-red-800 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined shrink-0 text-[16px]">
|
||||
{testModelResult.ok ? "check_circle" : "error"}
|
||||
</span>
|
||||
<div className="min-w-0 break-words">
|
||||
<p className="font-medium">
|
||||
{testModelResult.ok ? "Model reachable" : "Test failed"}
|
||||
{typeof testModelResult.latencyMs === "number" ? ` · ${testModelResult.latencyMs}ms` : ""}
|
||||
</p>
|
||||
{testModelResult.error && (
|
||||
<p className="mt-0.5 text-xs opacity-90">{testModelResult.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleTestModel}
|
||||
disabled={!resolveTestModelId()}
|
||||
loading={testingModel}
|
||||
icon="science"
|
||||
fullWidth
|
||||
>
|
||||
{testingModel ? "Testing..." : "Run Test"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (testingModel) return;
|
||||
setShowTestModelModal(false);
|
||||
setTestModelResult(null);
|
||||
}}
|
||||
disabled={testingModel}
|
||||
fullWidth
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -584,9 +315,4 @@ ConnectionRow.propTypes = {
|
||||
onToggle: PropTypes.func,
|
||||
provider: PropTypes.string,
|
||||
}),
|
||||
testModels: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
providerAlias: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -65,7 +65,6 @@ export default function ProviderDetailPage() {
|
||||
const [providerStrategy, setProviderStrategy] = useState(null);
|
||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||
const [providerNoAuthEnabled, setProviderNoAuthEnabled] = useState(true);
|
||||
const [providerTimeout, setProviderTimeout] = useState("");
|
||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
@@ -324,9 +323,6 @@ export default function ProviderDetailPage() {
|
||||
setProviderStrategy(override.fallbackStrategy || null);
|
||||
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||
setProviderNoAuthEnabled(override.enabled !== false);
|
||||
// Load per-provider connect timeout
|
||||
const timeoutCfg = (settingsData.providerTimeouts || {})[providerId] || {};
|
||||
setProviderTimeout(timeoutCfg.timeoutMs != null ? String(timeoutCfg.timeoutMs) : "");
|
||||
// Load per-provider thinking config
|
||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||
setThinkingMode(thinkingCfg.mode || "auto");
|
||||
@@ -463,38 +459,6 @@ export default function ProviderDetailPage() {
|
||||
saveThinkingConfig(mode);
|
||||
};
|
||||
|
||||
const saveProviderTimeout = async (ms) => {
|
||||
try {
|
||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
const current = settingsData.providerTimeouts || {};
|
||||
const updated = { ...current };
|
||||
if (!ms || ms === "") {
|
||||
delete updated[providerId];
|
||||
} else {
|
||||
const timeoutMs = parseInt(ms, 10);
|
||||
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
||||
updated[providerId] = { timeoutMs };
|
||||
} else {
|
||||
delete updated[providerId];
|
||||
}
|
||||
}
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerTimeouts: updated }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error saving provider timeout:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeoutChange = (value) => {
|
||||
const cleaned = value.replace(/[^0-9]/g, "");
|
||||
setProviderTimeout(cleaned);
|
||||
saveProviderTimeout(cleaned);
|
||||
};
|
||||
|
||||
const saveAutoPing = async (next) => {
|
||||
const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId];
|
||||
if (!autoPingSettingsKey) return;
|
||||
@@ -1714,21 +1678,6 @@ export default function ProviderDetailPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Connect Timeout */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Connect Timeout</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={providerTimeout}
|
||||
onChange={(e) => handleTimeoutChange(e.target.value)}
|
||||
placeholder="default"
|
||||
className="w-20 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<span className="text-xs text-text-muted">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Round Robin toggle */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function ProviderLimits() {
|
||||
const [proxyPools, setProxyPools] = useState([]);
|
||||
const [providerFilter, setProviderFilter] = useState("all");
|
||||
const [providerOptions, setProviderOptions] = useState([]);
|
||||
const [accountFilter, setAccountFilter] = useState("active");
|
||||
const [accountFilter, setAccountFilter] = useState("all");
|
||||
const [quotaSortMode, setQuotaSortMode] = useState("default");
|
||||
const [quotaVisibility, setQuotaVisibility] = useState({});
|
||||
const [expiringFirst, setExpiringFirst] = useState(false);
|
||||
|
||||
@@ -114,19 +114,9 @@ export default function RequestDetailsTab() {
|
||||
const [providerNameCache, setProviderNameCache] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
provider: "",
|
||||
model: "",
|
||||
status: "",
|
||||
connectionId: "",
|
||||
apiKey: "",
|
||||
startDate: "",
|
||||
endDate: ""
|
||||
});
|
||||
const [filterOptions, setFilterOptions] = useState({
|
||||
models: [],
|
||||
apiKeys: [],
|
||||
statuses: [],
|
||||
connections: []
|
||||
});
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
@@ -141,21 +131,6 @@ export default function RequestDetailsTab() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFilterOptions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/usage/filters");
|
||||
const data = await res.json();
|
||||
setFilterOptions({
|
||||
models: data.models || [],
|
||||
apiKeys: data.apiKeys || [],
|
||||
statuses: data.statuses || [],
|
||||
connections: data.connections || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch filter options:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDetails = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -164,10 +139,6 @@ export default function RequestDetailsTab() {
|
||||
pageSize: pagination.pageSize.toString()
|
||||
});
|
||||
if (filters.provider) params.append("provider", filters.provider);
|
||||
if (filters.model) params.append("model", filters.model);
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.connectionId) params.append("connectionId", filters.connectionId);
|
||||
if (filters.apiKey) params.append("apiKey", filters.apiKey);
|
||||
if (filters.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters.endDate) params.append("endDate", filters.endDate);
|
||||
|
||||
@@ -185,8 +156,7 @@ export default function RequestDetailsTab() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders();
|
||||
fetchFilterOptions();
|
||||
}, [fetchProviders, fetchFilterOptions]);
|
||||
}, [fetchProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetails();
|
||||
@@ -206,7 +176,7 @@ export default function RequestDetailsTab() {
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({ provider: "", model: "", status: "", connectionId: "", apiKey: "", startDate: "", endDate: "" });
|
||||
setFilters({ provider: "", startDate: "", endDate: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -234,98 +204,6 @@ export default function RequestDetailsTab() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="model-filter" className="text-sm font-medium text-text-main">Model</label>
|
||||
<select
|
||||
id="model-filter"
|
||||
value={filters.model}
|
||||
onChange={(e) => setFilters({ ...filters, model: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Models</option>
|
||||
{filterOptions.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="status-filter" className="text-sm font-medium text-text-main">Status</label>
|
||||
<select
|
||||
id="status-filter"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{filterOptions.statuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="connection-filter" className="text-sm font-medium text-text-main">Account</label>
|
||||
<select
|
||||
id="connection-filter"
|
||||
value={filters.connectionId}
|
||||
onChange={(e) => setFilters({ ...filters, connectionId: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All Accounts</option>
|
||||
{filterOptions.connections.map((group) => (
|
||||
<optgroup key={group.provider} label={group.name}>
|
||||
{group.accounts.map((conn) => (
|
||||
<option key={conn.id} value={conn.id}>
|
||||
{conn.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="apikey-filter" className="text-sm font-medium text-text-main">API Key</label>
|
||||
<select
|
||||
id="apikey-filter"
|
||||
value={filters.apiKey}
|
||||
onChange={(e) => setFilters({ ...filters, apiKey: e.target.value })}
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
|
||||
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
"w-full min-w-0 cursor-pointer"
|
||||
)}
|
||||
style={{ colorScheme: 'auto' }}
|
||||
>
|
||||
<option value="">All API Keys</option>
|
||||
{filterOptions.apiKeys.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor="start-date-filter" className="text-sm font-medium text-text-main">Start Date</label>
|
||||
@@ -360,7 +238,7 @@ export default function RequestDetailsTab() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClearFilters}
|
||||
disabled={!filters.provider && !filters.model && !filters.status && !filters.connectionId && !filters.apiKey && !filters.startDate && !filters.endDate}
|
||||
disabled={!filters.provider && !filters.startDate && !filters.endDate}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filters
|
||||
@@ -377,7 +255,6 @@ export default function RequestDetailsTab() {
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Timestamp</th>
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Model</th>
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Provider</th>
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Status</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Input Tokens</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Cached</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Cache Creation</th>
|
||||
@@ -419,20 +296,6 @@ export default function RequestDetailsTab() {
|
||||
{getProviderName(detail.provider, providerNameCache)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
detail.status === "error"
|
||||
? "bg-red-500/10 text-red-600"
|
||||
: "bg-green-500/10 text-green-600"
|
||||
)}>
|
||||
<span className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
detail.status === "error" ? "bg-red-500" : "bg-green-500"
|
||||
)} />
|
||||
{detail.status === "error" ? "Error" : "Success"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{getInputTokens(detail.tokens).toLocaleString()}
|
||||
</td>
|
||||
|
||||
@@ -105,7 +105,6 @@ export default function UsageTable({
|
||||
renderDetailCells,
|
||||
renderSummaryCells,
|
||||
emptyMessage,
|
||||
providerLabel,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(new Set());
|
||||
|
||||
@@ -199,13 +198,8 @@ export default function UsageTable({
|
||||
<span className={`material-symbols-outlined text-[18px] text-text-muted transition-transform ${expanded.has(group.groupKey) ? "rotate-90" : ""}`}>
|
||||
chevron_right
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium transition-colors ${group.summary.pending > 0 ? "text-primary" : ""}`}
|
||||
title={group.groupKey}
|
||||
>
|
||||
{tableType === "provider" && providerLabel
|
||||
? providerLabel(group.groupKey)
|
||||
: group.groupKey}
|
||||
<span className={`font-medium transition-colors ${group.summary.pending > 0 ? "text-primary" : ""}`}>
|
||||
{group.groupKey}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { importApiKey, getApiKeys } from "@/lib/localDb";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// POST /api/keys/import - Import existing API key
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, key } = body;
|
||||
|
||||
if (!key?.trim()) {
|
||||
return NextResponse.json({ error: "API key value is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = await importApiKey(name, key.trim());
|
||||
|
||||
return NextResponse.json({
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
id: apiKey.id,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
const message = error.message;
|
||||
if (message?.includes("already exists")) {
|
||||
return NextResponse.json({ error: message }, { status: 409 });
|
||||
}
|
||||
console.log("Error importing key:", error);
|
||||
return NextResponse.json({ error: message || "Failed to import key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -50,15 +50,8 @@ async function getInternalHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function pingModelByKind(
|
||||
model,
|
||||
kind,
|
||||
baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`,
|
||||
options = {},
|
||||
) {
|
||||
export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`) {
|
||||
const headers = await getInternalHeaders();
|
||||
// Pin the request to a specific provider connection when testing a single account.
|
||||
if (options.connectionId) headers["x-connection-id"] = options.connectionId;
|
||||
const start = Date.now();
|
||||
|
||||
if (kind === "embedding") {
|
||||
|
||||
@@ -2,12 +2,11 @@ import { NextResponse } from "next/server";
|
||||
import { pingModelByKind } from "./ping";
|
||||
|
||||
// POST /api/models/test - Ping a single model via internal completions or embeddings
|
||||
// Optional body.connectionId pins the request to one provider account.
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { model, kind, connectionId } = await request.json();
|
||||
const { model, kind } = await request.json();
|
||||
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
|
||||
const result = await pingModelByKind(model, kind || "llm", undefined, { connectionId: connectionId || null });
|
||||
const result = await pingModelByKind(model, kind || "llm");
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/providers/[id]/api-key - Get API key for a connection
|
||||
// Only returns key for apikey authType connections
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const connection = await getProviderConnectionById(id);
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (connection.authType !== "apikey" && connection.authType !== "api_key") {
|
||||
return NextResponse.json({ error: "This connection does not use API key authentication" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ apiKey: connection.apiKey || "" });
|
||||
} catch (error) {
|
||||
console.log("Error fetching API key:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch API key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -140,12 +140,6 @@ export async function PUT(request, { params }) {
|
||||
...(providerSpecificData || {}),
|
||||
};
|
||||
|
||||
// null sentinel = explicit delete for sensitive/optional PSD keys
|
||||
for (const key of Object.keys(updateData.providerSpecificData)) {
|
||||
if (updateData.providerSpecificData[key] === null) {
|
||||
delete updateData.providerSpecificData[key];
|
||||
}
|
||||
}
|
||||
if (proxyConfig.hasAnyProxyField) {
|
||||
updateData.providerSpecificData.connectionProxyEnabled = proxyConfig.connectionProxyEnabled;
|
||||
updateData.providerSpecificData.connectionProxyUrl = proxyConfig.connectionProxyUrl;
|
||||
@@ -169,10 +163,6 @@ export async function PUT(request, { params }) {
|
||||
delete result.accessToken;
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
if (result.providerSpecificData) {
|
||||
const psd = { ...result.providerSpecificData };
|
||||
result.providerSpecificData = psd;
|
||||
}
|
||||
|
||||
return NextResponse.json({ connection: result });
|
||||
} catch (error) {
|
||||
|
||||
@@ -43,17 +43,15 @@ export async function POST(request, { params }) {
|
||||
|
||||
// Warm up with first model to trigger token refresh (if needed) before parallel calls.
|
||||
// This prevents race condition where multiple requests concurrently refresh the same token.
|
||||
// Always pin to this connection so multi-account providers test the intended account only.
|
||||
const pinOpts = { connectionId: id };
|
||||
const [first, ...rest] = models;
|
||||
const firstKind = first.kind || first.type || "llm";
|
||||
const firstResult = await pingModelByKind(`${alias}/${first.id}`, firstKind, baseUrl, pinOpts);
|
||||
const firstResult = await pingModelByKind(`${alias}/${first.id}`, firstKind, baseUrl);
|
||||
const results = [{ modelId: first.id, name: first.name || first.id, ...firstResult }];
|
||||
|
||||
if (rest.length > 0) {
|
||||
const restResults = await Promise.all(
|
||||
rest.map(async (model) => {
|
||||
const result = await pingModelByKind(`${alias}/${model.id}`, model.kind || model.type || "llm", baseUrl, pinOpts);
|
||||
const result = await pingModelByKind(`${alias}/${model.id}`, model.kind || model.type || "llm", baseUrl);
|
||||
return { modelId: model.id, name: model.name || model.id, ...result };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -44,12 +44,8 @@ function sanitize(c) {
|
||||
}
|
||||
|
||||
function isUsageEligible(connection) {
|
||||
if (!USAGE_SUPPORTED_PROVIDERS.includes(connection.provider)) return false;
|
||||
// OAuth + apikey/cookie providers that expose a usage API (cookie used by grok-web).
|
||||
return (
|
||||
connection.authType === "oauth" ||
|
||||
connection.authType === "cookie" ||
|
||||
USAGE_APIKEY_PROVIDERS.includes(connection.provider)
|
||||
return USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) && (
|
||||
connection.authType === "oauth" || USAGE_APIKEY_PROVIDERS.includes(connection.provider)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ export async function GET() {
|
||||
const name = isCompatible
|
||||
? (c.name || nodeNameMap[c.provider] || c.providerSpecificData?.nodeName || c.provider)
|
||||
: c.name;
|
||||
const psd = c.providerSpecificData ? { ...c.providerSpecificData } : undefined;
|
||||
return {
|
||||
...c,
|
||||
name,
|
||||
@@ -74,7 +73,6 @@ export async function GET() {
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
idToken: undefined,
|
||||
providerSpecificData: psd,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -132,15 +132,14 @@ export async function GET(request, { params }) {
|
||||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Allow OAuth connections, plus whitelisted apikey/cookie providers (glm/minimax/kiro/grok-web/...)
|
||||
// Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/kiro/...)
|
||||
// Kiro's headless api-key flow persists authType "api_key" (underscore) while
|
||||
// generic apikey providers persist "apikey". Web cookie providers (grok-web) use "cookie".
|
||||
// generic apikey providers persist "apikey" — accept both spellings here.
|
||||
const isOAuth = connection.authType === "oauth";
|
||||
const isApikeyAuth =
|
||||
connection.authType === "apikey" || connection.authType === "api_key";
|
||||
const isCookieAuth = connection.authType === "cookie";
|
||||
const isApikeyEligible =
|
||||
(isApikeyAuth || isCookieAuth) && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
||||
isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
||||
|
||||
if (!isOAuth && !isApikeyEligible) {
|
||||
return Response.json({ message: "Usage not available for this connection" });
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getDistinctModels,
|
||||
getDistinctApiKeys,
|
||||
getDistinctStatuses,
|
||||
} from "@/lib/requestDetailsDb";
|
||||
import { getProviderConnections, getProviderNodes } from "@/lib/localDb";
|
||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
|
||||
/**
|
||||
* GET /api/usage/filters
|
||||
* Returns option lists for the Usage → Details filter bar:
|
||||
* models: distinct model ids seen in requestDetails
|
||||
* apiKeys: distinct (masked) api keys seen in requestDetails
|
||||
* statuses: distinct status values (success/error)
|
||||
* connections: provider connections grouped by provider (for <optgroup>)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const [models, apiKeys, statuses, connections, nodes] = await Promise.all([
|
||||
getDistinctModels(),
|
||||
getDistinctApiKeys(),
|
||||
getDistinctStatuses(),
|
||||
getProviderConnections(),
|
||||
getProviderNodes(),
|
||||
]);
|
||||
|
||||
const nodeNameMap = {};
|
||||
for (const node of nodes || []) {
|
||||
if (node.id && node.name) nodeNameMap[node.id] = node.name;
|
||||
}
|
||||
|
||||
const byProvider = {};
|
||||
for (const c of connections) {
|
||||
const isCompatible =
|
||||
isOpenAICompatibleProvider(c.provider) ||
|
||||
isAnthropicCompatibleProvider(c.provider);
|
||||
const baseName = isCompatible
|
||||
? c.name || nodeNameMap[c.provider] || c.providerSpecificData?.nodeName
|
||||
: c.name;
|
||||
const providerName =
|
||||
getProviderByAlias(c.provider)?.name || AI_PROVIDERS[c.provider]?.name || c.provider || "unknown";
|
||||
const key = c.provider || "unknown";
|
||||
if (!byProvider[key]) {
|
||||
byProvider[key] = { provider: key, name: providerName, accounts: [] };
|
||||
}
|
||||
byProvider[key].accounts.push({
|
||||
id: c.id,
|
||||
label: baseName || providerName,
|
||||
});
|
||||
}
|
||||
|
||||
const connectionsList = Object.values(byProvider).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
return NextResponse.json({ models, apiKeys, statuses, connections: connectionsList });
|
||||
} catch (error) {
|
||||
console.error("[API] Failed to get usage filters:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch usage filters" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export async function GET(request) {
|
||||
const model = searchParams.get("model");
|
||||
const connectionId = searchParams.get("connectionId");
|
||||
const status = searchParams.get("status");
|
||||
const apiKey = searchParams.get("apiKey");
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
@@ -44,7 +43,6 @@ export async function GET(request) {
|
||||
if (model) filter.model = model;
|
||||
if (connectionId) filter.connectionId = connectionId;
|
||||
if (status) filter.status = status;
|
||||
if (apiKey) filter.apiKey = apiKey;
|
||||
if (startDate) filter.startDate = startDate;
|
||||
if (endDate) filter.endDate = endDate;
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export {
|
||||
|
||||
// API keys
|
||||
export {
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey,
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||
} from "./repos/apiKeysRepo.js";
|
||||
|
||||
// Combos
|
||||
@@ -65,7 +65,6 @@ export {
|
||||
// Request details
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||
} from "./repos/requestDetailsRepo.js";
|
||||
|
||||
// Export/import full DB
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Adds an indexed apiKey column to requestDetails so the Details tab can
|
||||
// filter by (masked) API key without parsing the JSON data blob.
|
||||
export default {
|
||||
version: 2,
|
||||
name: "request-details-apikey",
|
||||
up(db) {
|
||||
// Idempotent: fresh DBs already get the column from TABLES schema.
|
||||
const existing = db.all(`PRAGMA table_info(requestDetails)`) || [];
|
||||
if (!existing.some((c) => c.name === "apiKey")) {
|
||||
db.exec(`ALTER TABLE requestDetails ADD COLUMN apiKey TEXT`);
|
||||
}
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_rd_apikey ON requestDetails(apiKey)`);
|
||||
},
|
||||
};
|
||||
@@ -2,9 +2,8 @@
|
||||
// Each migration: { version: number, name: string, up(db): void }
|
||||
// Versions MUST be unique and monotonically increasing.
|
||||
import m001 from "./001-initial.js";
|
||||
import m002 from "./002-request-details-apikey.js";
|
||||
|
||||
export const MIGRATIONS = [m001, m002].sort((a, b) => a.version - b.version);
|
||||
export const MIGRATIONS = [m001].sort((a, b) => a.version - b.version);
|
||||
|
||||
export function latestVersion() {
|
||||
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
||||
|
||||
@@ -61,31 +61,6 @@ export async function updateApiKey(id, data) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function importApiKey(name, keyValue) {
|
||||
if (!keyValue?.trim()) throw new Error("Key value is required");
|
||||
const db = await getAdapter();
|
||||
|
||||
// Check for duplicates
|
||||
const existing = db.get(`SELECT id FROM apiKeys WHERE key = ?`, [keyValue.trim()]);
|
||||
if (existing) {
|
||||
throw new Error("This API key already exists in the system");
|
||||
}
|
||||
|
||||
const apiKey = {
|
||||
id: uuidv4(),
|
||||
name: name?.trim() || "Imported Key",
|
||||
key: keyValue.trim(),
|
||||
machineId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
db.run(
|
||||
`INSERT INTO apiKeys(id, key, name, machineId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
[apiKey.id, apiKey.key, apiKey.name, apiKey.machineId, 1, apiKey.createdAt]
|
||||
);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export async function deleteApiKey(id) {
|
||||
const db = await getAdapter();
|
||||
const res = db.run(`DELETE FROM apiKeys WHERE id = ?`, [id]);
|
||||
|
||||
@@ -3,92 +3,71 @@ import { getAdapter } from "../driver.js";
|
||||
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||
|
||||
function rowToCombo(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
models: parseJson(row.models, []),
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
models: parseJson(row.models, []),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCombos() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`);
|
||||
return rows.map(rowToCombo);
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT * FROM combos ORDER BY createdAt ASC`);
|
||||
return rows.map(rowToCombo);
|
||||
}
|
||||
|
||||
export async function getComboById(id) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||
return rowToCombo(row);
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||
return rowToCombo(row);
|
||||
}
|
||||
|
||||
export async function getComboByName(name) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]);
|
||||
return rowToCombo(row);
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT * FROM combos WHERE name = ?`, [name]);
|
||||
return rowToCombo(row);
|
||||
}
|
||||
|
||||
export async function createCombo(data) {
|
||||
const db = await getAdapter();
|
||||
const now = new Date().toISOString();
|
||||
const combo = {
|
||||
id: uuidv4(),
|
||||
name: data.name,
|
||||
kind: data.kind || null,
|
||||
models: data.models || [],
|
||||
enabled: data.enabled !== false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
db.run(
|
||||
`INSERT INTO combos(id, name, kind, models, enabled, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
combo.id,
|
||||
combo.name,
|
||||
combo.kind,
|
||||
stringifyJson(combo.models),
|
||||
combo.enabled !== false ? 1 : 0,
|
||||
combo.createdAt,
|
||||
combo.updatedAt,
|
||||
],
|
||||
);
|
||||
return combo;
|
||||
const db = await getAdapter();
|
||||
const now = new Date().toISOString();
|
||||
const combo = {
|
||||
id: uuidv4(),
|
||||
name: data.name,
|
||||
kind: data.kind || null,
|
||||
models: data.models || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
db.run(
|
||||
`INSERT INTO combos(id, name, kind, models, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
[combo.id, combo.name, combo.kind, stringifyJson(combo.models), combo.createdAt, combo.updatedAt]
|
||||
);
|
||||
return combo;
|
||||
}
|
||||
|
||||
export async function updateCombo(id, data) {
|
||||
const db = await getAdapter();
|
||||
let result = null;
|
||||
db.transaction(() => {
|
||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||
if (!row) return;
|
||||
const merged = {
|
||||
...rowToCombo(row),
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
db.run(
|
||||
`UPDATE combos SET name = ?, kind = ?, models = ?, enabled = ?, updatedAt = ? WHERE id = ?`,
|
||||
[
|
||||
merged.name,
|
||||
merged.kind,
|
||||
stringifyJson(merged.models || []),
|
||||
merged.enabled ? 1 : 0,
|
||||
merged.updatedAt,
|
||||
id,
|
||||
],
|
||||
);
|
||||
result = merged;
|
||||
});
|
||||
return result;
|
||||
const db = await getAdapter();
|
||||
let result = null;
|
||||
db.transaction(() => {
|
||||
const row = db.get(`SELECT * FROM combos WHERE id = ?`, [id]);
|
||||
if (!row) return;
|
||||
const merged = { ...rowToCombo(row), ...data, updatedAt: new Date().toISOString() };
|
||||
db.run(
|
||||
`UPDATE combos SET name = ?, kind = ?, models = ?, updatedAt = ? WHERE id = ?`,
|
||||
[merged.name, merged.kind, stringifyJson(merged.models || []), merged.updatedAt, id]
|
||||
);
|
||||
result = merged;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteCombo(id) {
|
||||
const db = await getAdapter();
|
||||
const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]);
|
||||
return (res?.changes ?? 0) > 0;
|
||||
const db = await getAdapter();
|
||||
const res = db.run(`DELETE FROM combos WHERE id = ?`, [id]);
|
||||
return (res?.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,6 @@ async function flushToDatabase() {
|
||||
connectionId: item.connectionId || null,
|
||||
timestamp: item.timestamp,
|
||||
status: item.status || null,
|
||||
apiKey: item.apiKey || null,
|
||||
latency: item.latency || {},
|
||||
tokens: item.tokens || {},
|
||||
request: truncateField(item.request, config.maxJsonSize),
|
||||
@@ -120,8 +119,8 @@ async function flushToDatabase() {
|
||||
};
|
||||
|
||||
db.run(
|
||||
`INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, apiKey, data) VALUES(?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, provider = excluded.provider, model = excluded.model, connectionId = excluded.connectionId, status = excluded.status, apiKey = excluded.apiKey, data = excluded.data`,
|
||||
[record.id, record.timestamp, record.provider, record.model, record.connectionId, record.status, record.apiKey, stringifyJson(record)]
|
||||
`INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, data) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, provider = excluded.provider, model = excluded.model, connectionId = excluded.connectionId, status = excluded.status, data = excluded.data`,
|
||||
[record.id, record.timestamp, record.provider, record.model, record.connectionId, record.status, stringifyJson(record)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +168,6 @@ export async function getRequestDetails(filter = {}) {
|
||||
if (filter.model) { conds.push("model = ?"); params.push(filter.model); }
|
||||
if (filter.connectionId) { conds.push("connectionId = ?"); params.push(filter.connectionId); }
|
||||
if (filter.status) { conds.push("status = ?"); params.push(filter.status); }
|
||||
if (filter.apiKey) { conds.push("apiKey = ?"); params.push(filter.apiKey); }
|
||||
if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); }
|
||||
if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); }
|
||||
|
||||
@@ -200,24 +198,6 @@ export async function getDistinctProviders() {
|
||||
return rows.map((r) => r.provider);
|
||||
}
|
||||
|
||||
export async function getDistinctModels() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT DISTINCT model FROM requestDetails WHERE model IS NOT NULL AND model != '' ORDER BY model ASC`);
|
||||
return rows.map((r) => r.model);
|
||||
}
|
||||
|
||||
export async function getDistinctApiKeys() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT DISTINCT apiKey FROM requestDetails WHERE apiKey IS NOT NULL AND apiKey != '' ORDER BY apiKey ASC`);
|
||||
return rows.map((r) => r.apiKey);
|
||||
}
|
||||
|
||||
export async function getDistinctStatuses() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT DISTINCT status FROM requestDetails WHERE status IS NOT NULL AND status != '' ORDER BY status ASC`);
|
||||
return rows.map((r) => r.status);
|
||||
}
|
||||
|
||||
export async function getRequestDetailById(id) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
|
||||
|
||||
@@ -13,8 +13,6 @@ const DEFAULT_SETTINGS = {
|
||||
tailscaleUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
providerStrategies: {},
|
||||
providerTimeouts: {},
|
||||
defaultTimeoutMs: null,
|
||||
quotaVisibility: {},
|
||||
comboStrategy: "fallback",
|
||||
comboStickyRoundRobinLimit: 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
||||
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
||||
export const SCHEMA_VERSION = 2;
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
export const PRAGMA_SQL = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
@@ -19,146 +19,143 @@ PRAGMA busy_timeout = 5000;
|
||||
// auto-add missing tables/columns/indexes after versioned migrations.
|
||||
// For destructive changes (drop/rename/type-change), write a migration file.
|
||||
export const TABLES = {
|
||||
_meta: {
|
||||
columns: {
|
||||
key: "TEXT PRIMARY KEY",
|
||||
value: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
columns: {
|
||||
id: "INTEGER PRIMARY KEY CHECK (id = 1)",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
providerConnections: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
provider: "TEXT NOT NULL",
|
||||
authType: "TEXT NOT NULL",
|
||||
name: "TEXT",
|
||||
email: "TEXT",
|
||||
priority: "INTEGER",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider_active ON providerConnections(provider, isActive)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)",
|
||||
],
|
||||
},
|
||||
providerNodes: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
type: "TEXT",
|
||||
name: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_pn_type ON providerNodes(type)"],
|
||||
},
|
||||
proxyPools: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
testStatus: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pp_active ON proxyPools(isActive)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pp_status ON proxyPools(testStatus)",
|
||||
],
|
||||
},
|
||||
apiKeys: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
key: "TEXT UNIQUE NOT NULL",
|
||||
name: "TEXT",
|
||||
machineId: "TEXT",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"],
|
||||
},
|
||||
combos: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
name: "TEXT UNIQUE NOT NULL",
|
||||
kind: "TEXT",
|
||||
models: "TEXT NOT NULL",
|
||||
enabled: "INTEGER NOT NULL DEFAULT 1",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"],
|
||||
},
|
||||
kv: {
|
||||
columns: {
|
||||
scope: "TEXT NOT NULL",
|
||||
key: "TEXT NOT NULL",
|
||||
value: "TEXT NOT NULL",
|
||||
},
|
||||
primaryKey: "PRIMARY KEY (scope, key)",
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv(scope)"],
|
||||
},
|
||||
usageHistory: {
|
||||
columns: {
|
||||
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||
timestamp: "TEXT NOT NULL",
|
||||
provider: "TEXT",
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
apiKey: "TEXT",
|
||||
endpoint: "TEXT",
|
||||
promptTokens: "INTEGER DEFAULT 0",
|
||||
completionTokens: "INTEGER DEFAULT 0",
|
||||
cost: "REAL DEFAULT 0",
|
||||
status: "TEXT",
|
||||
tokens: "TEXT",
|
||||
meta: "TEXT",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_ts ON usageHistory(timestamp DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
|
||||
],
|
||||
},
|
||||
usageDaily: {
|
||||
columns: {
|
||||
dateKey: "TEXT PRIMARY KEY",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
requestDetails: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
timestamp: "TEXT NOT NULL",
|
||||
provider: "TEXT",
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
status: "TEXT",
|
||||
apiKey: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_ts ON requestDetails(timestamp DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_apikey ON requestDetails(apiKey)",
|
||||
],
|
||||
},
|
||||
_meta: {
|
||||
columns: {
|
||||
key: "TEXT PRIMARY KEY",
|
||||
value: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
columns: {
|
||||
id: "INTEGER PRIMARY KEY CHECK (id = 1)",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
providerConnections: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
provider: "TEXT NOT NULL",
|
||||
authType: "TEXT NOT NULL",
|
||||
name: "TEXT",
|
||||
email: "TEXT",
|
||||
priority: "INTEGER",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_provider_active ON providerConnections(provider, isActive)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)",
|
||||
],
|
||||
},
|
||||
providerNodes: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
type: "TEXT",
|
||||
name: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_pn_type ON providerNodes(type)"],
|
||||
},
|
||||
proxyPools: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
testStatus: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_pp_active ON proxyPools(isActive)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pp_status ON proxyPools(testStatus)",
|
||||
],
|
||||
},
|
||||
apiKeys: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
key: "TEXT UNIQUE NOT NULL",
|
||||
name: "TEXT",
|
||||
machineId: "TEXT",
|
||||
isActive: "INTEGER DEFAULT 1",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"],
|
||||
},
|
||||
combos: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
name: "TEXT UNIQUE NOT NULL",
|
||||
kind: "TEXT",
|
||||
models: "TEXT NOT NULL",
|
||||
createdAt: "TEXT NOT NULL",
|
||||
updatedAt: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_combo_name ON combos(name)"],
|
||||
},
|
||||
kv: {
|
||||
columns: {
|
||||
scope: "TEXT NOT NULL",
|
||||
key: "TEXT NOT NULL",
|
||||
value: "TEXT NOT NULL",
|
||||
},
|
||||
primaryKey: "PRIMARY KEY (scope, key)",
|
||||
indexes: ["CREATE INDEX IF NOT EXISTS idx_kv_scope ON kv(scope)"],
|
||||
},
|
||||
usageHistory: {
|
||||
columns: {
|
||||
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||
timestamp: "TEXT NOT NULL",
|
||||
provider: "TEXT",
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
apiKey: "TEXT",
|
||||
endpoint: "TEXT",
|
||||
promptTokens: "INTEGER DEFAULT 0",
|
||||
completionTokens: "INTEGER DEFAULT 0",
|
||||
cost: "REAL DEFAULT 0",
|
||||
status: "TEXT",
|
||||
tokens: "TEXT",
|
||||
meta: "TEXT",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_ts ON usageHistory(timestamp DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
|
||||
],
|
||||
},
|
||||
usageDaily: {
|
||||
columns: {
|
||||
dateKey: "TEXT PRIMARY KEY",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
},
|
||||
requestDetails: {
|
||||
columns: {
|
||||
id: "TEXT PRIMARY KEY",
|
||||
timestamp: "TEXT NOT NULL",
|
||||
provider: "TEXT",
|
||||
model: "TEXT",
|
||||
connectionId: "TEXT",
|
||||
status: "TEXT",
|
||||
data: "TEXT NOT NULL",
|
||||
},
|
||||
indexes: [
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_ts ON requestDetails(timestamp DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function buildCreateTableSql(name, def) {
|
||||
const cols = Object.entries(def.columns).map(([k, v]) => `${k} ${v}`);
|
||||
if (def.primaryKey) cols.push(def.primaryKey);
|
||||
return `CREATE TABLE IF NOT EXISTS ${name} (${cols.join(", ")})`;
|
||||
const cols = Object.entries(def.columns).map(([k, v]) => `${k} ${v}`);
|
||||
if (def.primaryKey) cols.push(def.primaryKey);
|
||||
return `CREATE TABLE IF NOT EXISTS ${name} (${cols.join(", ")})`;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export {
|
||||
createProviderNode, updateProviderNode, deleteProviderNode,
|
||||
getProxyPools, getProxyPoolById,
|
||||
createProxyPool, updateProxyPool, deleteProxyPool,
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey,
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||
getCombos, getComboById, getComboByName,
|
||||
createCombo, updateCombo, deleteCombo,
|
||||
getModelAliases, setModelAlias, deleteModelAlias,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||
} from "@/lib/db/index.js";
|
||||
|
||||
@@ -4,5 +4,4 @@ export {
|
||||
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
|
||||
appendRequestLog, getRecentLogs,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||
} from "@/lib/db/index.js";
|
||||
|
||||
@@ -32,7 +32,6 @@ export {
|
||||
setMitmAliasAll,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
importApiKey,
|
||||
deleteApiKey,
|
||||
validateApiKey,
|
||||
isCloudEnabled,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -171,7 +171,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
||||
if (providerRegions && region) {
|
||||
updates.providerSpecificData = buildRegionSpecificData();
|
||||
}
|
||||
|
||||
|
||||
await onSave(updates);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -202,8 +202,6 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Modal({
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "max-w-6xl",
|
||||
full: "max-w-4xl",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,10 +99,7 @@ export default function Modal({
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className={cn(
|
||||
"p-6 overflow-y-auto custom-scrollbar",
|
||||
size === "full" ? "max-h-[calc(92vh-100px)]" : "max-h-[calc(85vh-100px)]"
|
||||
)}>{children}</div>
|
||||
<div className="p-6 max-h-[calc(85vh-100px)] overflow-y-auto custom-scrollbar">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,6 @@ export { default as SegmentedControl } from "./SegmentedControl";
|
||||
export { default as Tooltip } from "./Tooltip";
|
||||
export { default as ProviderInfoCard } from "./ProviderInfoCard";
|
||||
export { default as CapacityBadges } from "./CapacityBadges";
|
||||
export { default as ApiExplorerModal } from "./ApiExplorerModal";
|
||||
|
||||
// Layouts
|
||||
export * from "./layouts";
|
||||
|
||||
@@ -1,871 +0,0 @@
|
||||
/**
|
||||
* Public AI API catalog for the dashboard API Explorer.
|
||||
* Single source of truth for endpoint docs + form fields used by ApiExplorerModal.
|
||||
*/
|
||||
|
||||
/** @typedef {"string"|"number"|"boolean"|"select"|"textarea"|"json"|"file"|"messages"} FieldType */
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApiField
|
||||
* @property {string} key
|
||||
* @property {string} label
|
||||
* @property {FieldType} type
|
||||
* @property {boolean} [required]
|
||||
* @property {string} [description]
|
||||
* @property {*} [default]
|
||||
* @property {string[]} [options]
|
||||
* @property {number} [min]
|
||||
* @property {number} [max]
|
||||
* @property {number} [step]
|
||||
* @property {string} [placeholder]
|
||||
* @property {string} [accept] - file accept attr
|
||||
* @property {"body"|"query"|"header"} [location] - default body
|
||||
* @property {boolean} [advanced] - hide behind "Advanced"
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApiEndpoint
|
||||
* @property {string} id
|
||||
* @property {string} method
|
||||
* @property {string} path
|
||||
* @property {string} label
|
||||
* @property {string} description
|
||||
* @property {string} icon
|
||||
* @property {string} category
|
||||
* @property {"json"|"multipart"|"none"} contentType
|
||||
* @property {boolean} [needsModel]
|
||||
* @property {string} [modelsKind] - /v1/models/{kind} query
|
||||
* @property {ApiField[]} fields
|
||||
* @property {string} [defaultResponse]
|
||||
* @property {boolean} [supportsStream]
|
||||
*/
|
||||
|
||||
/** @type {ApiEndpoint[]} */
|
||||
export const API_CATALOG = [
|
||||
{
|
||||
id: "models",
|
||||
method: "GET",
|
||||
path: "/v1/models",
|
||||
label: "List Models",
|
||||
description: "List chat/LLM models (default). Combos appear with owned_by: combo.",
|
||||
icon: "list_alt",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [\n { "id": "openai/gpt-4o", "object": "model", "owned_by": "openai" }\n ]\n}`,
|
||||
},
|
||||
{
|
||||
id: "models-kind",
|
||||
method: "GET",
|
||||
path: "/v1/models/{kind}",
|
||||
label: "List Models by Kind",
|
||||
description: "List models for a capability kind: image, tts, stt, embedding, web, etc.",
|
||||
icon: "category",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "kind",
|
||||
label: "Kind",
|
||||
type: "select",
|
||||
required: true,
|
||||
location: "path",
|
||||
default: "image",
|
||||
options: ["image", "tts", "stt", "embedding", "web", "image-to-text"],
|
||||
description: "Path segment: /v1/models/{kind}. Chat models use GET /v1/models (no kind).",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "id": "openai/dall-e-3", "object": "model", "owned_by": "openai" }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "models-info",
|
||||
method: "GET",
|
||||
path: "/v1/models/info",
|
||||
label: "Model Info",
|
||||
description: "Per-model metadata: params, options, contextWindow, capabilities.",
|
||||
icon: "info",
|
||||
category: "Discovery",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "id",
|
||||
label: "Model ID",
|
||||
type: "string",
|
||||
required: true,
|
||||
location: "query",
|
||||
placeholder: "openai/gpt-4o",
|
||||
description: "Query: ?id=provider/model",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "openai/gpt-4o",\n "kind": "llm",\n "endpoint": "/v1/chat/completions",\n "contextWindow": 128000\n}`,
|
||||
},
|
||||
{
|
||||
id: "chat-completions",
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
label: "Chat Completions",
|
||||
description: "OpenAI-compatible chat/code generation with streaming + auto-fallback.",
|
||||
icon: "chat",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello! Say hi in one sentence." }],
|
||||
description: "Array of { role, content }",
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "SSE streaming response",
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_p",
|
||||
label: "Top P",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_tokens",
|
||||
label: "Max Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_completion_tokens",
|
||||
label: "Max Completion Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "presence_penalty",
|
||||
label: "Presence Penalty",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "frequency_penalty",
|
||||
label: "Frequency Penalty",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "stop",
|
||||
label: "Stop",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '["\\n"] or "stop"',
|
||||
advanced: true,
|
||||
description: "String or array of stop sequences",
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: "[{ type: \"function\", function: {...} }]",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tool_choice",
|
||||
label: "Tool Choice",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '"auto" | "none" | { type, function }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "json_object" }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "reasoning_effort",
|
||||
label: "Reasoning Effort",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "low", "medium", "high"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "thinking",
|
||||
label: "Thinking",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "enabled", "budget_tokens": 1024 }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "n",
|
||||
label: "n",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
max: 8,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: "User",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "chatcmpl-...",\n "object": "chat.completion",\n "choices": [{ "message": { "role": "assistant", "content": "..." }, "finish_reason": "stop" }],\n "usage": { "prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "messages",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
label: "Messages (Anthropic)",
|
||||
description: "Anthropic Messages API format. Same models, Claude-compatible tools/clients.",
|
||||
icon: "forum",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello! Say hi in one sentence." }],
|
||||
},
|
||||
{
|
||||
key: "max_tokens",
|
||||
label: "Max Tokens",
|
||||
type: "number",
|
||||
required: true,
|
||||
default: 1024,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
label: "System",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
placeholder: "You are a helpful assistant.",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_p",
|
||||
label: "Top P",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "top_k",
|
||||
label: "Top K",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "stop_sequences",
|
||||
label: "Stop Sequences",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '["\\n\\nHuman:"]',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tool_choice",
|
||||
label: "Tool Choice",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "thinking",
|
||||
label: "Thinking",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "type": "enabled", "budget_tokens": 1024 }',
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "anthropic-version",
|
||||
label: "anthropic-version",
|
||||
type: "string",
|
||||
location: "header",
|
||||
default: "2023-06-01",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "msg_...",\n "type": "message",\n "role": "assistant",\n "content": [{ "type": "text", "text": "Hello!" }],\n "stop_reason": "end_turn",\n "usage": { "input_tokens": 8, "output_tokens": 2 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
method: "POST",
|
||||
path: "/v1/responses",
|
||||
label: "Responses API",
|
||||
description: "OpenAI Responses format (Codex / OpenClaw). input can be string or items[].",
|
||||
icon: "reply",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
supportsStream: true,
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello! Say hi in one sentence.",
|
||||
description: "String or JSON array of input items",
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
label: "Stream",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: "instructions",
|
||||
label: "Instructions",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "max_output_tokens",
|
||||
label: "Max Output Tokens",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
type: "json",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "reasoning",
|
||||
label: "Reasoning",
|
||||
type: "json",
|
||||
default: "",
|
||||
placeholder: '{ "effort": "medium" }',
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "id": "resp_...",\n "object": "response",\n "status": "completed",\n "output": [{ "type": "message", "content": [{ "type": "output_text", "text": "..." }] }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "count-tokens",
|
||||
method: "POST",
|
||||
path: "/v1/messages/count_tokens",
|
||||
label: "Count Tokens",
|
||||
description: "Estimate token count for Anthropic-style messages (local estimate).",
|
||||
icon: "tag",
|
||||
category: "Chat",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "llm",
|
||||
fields: [
|
||||
{
|
||||
key: "messages",
|
||||
label: "Messages",
|
||||
type: "messages",
|
||||
required: true,
|
||||
default: [{ role: "user", content: "Hello world" }],
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
label: "System",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "input_tokens": 12\n}`,
|
||||
},
|
||||
{
|
||||
id: "embeddings",
|
||||
method: "POST",
|
||||
path: "/v1/embeddings",
|
||||
label: "Embeddings",
|
||||
description: "Vector embeddings for RAG / semantic search.",
|
||||
icon: "scatter_plot",
|
||||
category: "Embeddings",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "embedding",
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello world",
|
||||
description: "String or JSON array of strings",
|
||||
},
|
||||
{
|
||||
key: "encoding_format",
|
||||
label: "Encoding",
|
||||
type: "select",
|
||||
default: "float",
|
||||
options: ["float", "base64"],
|
||||
},
|
||||
{
|
||||
key: "dimensions",
|
||||
label: "Dimensions",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 1,
|
||||
advanced: true,
|
||||
description: "OpenAI text-embedding-3-* only",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "object": "embedding", "index": 0, "embedding": [0.01, -0.02, "..."] }],\n "usage": { "prompt_tokens": 2, "total_tokens": 2 }\n}`,
|
||||
},
|
||||
{
|
||||
id: "images-generations",
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
label: "Image Generation",
|
||||
description: "Text-to-image (DALL·E, Imagen, FLUX, Codex, MiniMax…). Supports binary output.",
|
||||
icon: "brush",
|
||||
category: "Media",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "image",
|
||||
fields: [
|
||||
{
|
||||
key: "prompt",
|
||||
label: "Prompt",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "A cute cat wearing a hat, watercolor style",
|
||||
},
|
||||
{
|
||||
key: "n",
|
||||
label: "n",
|
||||
type: "number",
|
||||
default: 1,
|
||||
min: 1,
|
||||
max: 4,
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
label: "Size",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"],
|
||||
},
|
||||
{
|
||||
key: "aspect_ratio",
|
||||
label: "Aspect Ratio",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "auto", "1:1", "16:9", "9:16", "4:3", "3:2", "2:3", "9:19.5", "20:9"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "resolution",
|
||||
label: "Resolution",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "1k", "2k"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
label: "Quality",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "low", "medium", "high", "standard", "hd"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "background",
|
||||
label: "Background",
|
||||
type: "select",
|
||||
default: "auto",
|
||||
options: ["auto", "transparent", "opaque"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "style",
|
||||
label: "Style",
|
||||
type: "select",
|
||||
default: "",
|
||||
options: ["", "vivid", "natural"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
default: "url",
|
||||
options: ["url", "b64_json", "binary"],
|
||||
description: "binary uses ?response_format=binary (raw image bytes)",
|
||||
},
|
||||
{
|
||||
key: "output_format",
|
||||
label: "Codec",
|
||||
type: "select",
|
||||
default: "png",
|
||||
options: ["png", "jpeg", "webp"],
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
label: "Reference Image",
|
||||
type: "image",
|
||||
default: "",
|
||||
placeholder: "https://... or upload an image",
|
||||
advanced: true,
|
||||
description: "Edit / img2img when provider supports it. Paste URL or upload file (stored as data URL).",
|
||||
accept: "image/*",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "created": 1735000000,\n "data": [{ "url": "https://..." }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "audio-speech",
|
||||
method: "POST",
|
||||
path: "/v1/audio/speech",
|
||||
label: "Text to Speech",
|
||||
description: "OpenAI / ElevenLabs / Edge / Google / Deepgram voices → audio bytes.",
|
||||
icon: "record_voice_over",
|
||||
category: "Media",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "tts",
|
||||
fields: [
|
||||
{
|
||||
key: "input",
|
||||
label: "Text",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "Hello from 9Router!",
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
location: "query",
|
||||
default: "mp3",
|
||||
options: ["mp3", "json"],
|
||||
description: "mp3 = raw audio; json = { audio: base64, format }",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language Hint",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "en, vi, ...",
|
||||
advanced: true,
|
||||
description: "Optional language hint (e.g. Gemini)",
|
||||
},
|
||||
{
|
||||
key: "voice",
|
||||
label: "Voice",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "alloy (provider-dependent)",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "speed",
|
||||
label: "Speed",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
step: 0.05,
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: "(binary audio/mp3)",
|
||||
},
|
||||
{
|
||||
id: "audio-voices",
|
||||
method: "GET",
|
||||
path: "/v1/audio/voices",
|
||||
label: "List Voices",
|
||||
description: "List TTS voices for elevenlabs, edge-tts, deepgram, inworld, local-device.",
|
||||
icon: "voice_selection",
|
||||
category: "Media",
|
||||
contentType: "none",
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
required: true,
|
||||
location: "query",
|
||||
default: "edge-tts",
|
||||
options: ["edge-tts", "elevenlabs", "deepgram", "inworld", "local-device"],
|
||||
},
|
||||
{
|
||||
key: "lang",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
location: "query",
|
||||
default: "",
|
||||
placeholder: "vi, en, ...",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "object": "list",\n "data": [{ "id": "...", "model": "edge-tts/vi-VN-HoaiMyNeural" }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "audio-transcriptions",
|
||||
method: "POST",
|
||||
path: "/v1/audio/transcriptions",
|
||||
label: "Speech to Text",
|
||||
description: "Whisper-compatible multipart transcription.",
|
||||
icon: "mic",
|
||||
category: "Media",
|
||||
contentType: "multipart",
|
||||
needsModel: true,
|
||||
modelsKind: "stt",
|
||||
fields: [
|
||||
{
|
||||
key: "file",
|
||||
label: "Audio File",
|
||||
type: "file",
|
||||
required: true,
|
||||
accept: "audio/*,.mp3,.wav,.m4a,.webm,.ogg,.flac",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "en, vi, ...",
|
||||
},
|
||||
{
|
||||
key: "prompt",
|
||||
label: "Prompt",
|
||||
type: "textarea",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "response_format",
|
||||
label: "Response Format",
|
||||
type: "select",
|
||||
default: "json",
|
||||
options: ["json", "text", "verbose_json", "srt", "vtt"],
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
default: "",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "text": "..."\n}`,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
method: "POST",
|
||||
path: "/v1/search",
|
||||
label: "Web Search",
|
||||
description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com…",
|
||||
icon: "search",
|
||||
category: "Web",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "web",
|
||||
fields: [
|
||||
{
|
||||
key: "query",
|
||||
label: "Query",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
default: "What is the latest news about AI?",
|
||||
},
|
||||
{
|
||||
key: "max_results",
|
||||
label: "Max Results",
|
||||
type: "number",
|
||||
default: 5,
|
||||
min: 1,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
key: "search_type",
|
||||
label: "Search Type",
|
||||
type: "select",
|
||||
default: "web",
|
||||
options: ["web", "news"],
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Country",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
default: "",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "time_range",
|
||||
label: "Time Range",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "day, week, month, year",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
key: "domain_filter",
|
||||
label: "Domain Filter",
|
||||
type: "string",
|
||||
default: "",
|
||||
placeholder: "example.com",
|
||||
advanced: true,
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "provider": "tavily",\n "query": "...",\n "results": [{ "title": "...", "url": "...", "snippet": "..." }]\n}`,
|
||||
},
|
||||
{
|
||||
id: "web-fetch",
|
||||
method: "POST",
|
||||
path: "/v1/web/fetch",
|
||||
label: "Web Fetch",
|
||||
description: "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.",
|
||||
icon: "language",
|
||||
category: "Web",
|
||||
contentType: "json",
|
||||
needsModel: true,
|
||||
modelsKind: "web",
|
||||
fields: [
|
||||
{
|
||||
key: "url",
|
||||
label: "URL",
|
||||
type: "string",
|
||||
required: true,
|
||||
default: "https://9router.com",
|
||||
placeholder: "https://example.com",
|
||||
},
|
||||
{
|
||||
key: "format",
|
||||
label: "Format",
|
||||
type: "select",
|
||||
default: "markdown",
|
||||
options: ["markdown", "text", "html"],
|
||||
},
|
||||
{
|
||||
key: "max_characters",
|
||||
label: "Max Characters",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
advanced: true,
|
||||
description: "0 = no truncate",
|
||||
},
|
||||
],
|
||||
defaultResponse: `{\n "provider": "jina-reader",\n "url": "...",\n "title": "...",\n "content": { "format": "markdown", "text": "..." }\n}`,
|
||||
},
|
||||
];
|
||||
|
||||
export const API_CATEGORIES = [...new Set(API_CATALOG.map((e) => e.category))];
|
||||
|
||||
export function getApiEndpointById(id) {
|
||||
return API_CATALOG.find((e) => e.id === id) || null;
|
||||
}
|
||||
|
||||
export function getApiEndpointsByCategory(category) {
|
||||
return API_CATALOG.filter((e) => e.category === category);
|
||||
}
|
||||
@@ -89,16 +89,13 @@ export async function handleEmbeddings(request) {
|
||||
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
|
||||
}
|
||||
|
||||
// Optional pin to a specific connection (dashboard test / client override)
|
||||
const preferredConnectionId = request?.headers?.get("x-connection-id") || null;
|
||||
|
||||
// Credential + fallback loop (mirrors handleChat)
|
||||
const excludeConnectionIds = new Set();
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId });
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
||||
|
||||
// All accounts unavailable
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
@@ -156,10 +153,6 @@ export async function handleEmbeddings(request) {
|
||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||
|
||||
if (shouldFallback) {
|
||||
if (preferredConnectionId) {
|
||||
log.warn("AUTH", `Pinned account ${credentials.connectionName} unavailable (${result.status}), no fallback`);
|
||||
return result.response;
|
||||
}
|
||||
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||
excludeConnectionIds.add(credentials.connectionId);
|
||||
lastError = result.error;
|
||||
|
||||
@@ -128,21 +128,15 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
|
||||
|
||||
let connection;
|
||||
// Strict pin: only use the requested connection (no strategy fallback).
|
||||
// Allows model-locked accounts so explicit tests can still hit that account.
|
||||
// Pin to preferred connection if specified and available
|
||||
if (preferredConnectionId) {
|
||||
connection = connections.find((c) => c.id === preferredConnectionId);
|
||||
if (!connection || excludeSet.has(connection.id)) {
|
||||
log.warn(
|
||||
"AUTH",
|
||||
`${provider} | preferred connection ${preferredConnectionId.slice(0, 8)} not found/active or excluded`,
|
||||
);
|
||||
return null;
|
||||
connection = availableConnections.find((c) => c.id === preferredConnectionId);
|
||||
if (connection) {
|
||||
log.info("AUTH", `${provider} | pinned to ${connection.id?.slice(0, 8)} (${connection.name || connection.email || "unnamed"})`);
|
||||
}
|
||||
log.info(
|
||||
"AUTH",
|
||||
`${provider} | pinned to ${connection.id?.slice(0, 8)} (${connection.name || connection.email || "unnamed"})`,
|
||||
);
|
||||
}
|
||||
if (connection) {
|
||||
// skip strategy
|
||||
} else if (strategy === "round-robin") {
|
||||
const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;
|
||||
|
||||
|
||||
@@ -1,103 +1,81 @@
|
||||
// Re-export from open-sse with localDb integration
|
||||
import {
|
||||
getModelAliases,
|
||||
getComboByName,
|
||||
getProviderNodes,
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
parseModel as parseModelCore,
|
||||
resolveModelAliasFromMap,
|
||||
getModelInfoCore,
|
||||
} from "open-sse/services/model.js";
|
||||
import { getModelAliases, getComboByName, getProviderNodes } from "@/lib/localDb";
|
||||
import { parseModel as parseModelCore, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js";
|
||||
import REGISTRY from "open-sse/providers/registry/index.js";
|
||||
|
||||
// Local provider alias overrides (HMR-friendly, applied on top of open-sse map)
|
||||
const LOCAL_PROVIDER_ALIASES = {
|
||||
xmtp: "xiaomi-tokenplan",
|
||||
"xiaomi-tokenplan": "xiaomi-tokenplan",
|
||||
xmtp: "xiaomi-tokenplan",
|
||||
"xiaomi-tokenplan": "xiaomi-tokenplan",
|
||||
};
|
||||
|
||||
const RESERVED_PROVIDER_PREFIXES = new Set(Object.keys(LOCAL_PROVIDER_ALIASES));
|
||||
for (const entry of REGISTRY) {
|
||||
RESERVED_PROVIDER_PREFIXES.add(entry.id);
|
||||
if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias);
|
||||
for (const alias of entry.aliases || [])
|
||||
RESERVED_PROVIDER_PREFIXES.add(alias);
|
||||
RESERVED_PROVIDER_PREFIXES.add(entry.id);
|
||||
if (entry.alias) RESERVED_PROVIDER_PREFIXES.add(entry.alias);
|
||||
for (const alias of entry.aliases || []) RESERVED_PROVIDER_PREFIXES.add(alias);
|
||||
}
|
||||
|
||||
export function parseModel(modelStr) {
|
||||
const parsed = parseModelCore(modelStr);
|
||||
if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) {
|
||||
return {
|
||||
...parsed,
|
||||
provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias],
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
const parsed = parseModelCore(modelStr);
|
||||
if (parsed?.providerAlias && LOCAL_PROVIDER_ALIASES[parsed.providerAlias]) {
|
||||
return { ...parsed, provider: LOCAL_PROVIDER_ALIASES[parsed.providerAlias] };
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve model alias from localDb
|
||||
*/
|
||||
export async function resolveModelAlias(alias) {
|
||||
const aliases = await getModelAliases();
|
||||
return resolveModelAliasFromMap(alias, aliases);
|
||||
const aliases = await getModelAliases();
|
||||
return resolveModelAliasFromMap(alias, aliases);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full model info (parse or resolve)
|
||||
*/
|
||||
export async function getModelInfo(modelStr) {
|
||||
const parsed = parseModel(modelStr);
|
||||
const parsed = parseModel(modelStr);
|
||||
|
||||
if (!parsed.isAlias) {
|
||||
// Provider-node prefixes are user-defined. They must not override built-in
|
||||
// provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`.
|
||||
if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) {
|
||||
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
|
||||
const matchedOpenAI = openaiNodes.find(
|
||||
(node) => node.prefix === parsed.providerAlias,
|
||||
);
|
||||
if (matchedOpenAI) {
|
||||
return { provider: matchedOpenAI.id, model: parsed.model };
|
||||
}
|
||||
if (!parsed.isAlias) {
|
||||
// Provider-node prefixes are user-defined. They must not override built-in
|
||||
// provider ids/aliases such as `cf`, `cloudflare-ai`, `openai`, or `hf`.
|
||||
if (!RESERVED_PROVIDER_PREFIXES.has(parsed.providerAlias)) {
|
||||
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
|
||||
const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||
if (matchedOpenAI) {
|
||||
return { provider: matchedOpenAI.id, model: parsed.model };
|
||||
}
|
||||
|
||||
const anthropicNodes = await getProviderNodes({
|
||||
type: "anthropic-compatible",
|
||||
});
|
||||
const matchedAnthropic = anthropicNodes.find(
|
||||
(node) => node.prefix === parsed.providerAlias,
|
||||
);
|
||||
if (matchedAnthropic) {
|
||||
return { provider: matchedAnthropic.id, model: parsed.model };
|
||||
}
|
||||
const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" });
|
||||
const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||
if (matchedAnthropic) {
|
||||
return { provider: matchedAnthropic.id, model: parsed.model };
|
||||
}
|
||||
|
||||
const embeddingNodes = await getProviderNodes({
|
||||
type: "custom-embedding",
|
||||
});
|
||||
const matchedEmbedding = embeddingNodes.find(
|
||||
(node) => node.prefix === parsed.providerAlias,
|
||||
);
|
||||
if (matchedEmbedding) {
|
||||
return { provider: matchedEmbedding.id, model: parsed.model };
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
};
|
||||
}
|
||||
const embeddingNodes = await getProviderNodes({ type: "custom-embedding" });
|
||||
const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||
if (matchedEmbedding) {
|
||||
return { provider: matchedEmbedding.id, model: parsed.model };
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this is a combo name before resolving as alias
|
||||
// This prevents combo names from being incorrectly routed to providers
|
||||
const combo = await getComboByName(parsed.model);
|
||||
if (combo && combo.enabled !== false) {
|
||||
// Return null provider to signal this should be handled as combo
|
||||
// The caller (handleChat) will detect this and handle it as combo
|
||||
return { provider: null, model: parsed.model };
|
||||
}
|
||||
// Check if this is a combo name before resolving as alias
|
||||
// This prevents combo names from being incorrectly routed to providers
|
||||
const combo = await getComboByName(parsed.model);
|
||||
if (combo) {
|
||||
// Return null provider to signal this should be handled as combo
|
||||
// The caller (handleChat) will detect this and handle it as combo
|
||||
return { provider: null, model: parsed.model };
|
||||
}
|
||||
|
||||
return getModelInfoCore(modelStr, getModelAliases);
|
||||
return getModelInfoCore(modelStr, getModelAliases);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,17 +83,12 @@ export async function getModelInfo(modelStr) {
|
||||
* @returns {Promise<string[]|null>} Array of models or null if not a combo
|
||||
*/
|
||||
export async function getComboModels(modelStr) {
|
||||
// Only check if it's not in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
// Only check if it's not in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
|
||||
const combo = await getComboByName(modelStr);
|
||||
if (
|
||||
combo &&
|
||||
combo.enabled !== false &&
|
||||
combo.models &&
|
||||
combo.models.length > 0
|
||||
) {
|
||||
return combo.models;
|
||||
}
|
||||
return null;
|
||||
const combo = await getComboByName(modelStr);
|
||||
if (combo && combo.models && combo.models.length > 0) {
|
||||
return combo.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ const LOG_LEVELS = {
|
||||
ERROR: 3
|
||||
};
|
||||
|
||||
const LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase?.()] ?? LOG_LEVELS.INFO;
|
||||
|
||||
// Runtime log level. Defaults from LOG_LEVEL env, but can be changed at runtime
|
||||
// via setLogLevel (dashboard Settings → Logging). WARN/ERROR hide the noisy
|
||||
// INFO request lines (▶ POST / 📊 DONE / [COMBO] / [CHAT] ...) in production.
|
||||
let LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase?.()] ?? LOG_LEVELS.INFO;
|
||||
|
||||
export function setLogLevel(level) {
|
||||
const normalized = String(level || "").toUpperCase();
|
||||
if (Object.prototype.hasOwnProperty.call(LOG_LEVELS, normalized)) {
|
||||
@@ -49,7 +49,6 @@ export function tagForSession(seed) {
|
||||
}
|
||||
|
||||
// Print one correlated line: [time] tag symbol message
|
||||
// Visible at INFO and below (hidden by WARN/ERROR log levels in production).
|
||||
export function line(tag, symbol, message) {
|
||||
if (LEVEL > LOG_LEVELS.INFO) return;
|
||||
console.log(`[${formatTime()}] ${tag} ${symbol} ${message}`);
|
||||
@@ -118,14 +117,12 @@ export function request(method, path, extra) {
|
||||
}
|
||||
|
||||
export function response(status, duration, extra) {
|
||||
if (LEVEL > LOG_LEVELS.INFO) return;
|
||||
const icon = status < 400 ? "📤" : "💥";
|
||||
const dataStr = extra ? ` ${formatData(extra)}` : "";
|
||||
console.log(`[${formatTime()}] ${icon} ${status} (${duration}ms)${dataStr}`);
|
||||
}
|
||||
|
||||
export function stream(event, data) {
|
||||
if (LEVEL > LOG_LEVELS.INFO) return;
|
||||
const dataStr = data ? ` ${formatData(data)}` : "";
|
||||
console.log(`[${formatTime()}] 🌊 [STREAM] ${event}${dataStr}`);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test vision support for the commandcode provider in 9router.
|
||||
#
|
||||
# Usage:
|
||||
# ./test-commandcode-vision.sh [base_url] [model]
|
||||
# BASE_URL=http://localhost:20128 ./test-commandcode-vision.sh
|
||||
#
|
||||
# Defaults: base_url=http://localhost:20128, model=commandcode/Qwen/Qwen3.6-Max-Preview
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-${BASE_URL:-http://localhost:20128}}"
|
||||
MODEL="${2:-${MODEL:-commandcode/Qwen/Qwen3.6-Max-Preview}}"
|
||||
|
||||
# 1x1 red pixel PNG (base64) — enough to prove the image reaches the model.
|
||||
PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
|
||||
echo "==> POST $BASE_URL/v1/chat/completions"
|
||||
echo "==> model: $MODEL"
|
||||
echo
|
||||
|
||||
curl -sS -N "$BASE_URL/v1/chat/completions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(
|
||||
cat <<JSON
|
||||
{
|
||||
"model": "$MODEL",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What color is this image? Reply with just the color name."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,$PNG_B64"}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stream": false
|
||||
}
|
||||
JSON
|
||||
)"
|
||||
echo
|
||||
@@ -4,133 +4,73 @@ import "./registerAll.js";
|
||||
import { translateRequest } from "../../open-sse/translator/index.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
const O2G = (body) =>
|
||||
translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.GEMINI,
|
||||
"m",
|
||||
body,
|
||||
true,
|
||||
null,
|
||||
"gemini",
|
||||
);
|
||||
const O2C = (body) =>
|
||||
translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CURSOR,
|
||||
"m",
|
||||
body,
|
||||
true,
|
||||
null,
|
||||
"cursor",
|
||||
);
|
||||
const O2CC = (body) =>
|
||||
translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.COMMANDCODE,
|
||||
"m",
|
||||
body,
|
||||
true,
|
||||
null,
|
||||
"commandcode",
|
||||
);
|
||||
const O2G = (body) => translateRequest(FORMATS.OPENAI, FORMATS.GEMINI, "m", body, true, null, "gemini");
|
||||
const O2C = (body) => translateRequest(FORMATS.OPENAI, FORMATS.CURSOR, "m", body, true, null, "cursor");
|
||||
const O2CC = (body) => translateRequest(FORMATS.OPENAI, FORMATS.COMMANDCODE, "m", body, true, null, "commandcode");
|
||||
|
||||
describe("OpenAI → Gemini", () => {
|
||||
// openai-to-gemini.js:92-96 — each system message overwrites systemInstruction → only last kept
|
||||
// KNOWN BUG
|
||||
it.fails("multiple system messages are all kept", () => {
|
||||
const out = O2G({
|
||||
messages: [
|
||||
{ role: "system", content: "RULE_ONE" },
|
||||
{ role: "system", content: "RULE_TWO" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
JSON.stringify(out.systemInstruction),
|
||||
"earlier system lost",
|
||||
).toContain("RULE_ONE");
|
||||
});
|
||||
// openai-to-gemini.js:92-96 — each system message overwrites systemInstruction → only last kept
|
||||
// KNOWN BUG
|
||||
it.fails("multiple system messages are all kept", () => {
|
||||
const out = O2G({
|
||||
messages: [
|
||||
{ role: "system", content: "RULE_ONE" },
|
||||
{ role: "system", content: "RULE_TWO" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(out.systemInstruction), "earlier system lost").toContain("RULE_ONE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI → Cursor", () => {
|
||||
// openai-to-cursor.js:12-24 — image content fully dropped (text only)
|
||||
// KNOWN BUG
|
||||
it.fails("image content is preserved", () => {
|
||||
const out = O2C({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "look" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: "data:image/png;base64,AAAA" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(out), "image dropped").toContain("AAAA");
|
||||
});
|
||||
// openai-to-cursor.js:12-24 — image content fully dropped (text only)
|
||||
// KNOWN BUG
|
||||
it.fails("image content is preserved", () => {
|
||||
const out = O2C({
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
|
||||
] }],
|
||||
});
|
||||
expect(JSON.stringify(out), "image dropped").toContain("AAAA");
|
||||
});
|
||||
|
||||
// openai-to-cursor.js:179 — max_tokens hardcoded to 32000
|
||||
// KNOWN BUG
|
||||
it.fails("respects client max_tokens", () => {
|
||||
const out = O2C({
|
||||
max_tokens: 200,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(out.max_tokens).toBe(200);
|
||||
});
|
||||
// openai-to-cursor.js:179 — max_tokens hardcoded to 32000
|
||||
// KNOWN BUG
|
||||
it.fails("respects client max_tokens", () => {
|
||||
const out = O2C({ max_tokens: 200, messages: [{ role: "user", content: "hi" }] });
|
||||
expect(out.max_tokens).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI → CommandCode", () => {
|
||||
// openai-to-commandcode.js:53-57 — safeParseJson returns {} on bad JSON (args silently lost)
|
||||
// KNOWN BUG
|
||||
it.fails("malformed tool arguments are not silently emptied", () => {
|
||||
const out = O2CC({
|
||||
messages: [
|
||||
{ role: "user", content: "go" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: { name: "f", arguments: "{bad" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "c1", content: "r" },
|
||||
],
|
||||
});
|
||||
const asst = out.params.messages.find((m) => m.role === "assistant");
|
||||
const call = asst.content.find((b) => b.type === "tool-call");
|
||||
expect(
|
||||
Object.keys(call.input).length,
|
||||
"arguments silently dropped to {}",
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
// openai-to-commandcode.js:53-57 — safeParseJson returns {} on bad JSON (args silently lost)
|
||||
// KNOWN BUG
|
||||
it.fails("malformed tool arguments are not silently emptied", () => {
|
||||
const out = O2CC({
|
||||
messages: [
|
||||
{ role: "user", content: "go" },
|
||||
{ role: "assistant", content: "", tool_calls: [
|
||||
{ id: "c1", type: "function", function: { name: "f", arguments: "{bad" } },
|
||||
] },
|
||||
{ role: "tool", tool_call_id: "c1", content: "r" },
|
||||
],
|
||||
});
|
||||
const asst = out.params.messages.find((m) => m.role === "assistant");
|
||||
const call = asst.content.find((b) => b.type === "tool-call");
|
||||
expect(Object.keys(call.input).length, "arguments silently dropped to {}").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// openai-to-commandcode.js — image blocks now map to {type:"image", image:"data:..."}
|
||||
// FIXED: was "[image omitted]", now preserved as data URI
|
||||
it("image content is preserved", () => {
|
||||
const out = O2CC({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "look" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: "data:image/png;base64,BBBB" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(out), "image omitted").toContain("BBBB");
|
||||
});
|
||||
// openai-to-commandcode.js:41-42 — image becomes "[image omitted]"
|
||||
// KNOWN BUG
|
||||
it.fails("image content is preserved", () => {
|
||||
const out = O2CC({
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,BBBB" } },
|
||||
] }],
|
||||
});
|
||||
expect(JSON.stringify(out), "image omitted").toContain("BBBB");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Unit tests for the CommandCode executor early-error peek.
|
||||
*
|
||||
* The upstream emits AI SDK v5 NDJSON over an HTTP 200 stream, so a terminal
|
||||
* `{"type":"error"}` event is invisible to the normal `response.ok` success
|
||||
* check. `peekForUpstreamError` reads the first events before committing the
|
||||
* response: an error event → non-ok Response (fallback can kick in); otherwise
|
||||
* the buffered bytes are re-emitted and streaming proceeds as before.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { peekForUpstreamError } from "../../open-sse/executors/commandcode.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function ndjsonResponse(lines) {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const line of lines) controller.enqueue(encoder.encode(line + "\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("commandcode executor — early-error peek", () => {
|
||||
it("returns 502 when the first meaningful event is an error", async () => {
|
||||
const res = await peekForUpstreamError(
|
||||
ndjsonResponse([
|
||||
'{"type":"error","error":{"type":"server_error","message":"Network connection lost."}}',
|
||||
]),
|
||||
"model",
|
||||
);
|
||||
expect(res.status).toBe(502);
|
||||
const body = await res.json();
|
||||
expect(body.error.message).toBe("Network connection lost.");
|
||||
expect(body.error.type).toBe("server_error");
|
||||
});
|
||||
|
||||
it("detects an error event even when metadata events arrive first", async () => {
|
||||
const res = await peekForUpstreamError(
|
||||
ndjsonResponse([
|
||||
'{"type":"start"}',
|
||||
'{"type":"start-step"}',
|
||||
'{"type":"error","error":{"type":"server_error","message":"Network connection lost."}}',
|
||||
]),
|
||||
"model",
|
||||
);
|
||||
expect(res.status).toBe(502);
|
||||
const body = await res.json();
|
||||
expect(body.error.message).toContain("Network connection lost");
|
||||
});
|
||||
|
||||
it("commits and streams normally when the first meaningful event is content", async () => {
|
||||
const res = await peekForUpstreamError(
|
||||
ndjsonResponse([
|
||||
'{"type":"start"}',
|
||||
'{"type":"text-delta","text":"hi there"}',
|
||||
'{"type":"finish"}',
|
||||
]),
|
||||
"model",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('"content":"hi there"');
|
||||
expect(text).not.toContain("[CommandCode error:");
|
||||
});
|
||||
|
||||
it("commits when the stream ends without any event", async () => {
|
||||
const res = await peekForUpstreamError(ndjsonResponse([]), "model");
|
||||
expect(res.status).toBe(200);
|
||||
await res.body.cancel();
|
||||
});
|
||||
|
||||
it("commits (does not hang) when no event arrives before the peek timeout", async () => {
|
||||
const stalled = new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const res = await peekForUpstreamError(stalled, "model", { timeoutMs: 50 });
|
||||
expect(res.status).toBe(200);
|
||||
await res.body.cancel();
|
||||
});
|
||||
|
||||
it("does not hang when the request signal aborts during the peek", async () => {
|
||||
const controller = new AbortController();
|
||||
const stalled = new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
setTimeout(() => controller.abort(new Error("client gone")), 10);
|
||||
const res = await peekForUpstreamError(stalled, "model", {
|
||||
signal: controller.signal,
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body.cancel();
|
||||
});
|
||||
|
||||
it("re-emits raw bytes so a multi-byte char split across the peek boundary survives", async () => {
|
||||
// chunk1 ends mid-é (0xC3); the peek commits on the first complete line
|
||||
// (text-delta "hi") while the decoder still holds 0xC3. Re-emission must
|
||||
// use RAW bytes — re-encoding decoded text would replace 0xC3 with U+FFFD.
|
||||
const first = Buffer.from(
|
||||
'{"type":"text-delta","text":"hi"}\n{"type":"text-delta","text":"caf',
|
||||
);
|
||||
const chunk1 = new Uint8Array([...first, 0xc3]);
|
||||
const rest = new Uint8Array([0xa9, 0x22, 0x7d, 0x0a]); // é"}\n
|
||||
const body = new ReadableStream({
|
||||
start(c) {
|
||||
c.enqueue(chunk1);
|
||||
c.enqueue(rest);
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
const res = await peekForUpstreamError(
|
||||
new Response(body, { status: 200 }),
|
||||
"m",
|
||||
);
|
||||
const text = await res.text();
|
||||
expect(text).toContain("café");
|
||||
expect(text).not.toContain("\uFFFD");
|
||||
});
|
||||
});
|
||||
@@ -12,152 +12,116 @@ import { describe, it, expect } from "vitest";
|
||||
import { commandCodeToOpenAIResponse } from "../../open-sse/translator/response/commandcode-to-openai.js";
|
||||
|
||||
function feed(events) {
|
||||
const state = {};
|
||||
const all = [];
|
||||
for (const e of events) {
|
||||
const out = commandCodeToOpenAIResponse(JSON.stringify(e), state);
|
||||
if (out) for (const c of out) all.push(c);
|
||||
}
|
||||
return { state, chunks: all };
|
||||
const state = {};
|
||||
const all = [];
|
||||
for (const e of events) {
|
||||
const out = commandCodeToOpenAIResponse(JSON.stringify(e), state);
|
||||
if (out) for (const c of out) all.push(c);
|
||||
}
|
||||
return { state, chunks: all };
|
||||
}
|
||||
|
||||
describe("commandcode-to-openai — text-delta", () => {
|
||||
it("emits assistant role on first delta then content-only", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: " world" },
|
||||
]);
|
||||
expect(chunks[0].choices[0].delta.role).toBe("assistant");
|
||||
expect(chunks[0].choices[0].delta.content).toBe("Hello");
|
||||
expect(chunks[1].choices[0].delta.role).toBeUndefined();
|
||||
expect(chunks[1].choices[0].delta.content).toBe(" world");
|
||||
});
|
||||
it("emits assistant role on first delta then content-only", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: " world" },
|
||||
]);
|
||||
expect(chunks[0].choices[0].delta.role).toBe("assistant");
|
||||
expect(chunks[0].choices[0].delta.content).toBe("Hello");
|
||||
expect(chunks[1].choices[0].delta.role).toBeUndefined();
|
||||
expect(chunks[1].choices[0].delta.content).toBe(" world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("commandcode-to-openai — reasoning-delta", () => {
|
||||
it("maps reasoning-delta to reasoning_content delta", () => {
|
||||
const { chunks } = feed([{ type: "reasoning-delta", text: "thinking..." }]);
|
||||
expect(chunks[0].choices[0].delta.reasoning_content).toBe("thinking...");
|
||||
});
|
||||
it("maps reasoning-delta to reasoning_content delta", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "reasoning-delta", text: "thinking..." },
|
||||
]);
|
||||
expect(chunks[0].choices[0].delta.reasoning_content).toBe("thinking...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("commandcode-to-openai — tool-input-* with id field (live schema)", () => {
|
||||
it("registers tool index using event.id (NOT toolCallId)", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-input-start", id: "call_X", toolName: "Bash" },
|
||||
{ type: "tool-input-delta", id: "call_X", delta: '{"cmd' },
|
||||
{ type: "tool-input-delta", id: "call_X", delta: '":"ls"}' },
|
||||
]);
|
||||
it("registers tool index using event.id (NOT toolCallId)", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-input-start", id: "call_X", toolName: "Bash" },
|
||||
{ type: "tool-input-delta", id: "call_X", delta: "{\"cmd" },
|
||||
{ type: "tool-input-delta", id: "call_X", delta: "\":\"ls\"}" },
|
||||
]);
|
||||
|
||||
// First chunk emits tool_calls with id
|
||||
const startChunk = chunks[0].choices[0].delta.tool_calls[0];
|
||||
expect(startChunk.id).toBe("call_X");
|
||||
expect(startChunk.function.name).toBe("Bash");
|
||||
// First chunk emits tool_calls with id
|
||||
const startChunk = chunks[0].choices[0].delta.tool_calls[0];
|
||||
expect(startChunk.id).toBe("call_X");
|
||||
expect(startChunk.function.name).toBe("Bash");
|
||||
|
||||
// Subsequent deltas accumulate arguments
|
||||
expect(chunks[1].choices[0].delta.tool_calls[0].function.arguments).toBe(
|
||||
'{"cmd',
|
||||
);
|
||||
expect(chunks[2].choices[0].delta.tool_calls[0].function.arguments).toBe(
|
||||
'":"ls"}',
|
||||
);
|
||||
});
|
||||
// Subsequent deltas accumulate arguments
|
||||
expect(chunks[1].choices[0].delta.tool_calls[0].function.arguments).toBe("{\"cmd");
|
||||
expect(chunks[2].choices[0].delta.tool_calls[0].function.arguments).toBe("\":\"ls\"}");
|
||||
});
|
||||
|
||||
it("ignores tool-input-delta when id is unknown (no prior start)", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-input-delta", id: "unknown", delta: "x" },
|
||||
]);
|
||||
expect(chunks.length).toBe(0);
|
||||
});
|
||||
it("ignores tool-input-delta when id is unknown (no prior start)", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-input-delta", id: "unknown", delta: "x" },
|
||||
]);
|
||||
expect(chunks.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commandcode-to-openai — final tool-call event", () => {
|
||||
it("does NOT re-emit tool_calls when tool-input-* deltas already fired", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-input-start", id: "call_Y", toolName: "Write" },
|
||||
{ type: "tool-input-delta", id: "call_Y", delta: '{"file":"a"}' },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_Y",
|
||||
toolName: "Write",
|
||||
input: { file: "a" },
|
||||
},
|
||||
]);
|
||||
// Should be exactly 2 chunks (start + delta), no duplicate from final tool-call
|
||||
expect(chunks.length).toBe(2);
|
||||
});
|
||||
it("does NOT re-emit tool_calls when tool-input-* deltas already fired", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-input-start", id: "call_Y", toolName: "Write" },
|
||||
{ type: "tool-input-delta", id: "call_Y", delta: "{\"file\":\"a\"}" },
|
||||
{ type: "tool-call", toolCallId: "call_Y", toolName: "Write", input: { file: "a" } },
|
||||
]);
|
||||
// Should be exactly 2 chunks (start + delta), no duplicate from final tool-call
|
||||
expect(chunks.length).toBe(2);
|
||||
});
|
||||
|
||||
it("emits a consolidated tool_calls when only the final tool-call event arrives", () => {
|
||||
const { chunks } = feed([
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_Z",
|
||||
toolName: "Read",
|
||||
input: { path: "/x" },
|
||||
},
|
||||
]);
|
||||
expect(chunks.length).toBe(1);
|
||||
const tc = chunks[0].choices[0].delta.tool_calls[0];
|
||||
expect(tc.id).toBe("call_Z");
|
||||
expect(tc.function.name).toBe("Read");
|
||||
expect(tc.function.arguments).toBe(JSON.stringify({ path: "/x" }));
|
||||
});
|
||||
it("emits a consolidated tool_calls when only the final tool-call event arrives", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-call", toolCallId: "call_Z", toolName: "Read", input: { path: "/x" } },
|
||||
]);
|
||||
expect(chunks.length).toBe(1);
|
||||
const tc = chunks[0].choices[0].delta.tool_calls[0];
|
||||
expect(tc.id).toBe("call_Z");
|
||||
expect(tc.function.name).toBe("Read");
|
||||
expect(tc.function.arguments).toBe(JSON.stringify({ path: "/x" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("commandcode-to-openai — finish", () => {
|
||||
it("emits a final chunk with finish_reason=tool_calls when finishReason is tool-calls", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-input-start", id: "call_F", toolName: "Bash" },
|
||||
{ type: "tool-input-delta", id: "call_F", delta: "{}" },
|
||||
{ type: "finish-step", finishReason: "tool-calls" },
|
||||
{ type: "finish" },
|
||||
]);
|
||||
const last = chunks[chunks.length - 1];
|
||||
expect(last.choices[0].finish_reason).toBe("tool_calls");
|
||||
});
|
||||
it("emits a final chunk with finish_reason=tool_calls when finishReason is tool-calls", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "tool-input-start", id: "call_F", toolName: "Bash" },
|
||||
{ type: "tool-input-delta", id: "call_F", delta: "{}" },
|
||||
{ type: "finish-step", finishReason: "tool-calls" },
|
||||
{ type: "finish" },
|
||||
]);
|
||||
const last = chunks[chunks.length - 1];
|
||||
expect(last.choices[0].finish_reason).toBe("tool_calls");
|
||||
});
|
||||
|
||||
it("includes usage on the final chunk when totalUsage provided", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "text-delta", text: "hi" },
|
||||
{
|
||||
type: "finish-step",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
totalUsage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||
},
|
||||
]);
|
||||
const last = chunks[chunks.length - 1];
|
||||
expect(last.usage).toEqual({
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
});
|
||||
});
|
||||
it("includes usage on the final chunk when totalUsage provided", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "text-delta", text: "hi" },
|
||||
{ type: "finish-step", finishReason: "stop", usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
|
||||
{ type: "finish", totalUsage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
|
||||
]);
|
||||
const last = chunks[chunks.length - 1];
|
||||
expect(last.usage).toEqual({ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("commandcode-to-openai — error event", () => {
|
||||
it("emits an OpenAI-shaped error chunk instead of fake success content", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "error", error: { type: "server_error", message: "Boom" } },
|
||||
]);
|
||||
expect(chunks[0].error).toEqual({ message: "Boom", type: "server_error" });
|
||||
expect(chunks[0].choices[0].delta.content).toBeUndefined();
|
||||
expect(chunks[1].choices[0].finish_reason).toBe("stop");
|
||||
expect(JSON.stringify(chunks)).not.toContain("[CommandCode error:");
|
||||
});
|
||||
|
||||
it("keeps the stream terminal so clients do not hang waiting for more", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "start" },
|
||||
{
|
||||
type: "error",
|
||||
error: { type: "server_error", message: "Network connection lost." },
|
||||
},
|
||||
]);
|
||||
expect(chunks[0].error.message).toBe("Network connection lost.");
|
||||
expect(chunks[1].choices[0].finish_reason).toBe("stop");
|
||||
});
|
||||
it("stringifies object errors so client sees readable message", () => {
|
||||
const { chunks } = feed([
|
||||
{ type: "error", error: { type: "server_error", message: "Boom" } },
|
||||
]);
|
||||
const text = chunks[0].choices[0].delta.content;
|
||||
expect(text).toContain("Boom");
|
||||
expect(text).not.toContain("[object Object]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import {
|
||||
USAGE_SUPPORTED_PROVIDERS,
|
||||
USAGE_APIKEY_PROVIDERS,
|
||||
} from "../../src/shared/constants/providers.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
const BASE = "https://api.commandcode.ai";
|
||||
const WHOAMI_URL = `${BASE}/alpha/whoami`;
|
||||
const CREDITS_URL = `${BASE}/alpha/billing/credits`;
|
||||
const SUBS_URL = `${BASE}/alpha/billing/subscriptions`;
|
||||
const SUMMARY_URL = `${BASE}/alpha/usage/summary`;
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const WHOAMI = { success: true, user: { id: "u1" }, org: null };
|
||||
const CREDITS = {
|
||||
credits: {
|
||||
belowThreshold: false,
|
||||
creditThreshold: 0,
|
||||
monthlyCredits: 9.9,
|
||||
purchasedCredits: 0,
|
||||
freeCredits: 0,
|
||||
},
|
||||
windowLimits: {
|
||||
limited: true,
|
||||
exceeded: null,
|
||||
fiveHour: { used: 0.05, cap: 3, exceeded: false, resetAt: 1785812386064 },
|
||||
weekly: { used: 0.1, cap: 6, exceeded: false, resetAt: 1786379982640 },
|
||||
},
|
||||
};
|
||||
const SUBS = {
|
||||
success: true,
|
||||
data: {
|
||||
id: "sub_1",
|
||||
status: "active",
|
||||
orgId: null,
|
||||
planId: "individual-go",
|
||||
currentPeriodStart: "2026-08-03T16:38:16.000Z",
|
||||
currentPeriodEnd: "2026-09-03T16:38:16.000Z",
|
||||
},
|
||||
};
|
||||
const SUMMARY = {
|
||||
totalCount: 61,
|
||||
totalCost: 0.1,
|
||||
totalCredits: 0.1,
|
||||
totalMonthlyCredits: 0.1,
|
||||
periodBasis: "billing-period",
|
||||
};
|
||||
|
||||
describe("commandcode registry usage flags", () => {
|
||||
it("is listed for apikey quota dashboard", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("commandcode");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("commandcode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(commandcode)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches whoami → credits+subs → summary and maps windows + credits", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(WHOAMI))
|
||||
.mockResolvedValueOnce(jsonResponse(CREDITS))
|
||||
.mockResolvedValueOnce(jsonResponse(SUBS))
|
||||
.mockResolvedValueOnce(jsonResponse(SUMMARY));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "commandcode",
|
||||
apiKey: "user_cc_test",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("individual-go");
|
||||
expect(usage.periodBasis).toBe("billing-period");
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(4);
|
||||
const [whoamiUrl, whoamiOpts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(whoamiUrl).toBe(WHOAMI_URL);
|
||||
expect(whoamiOpts.headers.Authorization).toBe("Bearer user_cc_test");
|
||||
|
||||
// No org → credits/subscriptions called without orgId query
|
||||
const creditsCall = proxyAwareFetch.mock.calls[1][0];
|
||||
expect(creditsCall).toBe(CREDITS_URL);
|
||||
|
||||
// Summary uses currentPeriodStart as `since`
|
||||
const summaryCall = proxyAwareFetch.mock.calls[3][0];
|
||||
expect(summaryCall).toBe(
|
||||
`${SUMMARY_URL}?since=${encodeURIComponent("2026-08-03T16:38:16.000Z")}`,
|
||||
);
|
||||
|
||||
expect(usage.quotas["5-hour window"]).toMatchObject({
|
||||
used: 0.05,
|
||||
total: 3,
|
||||
resetAt: new Date(1785812386064).toISOString(),
|
||||
});
|
||||
expect(usage.quotas["Weekly window"]).toMatchObject({
|
||||
used: 0.1,
|
||||
total: 6,
|
||||
resetAt: new Date(1786379982640).toISOString(),
|
||||
});
|
||||
expect(usage.quotas["Monthly credits"]).toMatchObject({
|
||||
used: 0.1,
|
||||
total: 9.9,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds orgId query when whoami returns an org", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ success: true, org: { id: "org_1" } }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(CREDITS))
|
||||
.mockResolvedValueOnce(jsonResponse(SUBS))
|
||||
.mockResolvedValueOnce(jsonResponse(SUMMARY));
|
||||
|
||||
await getUsageForProvider({
|
||||
provider: "commandcode",
|
||||
apiKey: "user_cc_test",
|
||||
});
|
||||
|
||||
expect(proxyAwareFetch.mock.calls[1][0]).toBe(`${CREDITS_URL}?orgId=org_1`);
|
||||
expect(proxyAwareFetch.mock.calls[2][0]).toBe(`${SUBS_URL}?orgId=org_1`);
|
||||
});
|
||||
|
||||
it("falls back to first-of-month since when subscription has no period start", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(WHOAMI))
|
||||
.mockResolvedValueOnce(jsonResponse(CREDITS))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ success: true, data: { planId: "individual-go" } }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(SUMMARY));
|
||||
|
||||
await getUsageForProvider({
|
||||
provider: "commandcode",
|
||||
apiKey: "user_cc_test",
|
||||
});
|
||||
|
||||
const since = new URL(proxyAwareFetch.mock.calls[3][0]).searchParams.get(
|
||||
"since",
|
||||
);
|
||||
// firstOfMonth() is local-time based; assert the local date is the 1st.
|
||||
const localDate = new Date(since);
|
||||
expect(localDate.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it("returns message on missing key / 401 / non-ok whoami", async () => {
|
||||
const missing = await getUsageForProvider({ provider: "commandcode" });
|
||||
expect(missing.message).toMatch(/credential/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "no" }, 401));
|
||||
const auth = await getUsageForProvider({
|
||||
provider: "commandcode",
|
||||
apiKey: "bad",
|
||||
});
|
||||
expect(auth.message).toMatch(/invalid|expired/i);
|
||||
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "x" }, 500));
|
||||
const err = await getUsageForProvider({
|
||||
provider: "commandcode",
|
||||
apiKey: "bad",
|
||||
});
|
||||
expect(err.message).toMatch(/whoami/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(commandcode)", () => {
|
||||
it("forwards remainingPercentage + unit for window/credit rows", () => {
|
||||
const rows = parseQuotaData("commandcode", {
|
||||
plan: "individual-go",
|
||||
quotas: {
|
||||
"5-hour window": {
|
||||
used: 0.05,
|
||||
total: 3,
|
||||
remainingPercentage: 98.33,
|
||||
resetAt: "2026-08-03T22:59:46.064Z",
|
||||
unit: "$",
|
||||
},
|
||||
"Monthly credits": {
|
||||
used: 0.1,
|
||||
total: 9.9,
|
||||
remainingPercentage: 98.99,
|
||||
resetAt: null,
|
||||
unit: "$",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: "5-hour window",
|
||||
used: 0.05,
|
||||
total: 3,
|
||||
remainingPercentage: 98.33,
|
||||
unit: "$",
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
name: "Monthly credits",
|
||||
used: 0.1,
|
||||
total: 9.9,
|
||||
remainingPercentage: 98.99,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -543,146 +543,4 @@ describe("handleImageGenerationCore", () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(onRequestSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("generates image with xAI Imagine API", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
created: 1234567890,
|
||||
data: [{ url: "https://example.com/xai-gen.png" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
body: {
|
||||
prompt: "Mountain landscape at sunrise",
|
||||
n: 2,
|
||||
aspect_ratio: "16:9",
|
||||
resolution: "2k",
|
||||
response_format: "url",
|
||||
},
|
||||
modelInfo: { provider: "xai", model: "grok-imagine-image-quality" },
|
||||
credentials: { apiKey: "xai-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.x.ai/v1/images/generations",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer xai-key",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const reqBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(reqBody).toEqual({
|
||||
model: "grok-imagine-image-quality",
|
||||
prompt: "Mountain landscape at sunrise",
|
||||
n: 2,
|
||||
response_format: "url",
|
||||
aspect_ratio: "16:9",
|
||||
resolution: "2k",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps OpenAI size to aspect_ratio for xAI generation", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ created: 1, data: [{ url: "https://example.com/xai.png" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
await handleImageGenerationCore({
|
||||
body: { prompt: "city", size: "1792x1024" },
|
||||
modelInfo: { provider: "xai", model: "grok-imagine-image-quality" },
|
||||
credentials: { accessToken: "oauth-token" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
const reqBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(reqBody.aspect_ratio).toBe("16:9");
|
||||
expect(reqBody.size).toBeUndefined();
|
||||
expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer oauth-token");
|
||||
});
|
||||
|
||||
it("edits a single image via xAI /images/edits", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ created: 1, data: [{ url: "https://example.com/xai-edit.png" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
body: {
|
||||
prompt: "Render this as a pencil sketch",
|
||||
image: "https://docs.x.ai/assets/api-examples/images/style-realistic.png",
|
||||
},
|
||||
modelInfo: { provider: "xai", model: "grok-imagine-image-quality" },
|
||||
credentials: { apiKey: "xai-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.x.ai/v1/images/edits",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
|
||||
const reqBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(reqBody.image).toEqual({
|
||||
type: "image_url",
|
||||
url: "https://docs.x.ai/assets/api-examples/images/style-realistic.png",
|
||||
});
|
||||
expect(reqBody.images).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports multi-image edit for xAI (up to 3 refs)", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ created: 1, data: [{ b64_json: "abc" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
body: {
|
||||
prompt: "Show all subjects sitting together on the grass",
|
||||
images: [
|
||||
"https://docs.x.ai/assets/api-examples/images/image-merge/woman.jpg",
|
||||
{ url: "https://docs.x.ai/assets/api-examples/images/image-merge/man.jpg" },
|
||||
"rawbase64payload",
|
||||
"https://example.com/extra-ignored.jpg",
|
||||
],
|
||||
aspect_ratio: "3:2",
|
||||
response_format: "b64_json",
|
||||
},
|
||||
modelInfo: { provider: "xai", model: "grok-imagine-image-quality" },
|
||||
credentials: { apiKey: "xai-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.x.ai/v1/images/edits",
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
const reqBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(reqBody.images).toEqual([
|
||||
{ type: "image_url", url: "https://docs.x.ai/assets/api-examples/images/image-merge/woman.jpg" },
|
||||
{ type: "image_url", url: "https://docs.x.ai/assets/api-examples/images/image-merge/man.jpg" },
|
||||
{ type: "image_url", url: "data:image/png;base64,rawbase64payload" },
|
||||
]);
|
||||
expect(reqBody.image).toBeUndefined();
|
||||
expect(reqBody.aspect_ratio).toBe("3:2");
|
||||
expect(reqBody.response_format).toBe("b64_json");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,341 +14,168 @@ import { openaiToCommandCodeRequest } from "../../open-sse/translator/request/op
|
||||
const MODEL = "moonshotai/Kimi-K2.6";
|
||||
|
||||
describe("openaiToCommandCodeRequest — basic envelope", () => {
|
||||
it("returns the expected top-level envelope shape", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
true,
|
||||
);
|
||||
it("returns the expected top-level envelope shape", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}, true);
|
||||
|
||||
expect(out).toHaveProperty("threadId");
|
||||
expect(out).toHaveProperty("memory");
|
||||
expect(out).toHaveProperty("config");
|
||||
expect(out).toHaveProperty("params");
|
||||
expect(out.params.model).toBe(MODEL);
|
||||
expect(out.params.stream).toBe(true);
|
||||
});
|
||||
expect(out).toHaveProperty("threadId");
|
||||
expect(out).toHaveProperty("memory");
|
||||
expect(out).toHaveProperty("config");
|
||||
expect(out).toHaveProperty("params");
|
||||
expect(out.params.model).toBe(MODEL);
|
||||
expect(out.params.stream).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCodeRequest — system handling", () => {
|
||||
it("hoists system messages to params.system (string), not messages[]", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
it("hoists system messages to params.system (string), not messages[]", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
}, true);
|
||||
|
||||
expect(typeof out.params.system).toBe("string");
|
||||
expect(out.params.system).toBe("You are concise.");
|
||||
const roles = out.params.messages.map((m) => m.role);
|
||||
expect(roles).not.toContain("system");
|
||||
});
|
||||
expect(typeof out.params.system).toBe("string");
|
||||
expect(out.params.system).toBe("You are concise.");
|
||||
const roles = out.params.messages.map((m) => m.role);
|
||||
expect(roles).not.toContain("system");
|
||||
});
|
||||
|
||||
it("joins multiple system messages with blank line", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{ role: "system", content: "A" },
|
||||
{ role: "system", content: "B" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
it("joins multiple system messages with blank line", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "system", content: "A" },
|
||||
{ role: "system", content: "B" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
}, true);
|
||||
|
||||
expect(out.params.system).toBe("A\n\nB");
|
||||
});
|
||||
expect(out.params.system).toBe("A\n\nB");
|
||||
});
|
||||
|
||||
it("omits params.system when no system messages", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(out.params.system).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCodeRequest — vision / image blocks", () => {
|
||||
const PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
it('maps OpenAI image_url (data URI) → {type:"image", image:"data:..."}', () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What color?" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: `data:image/png;base64,${PNG}` },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const blocks = out.params.messages[0].content;
|
||||
expect(blocks[0]).toEqual({ type: "text", text: "What color?" });
|
||||
expect(blocks[1]).toEqual({
|
||||
type: "image",
|
||||
image: `data:image/png;base64,${PNG}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Claude-style image block (source.base64) → data URI with media_type", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/jpeg",
|
||||
data: "AAAA",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(out.params.messages[0].content[0]).toEqual({
|
||||
type: "image",
|
||||
image: "data:image/jpeg;base64,AAAA",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes raw URL through when image_url is a remote http(s) URL", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: "https://example.com/a.png" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(out.params.messages[0].content[0]).toEqual({
|
||||
type: "image",
|
||||
image: "https://example.com/a.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips image block when no usable image source", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: "" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const blocks = out.params.messages[0].content;
|
||||
expect(blocks.every((b) => b.type !== "image")).toBe(true);
|
||||
});
|
||||
it("omits params.system when no system messages", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}, true);
|
||||
expect(out.params.system).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCodeRequest — content shape", () => {
|
||||
it("MUST always emit content as Array (never string) for user", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
true,
|
||||
);
|
||||
it("MUST always emit content as Array (never string) for user", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}, true);
|
||||
|
||||
const u = out.params.messages[0];
|
||||
expect(Array.isArray(u.content)).toBe(true);
|
||||
expect(u.content[0]).toEqual({ type: "text", text: "hello" });
|
||||
});
|
||||
const u = out.params.messages[0];
|
||||
expect(Array.isArray(u.content)).toBe(true);
|
||||
expect(u.content[0]).toEqual({ type: "text", text: "hello" });
|
||||
});
|
||||
|
||||
it("MUST always emit content as Array for assistant", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "a" },
|
||||
{ role: "assistant", content: "b" },
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
const a = out.params.messages[1];
|
||||
expect(Array.isArray(a.content)).toBe(true);
|
||||
expect(a.content[0]).toEqual({ type: "text", text: "b" });
|
||||
});
|
||||
it("MUST always emit content as Array for assistant", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "user", content: "a" },
|
||||
{ role: "assistant", content: "b" },
|
||||
],
|
||||
}, true);
|
||||
const a = out.params.messages[1];
|
||||
expect(Array.isArray(a.content)).toBe(true);
|
||||
expect(a.content[0]).toEqual({ type: "text", text: "b" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCodeRequest — tool role / tool-result (AI SDK)", () => {
|
||||
it('converts role:"tool" to role:"tool" with tool-result block; output is {type:"text",value}', () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "run X" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "do_x", arguments: '{"a":1}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "call_1",
|
||||
name: "do_x",
|
||||
content: "RESULT_OK",
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
it("converts role:\"tool\" to role:\"tool\" with tool-result block; output is {type:\"text\",value}", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "user", content: "run X" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "call_1", type: "function", function: { name: "do_x", arguments: "{\"a\":1}" } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", name: "do_x", content: "RESULT_OK" },
|
||||
],
|
||||
}, true);
|
||||
|
||||
const toolMsg = out.params.messages[out.params.messages.length - 1];
|
||||
expect(toolMsg.role).toBe("tool");
|
||||
const block = toolMsg.content[0];
|
||||
expect(block.type).toBe("tool-result");
|
||||
expect(block.toolCallId).toBe("call_1");
|
||||
expect(block.toolName).toBe("do_x");
|
||||
expect(block.output).toEqual({ type: "text", value: "RESULT_OK" });
|
||||
});
|
||||
const toolMsg = out.params.messages[out.params.messages.length - 1];
|
||||
expect(toolMsg.role).toBe("tool");
|
||||
const block = toolMsg.content[0];
|
||||
expect(block.type).toBe("tool-result");
|
||||
expect(block.toolCallId).toBe("call_1");
|
||||
expect(block.toolName).toBe("do_x");
|
||||
expect(block.output).toEqual({ type: "text", value: "RESULT_OK" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCodeRequest — assistant tool_calls / tool-call", () => {
|
||||
it("converts assistant.tool_calls[] into content blocks of type tool-call", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "go" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_42",
|
||||
type: "function",
|
||||
function: { name: "search", arguments: '{"q":"hi"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
it("converts assistant.tool_calls[] into content blocks of type tool-call", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "user", content: "go" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "call_42", type: "function", function: { name: "search", arguments: "{\"q\":\"hi\"}" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
|
||||
const asst = out.params.messages[1];
|
||||
expect(asst.role).toBe("assistant");
|
||||
const tc = asst.content.find((b) => b.type === "tool-call");
|
||||
expect(tc).toBeDefined();
|
||||
expect(tc.toolCallId).toBe("call_42");
|
||||
expect(tc.toolName).toBe("search");
|
||||
expect(tc.input).toEqual({ q: "hi" });
|
||||
});
|
||||
const asst = out.params.messages[1];
|
||||
expect(asst.role).toBe("assistant");
|
||||
const tc = asst.content.find((b) => b.type === "tool-call");
|
||||
expect(tc).toBeDefined();
|
||||
expect(tc.toolCallId).toBe("call_42");
|
||||
expect(tc.toolName).toBe("search");
|
||||
expect(tc.input).toEqual({ q: "hi" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCodeRequest — tools schema conversion", () => {
|
||||
it('converts OpenAI {type:"function", function:{...}} to Anthropic plain {name, input_schema}', () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "weather",
|
||||
description: "Get weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
it("converts OpenAI {type:\"function\", function:{...}} to Anthropic plain {name, input_schema}", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "weather",
|
||||
description: "Get weather",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
},
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
|
||||
const t = out.params.tools[0];
|
||||
expect(t.name).toBe("weather");
|
||||
expect(t.input_schema).toBeDefined();
|
||||
expect(t.input_schema.type).toBe("object");
|
||||
expect(t.function).toBeUndefined();
|
||||
expect(t.parameters).toBeUndefined();
|
||||
});
|
||||
const t = out.params.tools[0];
|
||||
expect(t.name).toBe("weather");
|
||||
expect(t.input_schema).toBeDefined();
|
||||
expect(t.input_schema.type).toBe("object");
|
||||
expect(t.function).toBeUndefined();
|
||||
expect(t.parameters).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves description on converted tool", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "ping",
|
||||
description: "Ping the server",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(out.params.tools[0].description).toBe("Ping the server");
|
||||
});
|
||||
it("preserves description on converted tool", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "ping", description: "Ping the server", parameters: { type: "object" } } },
|
||||
],
|
||||
}, true);
|
||||
expect(out.params.tools[0].description).toBe("Ping the server");
|
||||
});
|
||||
|
||||
it("does not include tools field when input has none", () => {
|
||||
const out = openaiToCommandCodeRequest(
|
||||
MODEL,
|
||||
{
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(out.params.tools).toBeUndefined();
|
||||
});
|
||||
it("does not include tools field when input has none", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}, true);
|
||||
expect(out.params.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { handleForcedSSEToJson } from "../../open-sse/handlers/chatCore/sseToJsonHandler.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const sseResponse = (chunks) => {
|
||||
const body = new ReadableStream({
|
||||
start(c) {
|
||||
for (const ch of chunks)
|
||||
c.enqueue(encoder.encode(`data: ${JSON.stringify(ch)}\n\n`));
|
||||
c.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
};
|
||||
|
||||
const mkChunk = (content, finish = null) => ({
|
||||
id: "x",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "m",
|
||||
choices: [
|
||||
{ index: 0, delta: content ? { content } : {}, finish_reason: finish },
|
||||
],
|
||||
});
|
||||
|
||||
const baseCtx = {
|
||||
provider: "fakeprovider",
|
||||
model: "m",
|
||||
body: { stream: false },
|
||||
stream: true,
|
||||
translatedBody: null,
|
||||
finalBody: null,
|
||||
requestStartTime: Date.now(),
|
||||
connectionId: "c1",
|
||||
apiKey: null,
|
||||
clientRawRequest: null,
|
||||
onRequestSuccess: null,
|
||||
pxpipe: null,
|
||||
reqTag: "",
|
||||
log: null,
|
||||
trackDone: () => {},
|
||||
appendLog: () => {},
|
||||
reqLogger: null,
|
||||
toolNameMap: null,
|
||||
sourceFormat: "openai",
|
||||
};
|
||||
|
||||
describe("Layer 1 — non-streaming stream error patterns", () => {
|
||||
it("returns a 502 error result when content matches a configured pattern", async () => {
|
||||
const result = await handleForcedSSEToJson({
|
||||
...baseCtx,
|
||||
streamErrorPatterns: { fakeprovider: ["Network connection lost"] },
|
||||
providerResponse: sseResponse([
|
||||
mkChunk("Network connection lost."),
|
||||
mkChunk(null, "stop"),
|
||||
]),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(502);
|
||||
expect(result.error).toContain("Network connection lost");
|
||||
});
|
||||
|
||||
it("succeeds when content does not match", async () => {
|
||||
const result = await handleForcedSSEToJson({
|
||||
...baseCtx,
|
||||
streamErrorPatterns: { fakeprovider: ["Network connection lost"] },
|
||||
providerResponse: sseResponse([
|
||||
mkChunk("hello world"),
|
||||
mkChunk(null, "stop"),
|
||||
]),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores patterns for other providers", async () => {
|
||||
const result = await handleForcedSSEToJson({
|
||||
...baseCtx,
|
||||
streamErrorPatterns: { otherprovider: ["hello"] },
|
||||
providerResponse: sseResponse([
|
||||
mkChunk("hello world"),
|
||||
mkChunk(null, "stop"),
|
||||
]),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { maybeRejectEarlyStreamError } from "../../open-sse/utils/streamErrorPeek.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const sseResp = (lines) =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(c) {
|
||||
for (const l of lines) c.enqueue(encoder.encode(l));
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
|
||||
describe("maybeRejectEarlyStreamError", () => {
|
||||
it("returns 502 when a pattern matches early stream text", async () => {
|
||||
const res = await maybeRejectEarlyStreamError(
|
||||
sseResp([
|
||||
'{"type":"start"}\n{"type":"error","error":{"type":"server_error","message":"Network connection lost."}}\n',
|
||||
]),
|
||||
["server_error"],
|
||||
);
|
||||
expect(res.status).toBe(502);
|
||||
const body = await res.json();
|
||||
expect(body.error.message).toContain("server_error");
|
||||
});
|
||||
|
||||
it("passes the stream through unchanged when nothing matches", async () => {
|
||||
const res = await maybeRejectEarlyStreamError(
|
||||
sseResp([
|
||||
'data: {"choices":[{"delta":{"content":"hello"},"finish_reason":null}]}\n\ndata: [DONE]\n\n',
|
||||
]),
|
||||
["server_error"],
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
expect(text).toContain("hello");
|
||||
expect(text).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("commits on timeout without hanging", async () => {
|
||||
const stalled = new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
});
|
||||
const res = await maybeRejectEarlyStreamError(stalled, ["x"], {
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body.cancel();
|
||||
});
|
||||
|
||||
it("commits on abort without hanging", async () => {
|
||||
const ctrl = new AbortController();
|
||||
const stalled = new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
});
|
||||
setTimeout(() => ctrl.abort(new Error("gone")), 10);
|
||||
const res = await maybeRejectEarlyStreamError(stalled, ["x"], {
|
||||
signal: ctrl.signal,
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body.cancel();
|
||||
});
|
||||
|
||||
it("commits (passthrough) when patterns are empty", async () => {
|
||||
const res = await maybeRejectEarlyStreamError(
|
||||
sseResp(["data: hi\n\n"]),
|
||||
[],
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("data: hi\n\n");
|
||||
});
|
||||
|
||||
it("multi-byte UTF-8 split across peek boundary round-trips losslessly", async () => {
|
||||
// "café" split mid-é: the peek consumes bytes up to and including 0xC3
|
||||
// (the first half of é); the re-emitted stream must contain the RAW bytes
|
||||
// (never a re-encoded decoded string — TextDecoder flush would replace the
|
||||
// lone 0xC3 with U+FFFD and corrupt the output).
|
||||
const bytes = [
|
||||
0x64, 0x61, 0x74, 0x61, 0x3a, 0x20, 0x22, 0x63, 0x61, 0x66, 0xc3,
|
||||
];
|
||||
const rest = new Uint8Array([0xa9, 0x22, 0x0a, 0x0a]);
|
||||
const body = new ReadableStream({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array(bytes));
|
||||
c.enqueue(rest);
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
const res = await maybeRejectEarlyStreamError(
|
||||
new Response(body, { status: 200 }),
|
||||
["nomatch"],
|
||||
{ maxBytes: 11 },
|
||||
);
|
||||
expect(await res.text()).toBe('data: "café"\n\n');
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parsePatterns,
|
||||
matchStreamErrorPatterns,
|
||||
streamStatusForContent,
|
||||
} from "../../open-sse/utils/streamErrorPatterns.js";
|
||||
|
||||
describe("streamErrorPatterns util", () => {
|
||||
it("matches plain text as case-insensitive substring", () => {
|
||||
expect(
|
||||
matchStreamErrorPatterns(
|
||||
["Network connection lost"],
|
||||
"network CONNECTION LOST.",
|
||||
),
|
||||
).toBe("Network connection lost");
|
||||
expect(
|
||||
matchStreamErrorPatterns(["server_error"], "some normal content"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("matches /regex/flags", () => {
|
||||
expect(
|
||||
matchStreamErrorPatterns(
|
||||
["/generation failed.*retry/i"],
|
||||
"GENERATION FAILED. please RETRY",
|
||||
),
|
||||
).toBe("/generation failed.*retry/i");
|
||||
expect(matchStreamErrorPatterns(["/\\d+ tokens/"], "used 123 tokens")).toBe(
|
||||
"/\\d+ tokens/",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips invalid regex and empty entries", () => {
|
||||
expect(
|
||||
matchStreamErrorPatterns(["/[unclosed/", "", " "], "anything"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty patterns or text", () => {
|
||||
expect(matchStreamErrorPatterns([], "x")).toBeNull();
|
||||
expect(matchStreamErrorPatterns(["x"], "")).toBeNull();
|
||||
expect(matchStreamErrorPatterns(null, "x")).toBeNull();
|
||||
expect(matchStreamErrorPatterns(undefined, "x")).toBeNull();
|
||||
});
|
||||
|
||||
it("regex matching is stateless across calls", () => {
|
||||
const pats = ["/error/i"];
|
||||
expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i");
|
||||
expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i");
|
||||
});
|
||||
|
||||
it("parsePatterns normalizes entries", () => {
|
||||
const parsed = parsePatterns(["Plain", "/re/g", "", "/bad["]);
|
||||
expect(parsed.length).toBe(3);
|
||||
expect(parsed[0]).toEqual({ text: "plain", raw: "Plain" });
|
||||
expect(parsed[1].regex).toBeInstanceOf(RegExp);
|
||||
expect(parsed[2]).toEqual({ text: "/bad[", raw: "/bad[" });
|
||||
});
|
||||
|
||||
it("skips entries that look like regex but fail to compile", () => {
|
||||
const parsed = parsePatterns(["/[unclosed/", "/ok/g"]);
|
||||
expect(parsed.length).toBe(1);
|
||||
expect(parsed[0].raw).toBe("/ok/g");
|
||||
});
|
||||
|
||||
it("streamStatusForContent maps match to error status", () => {
|
||||
expect(streamStatusForContent(["boom"], "a boom happened")).toBe("error");
|
||||
expect(streamStatusForContent(["boom"], "all good")).toBe("success");
|
||||
});
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import { parseGrokCreditsConfig } from "../../open-sse/services/usage/xai.js";
|
||||
import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
function billingResponse(config) {
|
||||
return new Response(JSON.stringify({ config }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function settingsResponse(tier = "SuperGrok") {
|
||||
return new Response(JSON.stringify({ subscription_tier_display: tier }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal grpc-web GetGrokCreditsConfig payload matching the live
|
||||
* SuperGrok response shape: usedPercent float + start/end timestamps.
|
||||
*/
|
||||
function buildCreditsGrpcFrame({ usedPercent = 18, startSec = 1783673158, endSec = 1784277958 }) {
|
||||
// Encode protobuf Timestamp {1: seconds}
|
||||
const encodeVarint = (n) => {
|
||||
const out = [];
|
||||
let v = n >>> 0;
|
||||
while (v >= 0x80) {
|
||||
out.push((v & 0x7f) | 0x80);
|
||||
v >>>= 7;
|
||||
}
|
||||
out.push(v);
|
||||
return Buffer.from(out);
|
||||
};
|
||||
const encodeKey = (field, wt) => encodeVarint((field << 3) | wt);
|
||||
const encodeTimestamp = (seconds) => {
|
||||
const body = Buffer.concat([encodeKey(1, 0), encodeVarint(seconds)]);
|
||||
return body;
|
||||
};
|
||||
const encodeLen = (field, bytes) =>
|
||||
Buffer.concat([encodeKey(field, 2), encodeVarint(bytes.length), bytes]);
|
||||
const encodeFloat = (field, f) => {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeFloatLE(f, 0);
|
||||
return Buffer.concat([encodeKey(field, 5), buf]);
|
||||
};
|
||||
|
||||
const startTs = encodeTimestamp(startSec);
|
||||
const endTs = encodeTimestamp(endSec);
|
||||
const config = Buffer.concat([
|
||||
encodeFloat(1, usedPercent),
|
||||
encodeLen(4, startTs),
|
||||
encodeLen(5, endTs),
|
||||
]);
|
||||
const msg = encodeLen(1, config);
|
||||
const frame = Buffer.alloc(5 + msg.length);
|
||||
frame[0] = 0;
|
||||
frame.writeUInt32BE(msg.length, 1);
|
||||
msg.copy(frame, 5);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function creditsResponse(opts = {}) {
|
||||
const frame = buildCreditsGrpcFrame(opts);
|
||||
return new Response(frame, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/grpc-web+proto" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockXaiHappyPath({ used = 733, limit = 15000, weeklyPercent = 18 } = {}) {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (String(url).includes("/v1/billing")) {
|
||||
return billingResponse({
|
||||
monthlyLimit: { val: limit },
|
||||
used: { val: used },
|
||||
onDemandCap: { val: 0 },
|
||||
billingPeriodStart: "2026-07-01T00:00:00+00:00",
|
||||
billingPeriodEnd: "2026-08-01T00:00:00+00:00",
|
||||
});
|
||||
}
|
||||
if (String(url).includes("GetGrokCreditsConfig")) {
|
||||
return creditsResponse({ usedPercent: weeklyPercent });
|
||||
}
|
||||
if (String(url).includes("/v1/settings")) {
|
||||
return settingsResponse("SuperGrok");
|
||||
}
|
||||
return new Response("{}", { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
describe("xAI usage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parseGrokCreditsConfig extracts weekly used% and reset timestamp", () => {
|
||||
const frame = buildCreditsGrpcFrame({
|
||||
usedPercent: 18,
|
||||
startSec: 1783673158,
|
||||
endSec: 1784277958,
|
||||
});
|
||||
const parsed = parseGrokCreditsConfig(frame);
|
||||
expect(parsed).toMatchObject({
|
||||
usedPercent: 18,
|
||||
remainingPercent: 82,
|
||||
});
|
||||
expect(parsed.resetAt).toBe(new Date(1784277958 * 1000).toISOString());
|
||||
expect(parsed.periodStart).toBe(new Date(1783673158 * 1000).toISOString());
|
||||
});
|
||||
|
||||
it("fetches billing + weekly credits + settings in parallel", async () => {
|
||||
mockXaiHappyPath();
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "xai",
|
||||
accessToken: "tok-abc",
|
||||
});
|
||||
|
||||
const urls = proxyAwareFetch.mock.calls.map((c) => String(c[0]));
|
||||
expect(urls).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig",
|
||||
"https://cli-chat-proxy.grok.com/v1/settings",
|
||||
]),
|
||||
);
|
||||
|
||||
expect(usage.plan).toBe("SuperGrok");
|
||||
expect(usage.quotas.weekly).toMatchObject({
|
||||
used: 18,
|
||||
total: 100,
|
||||
remaining: 82,
|
||||
});
|
||||
expect(usage.quotas.api_usage).toMatchObject({
|
||||
used: 733,
|
||||
total: 15000,
|
||||
unit: "credits",
|
||||
});
|
||||
expect(usage.quotas.api_usage.resetAt).toBe("2026-08-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("still returns weekly when billing fails but credits succeed", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (String(url).includes("/v1/billing")) {
|
||||
return new Response("nope", { status: 503 });
|
||||
}
|
||||
if (String(url).includes("GetGrokCreditsConfig")) {
|
||||
return creditsResponse({ usedPercent: 42 });
|
||||
}
|
||||
if (String(url).includes("/v1/settings")) {
|
||||
return settingsResponse("SuperGrok");
|
||||
}
|
||||
return new Response("{}", { status: 404 });
|
||||
});
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "xai",
|
||||
accessToken: "tok",
|
||||
});
|
||||
|
||||
expect(usage.plan).toBe("SuperGrok");
|
||||
expect(usage.quotas.weekly).toMatchObject({ used: 42, total: 100, remaining: 58 });
|
||||
expect(usage.quotas.api_usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes on-demand row when onDemandCap > 0", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (String(url).includes("/v1/billing")) {
|
||||
return billingResponse({
|
||||
monthlyLimit: { val: 1000 },
|
||||
used: { val: 100 },
|
||||
onDemandCap: { val: 500 },
|
||||
billingPeriodEnd: "2026-08-01T00:00:00+00:00",
|
||||
});
|
||||
}
|
||||
if (String(url).includes("GetGrokCreditsConfig")) {
|
||||
return creditsResponse({ usedPercent: 10 });
|
||||
}
|
||||
if (String(url).includes("/v1/settings")) {
|
||||
return settingsResponse("SuperGrok");
|
||||
}
|
||||
return new Response("{}", { status: 404 });
|
||||
});
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "xai",
|
||||
accessToken: "tok",
|
||||
});
|
||||
|
||||
expect(usage.quotas.on_demand).toMatchObject({
|
||||
used: 0,
|
||||
total: 500,
|
||||
remainingCredits: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns auth message on 401 billing", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (String(url).includes("/v1/billing")) {
|
||||
return new Response("unauthorized", { status: 401 });
|
||||
}
|
||||
return new Response("{}", { status: 404 });
|
||||
});
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "xai",
|
||||
accessToken: "tok",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/re-authorize/i);
|
||||
});
|
||||
|
||||
it("parseQuotaData maps weekly + api_usage labels", () => {
|
||||
const rows = parseQuotaData("xai", {
|
||||
quotas: {
|
||||
weekly: {
|
||||
used: 18,
|
||||
total: 100,
|
||||
remaining: 82,
|
||||
remainingPercentage: 82,
|
||||
resetAt: "2026-07-17T08:45:58.000Z",
|
||||
},
|
||||
api_usage: {
|
||||
used: 733,
|
||||
total: 15000,
|
||||
remainingCredits: 14267,
|
||||
unit: "credits",
|
||||
resetAt: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "Weekly limit",
|
||||
used: 18,
|
||||
total: 100,
|
||||
remaining: 82,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Api usage",
|
||||
used: 733,
|
||||
total: 15000,
|
||||
unit: "credits",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const api = rows.find((r) => r.name === "Api usage");
|
||||
expect(api.remaining).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user