diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7cb1a186..0d729f06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,116 @@
+# v0.5.59 (2026-08-29)
+
+## Features
+- **Search**: new web search providers — Antigravity (Google Search grounding
+ on the existing OAuth account pool, citations keyed and merged by URL) and
+ Xquik (X search with `x-api-key` auth, cursor pagination, credit-based
+ usage), both on `POST /v1/search`. Based on #3437 by @Nautilaceae
+- **Search**: ollama-search and zai-search borrow a chat provider's API key
+ instead of requiring their own connection, driven by a new
+ `credentialFallback` registry field. zai-search later folded into the `glm`
+ provider itself so the web search page shows the shared connection
+- **Models**: daily background sync of model capabilities from models.dev —
+ modalities keyed by model id (majority of sources must declare one),
+ context/output limits keyed by provider + model, strictly additive and
+ sitting below the hand-written tables. ETag + mtime cache, 60s startup
+ delay, `MODEL_CATALOG_SYNC=off` to disable
+- **Models**: add GLM-5.3-Flash (1M context, natively multimodal), DeepSeek
+ V4 Vision, Grok 4.5/4.6 (500k context); correct glm-4.6v/4.5v video input
+ and output limits, backfill glm-4.6v on glm-cn
+- **Usage**: show the Zed plan quota on the dashboard — plan, edit
+ predictions, hosted model requests and billing-cycle reset; unlimited rows
+ render as "N used · Unlimited"
+- **Usage**: track GPT-5.3-Codex-Spark quota windows (spark_session /
+ spark_weekly) from the Codex usage response (#3431)
+- **Antigravity**: quota-aware routing — on 409/429 fetch live quota for the
+ exact per-model resetAt and skip only the exhausted account/model pair;
+ report the earliest reset when every account is blocked (#3561)
+- **Antigravity**: map image `size` to the aspect-ratio model suffix (-WxH);
+ add the Gemini 3.7 Flash tiers to MITM defaultModels so they show up in
+ the dashboard model-mapping table
+- **Dashboard**: bulk import Grok CLI accounts from JSON — paste an array or
+ drag-drop multiple .json files, all OAuth connections created in a single
+ call, mirroring the codex flow
+- **CLI tools**: endpoint presets shared across every tool card through one
+ live-resyncing store, instead of per-card localStorage copies that never
+ saw each other's saved endpoints
+- **Token Saver**: configurable compression timeout (`headroomTimeoutMs`) —
+ the fixed 3000 ms made busy machines time out and send inconsistently
+ compressed bodies, hurting prompt caching
+- **i18n**: pt-BR expanded to 1132 terms
+
+## Fixes
+- **Stream**: record usage when a client closes on the terminal event — the
+ Responses API has no [DONE] sentinel, so codex closed the socket on
+ `response.completed` and cancelled the reader before flush() ran its usage
+ side effects; the tail now lives in a once-guarded finalizeStream(). Also
+ stop logging a disconnect for every completed Responses call
+- **Stream**: parse the trailing NDJSON line an Ollama stream leaves behind
+ without a closing newline — the final chunk carrying `done_reason` and the
+ token counts was dropped
+- **Session**: read the Claude Code session id from the
+ `x-claude-code-session-id` header — `metadata.user_id` is dropped by
+ Responses translation, splitting one conversation across several
+ `prompt_cache_key` values and missing the upstream prefix cache
+- **Usage**: preserve nested `cached_tokens` — the top-level-only read
+ persisted `cached_tokens: 0` for every Responses-format provider (codex,
+ grok-cli, …), billing cache hits at the full input rate
+- **Usage**: GLM quotas accept CREDIT_LIMIT plans and multi-interval windows
+ (5h session / 7d weekly) instead of overwriting a single "session" key
+- **Models**: the catalog sync no longer erases its own output — deltas were
+ measured against the previous run's writes (the second run cut `providers`
+ from 20 entries to 5); one vote per provider in the modality tally, ETag
+ restored from file on startup, and the worker thread dropped after the
+ bundler rewrote its path into a module-not-found error
+- **Executor**: CommandCode returns errors as a `type:"error"` event inside
+ an HTTP 200 NDJSON stream — peek the first events before committing, abort
+ and return a real 4xx/5xx so combo/account fallback triggers instead of
+ streaming the error text as content
+- **Search**: scope failure locks on the credential-fallback path — a failing
+ search locked `modelLock___all` and took the shared glm key offline for
+ chat as well; locks are now attributed to the connection's owner and
+ scoped to `websearch:`
+- **Providers**: connection tests get a 15s AbortSignal timeout instead of
+ hanging and exhausting the browser socket pool; guard undefined provider
+ names on the providers page
+- **Antigravity**: sanitize competing-client branding via a config-driven
+ rule table (Zed's Claude-agent prompt, opencode → antigravity) — upstream
+ answers 429 Quota Exhausted. Applied in the executor so the shared
+ openai-to-gemini translator leaves gemini/vertex/zed untouched
+- **MiniMax**: preserve images on the sourceFormat-matched OpenAI transport
+ — MiniMax-M3 resolved a Claude-shaped body posted to the OpenAI endpoint,
+ silently dropping `image_url` blocks (#3418)
+- **Claude**: decloak tool names in same-format streaming passthrough —
+ OAuth-cloaked names (CLAUDE_TOOL_SUFFIX) leaked to the client and every
+ tool call was rejected as unknown
+- **Tools**: default a missing `tools[].type` to "custom" on Claude-format
+ requests — strict Anthropic-compatible gateways (MiniMax) reject the
+ request with 400 otherwise
+- **Translator**: zai thinkingFormat sends the top-level `reasoning_effort`
+ object GLM-5.2+ requires — every GLM-5.x request ran at the model default
+ (max); gated on GLM-5.2+ since older GLM does not read it (#2721)
+- **RTK**: system prompt injection matches each target wire format
+ (Chat/Responses/Claude/Gemini/Kiro) and is exact-idempotent across retries,
+ so distinct prompts sharing a long prefix are no longer collapsed (#3202).
+ Also set the diagnostic before the silent null return on Responses
+ translation failure so the panel is no longer blank
+- **OpenCode**: route muse-spark through /zen/v1/responses (it 500s on
+ chat/completions), normalizing the Chat fields the Responses API rejects
+ and clamping max/ultra effort to xhigh
+- **CLI**: install better-sqlite3 without build tools on Node 22+ (N-API
+ 13.0.3 ships per-platform prebuilds, `--ignore-scripts` skips the implicit
+ node-gyp build); Node < 22 stays on 12.6.2, working installs untouched
+- **CLI tools**: send the API key Codex actually reads —
+ `[model_providers.9router.http_headers]` instead of auth.json (which left
+ every request 401 and clobbered an existing ChatGPT login); subagent model
+ moved to `agents.default_subagent_model`
+- **OAuth**: refresh Cline tokens with the extension JSON contract
+- **Dashboard**: clamp the API key mask length — keys shorter than 8 chars
+ threw RangeError and crashed the media-provider detail page
+- **UI**: wait for the Material Symbols font itself before revealing icons —
+ `document.fonts.ready` resolved before the 4MB woff2 even started loading,
+ leaving icons blank until a second load
+
# v0.5.55 (2026-08-14)
## Features
diff --git a/cli/hooks/sqliteRuntime.js b/cli/hooks/sqliteRuntime.js
index feca2f59..3cb286e7 100644
--- a/cli/hooks/sqliteRuntime.js
+++ b/cli/hooks/sqliteRuntime.js
@@ -6,7 +6,13 @@ const fs = require("fs");
const os = require("os");
const path = require("path");
-const BETTER_SQLITE3_VERSION = "12.6.2";
+// Gate the pinned version by Node major, mirroring src/lib/db/driver.js gating
+// style: 13.x is N-API and ships per-platform prebuilds inside the package, so
+// it needs no ABI-specific download. It requires Node >= 22; older runtimes stay
+// on 12.6.2, which fetches an ABI-specific binary via prebuild-install.
+const [NODE_MAJOR] = process.versions.node.split(".").map(Number);
+const USE_NAPI_BUILD = NODE_MAJOR >= 22;
+const BETTER_SQLITE3_VERSION = USE_NAPI_BUILD ? "13.0.3" : "12.6.2";
const SQL_JS_VERSION = "1.14.1";
function getDataDir() {
@@ -45,9 +51,23 @@ function hasModule(name) {
return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json"));
}
+function isGlibcRuntime() {
+ try { return Boolean(process.report?.getReport()?.header?.glibcVersionRuntime); } catch { return true; }
+}
+
+// 12.x compiles/downloads into build/Release; 13.x ships prebuilds/-.node.
+function getBetterSqliteBinary() {
+ const root = path.join(getRuntimeNodeModules(), "better-sqlite3");
+ const platform = process.platform === "linux" && !isGlibcRuntime() ? "linuxmusl" : process.platform;
+ return [
+ path.join(root, "build", "Release", "better_sqlite3.node"),
+ path.join(root, "prebuilds", `${platform}-${process.arch}.node`),
+ ].find((file) => fs.existsSync(file));
+}
+
function isBetterSqliteBinaryValid() {
- const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node");
- if (!fs.existsSync(binary)) return false;
+ const binary = getBetterSqliteBinary();
+ if (!binary) return false;
try {
const fd = fs.openSync(binary, "r");
const buf = Buffer.alloc(4);
@@ -91,6 +111,7 @@ function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) {
function npmInstall(pkgs, opts = {}) {
const cwd = ensureRuntimeDir();
const extra = opts.optional ? ["--no-save"] : [];
+ if (opts.ignoreScripts) extra.push("--ignore-scripts");
if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)...");
const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 });
if (!res.ok && !opts.silent) {
@@ -129,7 +150,10 @@ function ensureSqliteRuntime({ silent = false } = {}) {
return { betterSqlite: true, sqlJs: sqlJsOk };
}
- const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
+ // npm injects an implicit `node-gyp rebuild` for any package carrying a
+ // binding.gyp, which would demand build tools even though 13.x already bundles
+ // the binary — skip scripts so the bundled prebuild is used as-is.
+ const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent, ignoreScripts: USE_NAPI_BUILD });
return {
betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
sqlJs: sqlJsOk,
diff --git a/cli/package.json b/cli/package.json
index 2fe55c9c..a6618ce8 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "9router",
- "version": "0.5.55",
+ "version": "0.5.59",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js
index 5ddf95ac..3e18633e 100644
--- a/open-sse/config/appConstants.js
+++ b/open-sse/config/appConstants.js
@@ -171,6 +171,13 @@ export const LOAD_CODE_ASSIST_METADATA = {
// System prompts
export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
+// Rewrite rules applied to Antigravity system prompts: competing-client branding
+// makes the backend flag the request and answer 429 Quota Exhausted.
+export const ANTIGRAVITY_PROMPT_REWRITES = [
+ { from: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", to: "" },
+ { from: /opencode/gi, to: (m) => (m === "OpenCode" ? "Antigravity" : m === "OPENCODE" ? "ANTIGRAVITY" : "antigravity") }
+];
+
export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**";
// Derive từ registry oauth.refreshLeadMs
diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js
index 07bbb4fc..35ec1006 100644
--- a/open-sse/executors/antigravity.js
+++ b/open-sse/executors/antigravity.js
@@ -1,7 +1,7 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
-import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
+import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX, ANTIGRAVITY_PROMPT_REWRITES } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
@@ -246,13 +246,13 @@ export class AntigravityExecutor extends BaseExecutor {
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
stripBlacklisted(requestWithoutTools);
- // Rewrite competitive system prompts (e.g. Zed IDE's Claude prompt) to prevent Antigravity from
- // flagging the request and immediately blocking it with a 429 Quota Exhausted response.
+ // Rewrite competing-client branding in system prompts (e.g. Zed's Claude prompt,
+ // OpenCode naming) so Antigravity doesn't flag the request with a 429 Quota Exhausted.
if (requestWithoutTools.systemInstruction?.parts) {
- const oldText = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
for (const part of requestWithoutTools.systemInstruction.parts) {
- if (typeof part.text === "string" && part.text.includes(oldText)) {
- part.text = part.text.split(oldText).join("");
+ if (typeof part.text !== "string") continue;
+ for (const { from, to } of ANTIGRAVITY_PROMPT_REWRITES) {
+ part.text = part.text.replaceAll(from, to);
}
}
}
diff --git a/open-sse/executors/commandcode.js b/open-sse/executors/commandcode.js
index aad40439..f694e61b 100644
--- a/open-sse/executors/commandcode.js
+++ b/open-sse/executors/commandcode.js
@@ -42,12 +42,235 @@ export class CommandCodeExecutor extends BaseExecutor {
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);
+ result.response = await inspectAndWrapCommandCodeResponse(result.response, opts.model);
return result;
}
+
+ parseError(response, bodyText) {
+ let parsed = null;
+ try {
+ parsed = JSON.parse(bodyText || "{}");
+ } catch {
+ parsed = null;
+ }
+ const errObj = parsed?.error || parsed;
+ const msg = errObj?.message || parsed?.message || bodyText || response.statusText;
+ const status = Number(errObj?.code || errObj?.statusCode || response.status) || response.status;
+ return {
+ status,
+ message: msg || `CommandCode upstream error: ${response.status}`,
+ };
+ }
}
-function wrapNdjsonAsOpenAISse(originalResponse, model) {
+export function parseCommandCodeError(event) {
+ if (!event || typeof event !== "object") {
+ return {
+ statusCode: 503,
+ message: "CommandCode upstream error",
+ type: "server_error",
+ };
+ }
+
+ const errVal = event.error ?? event.message ?? "unknown";
+ let message = "";
+ let statusCode = null;
+ let type = "server_error";
+
+ if (typeof errVal === "object" && errVal !== null) {
+ message = errVal.message || errVal.error || JSON.stringify(errVal);
+ if (errVal.statusCode && Number.isInteger(Number(errVal.statusCode))) {
+ statusCode = Number(errVal.statusCode);
+ } else if (errVal.status && Number.isInteger(Number(errVal.status))) {
+ statusCode = Number(errVal.status);
+ }
+ if (errVal.type) type = errVal.type;
+ } else if (typeof errVal === "string") {
+ message = errVal;
+ } else {
+ message = JSON.stringify(errVal);
+ }
+
+ if (event.statusCode && Number.isInteger(Number(event.statusCode))) {
+ statusCode = Number(event.statusCode);
+ }
+
+ if (!statusCode || statusCode < 400 || statusCode > 599) {
+ const lower = message.toLowerCase();
+ if (lower.includes("rate limit") || lower.includes("too many requests")) {
+ statusCode = 429;
+ type = "rate_limit_error";
+ } else if (lower.includes("unauthorized") || lower.includes("invalid api key") || lower.includes("authentication")) {
+ statusCode = 401;
+ type = "authentication_error";
+ } else if (lower.includes("payment required") || lower.includes("billing")) {
+ statusCode = 402;
+ type = "billing_error";
+ } else if (lower.includes("quota") || lower.includes("forbidden") || lower.includes("permission")) {
+ statusCode = 403;
+ type = "permission_error";
+ } else if (lower.includes("not found")) {
+ statusCode = 404;
+ type = "invalid_request_error";
+ } else if (lower.includes("unavailable") || lower.includes("overloaded") || lower.includes("server error")) {
+ statusCode = 503;
+ type = "server_error";
+ } else {
+ statusCode = 503;
+ }
+ }
+
+ return { statusCode, message, type };
+}
+
+export async function inspectAndWrapCommandCodeResponse(originalResponse, model) {
+ const reader = originalResponse.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ const bufferedLines = [];
+ let detectedError = null;
+
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) {
+ const trimmed = buffer.trim();
+ if (trimmed) {
+ try {
+ const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
+ const parsed = JSON.parse(jsonStr);
+ if (parsed?.type === "error") {
+ detectedError = parsed;
+ } else {
+ bufferedLines.push(trimmed);
+ }
+ } catch {
+ bufferedLines.push(trimmed);
+ }
+ }
+ break;
+ }
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ let stopLoop = false;
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
+ if (!jsonStr || jsonStr === "[DONE]") {
+ bufferedLines.push(trimmed);
+ stopLoop = true;
+ break;
+ }
+
+ let event;
+ try {
+ event = JSON.parse(jsonStr);
+ } catch {
+ bufferedLines.push(trimmed);
+ continue;
+ }
+
+ if (event?.type === "error") {
+ detectedError = event;
+ stopLoop = true;
+ break;
+ }
+
+ bufferedLines.push(trimmed);
+
+ if (
+ event?.type === "text-delta" ||
+ event?.type === "reasoning-delta" ||
+ event?.type === "tool-input-start" ||
+ event?.type === "tool-call" ||
+ event?.type === "finish" ||
+ event?.type === "finish-step"
+ ) {
+ stopLoop = true;
+ break;
+ }
+ }
+
+ if (stopLoop) break;
+ }
+ } catch {
+ try { reader.releaseLock(); } catch { /* ignore */ }
+ return originalResponse;
+ }
+
+ if (detectedError) {
+ try { await reader.cancel(); } catch { /* ignore */ }
+ const { statusCode, message, type } = parseCommandCodeError(detectedError);
+ return new Response(
+ JSON.stringify({
+ error: {
+ message: `[CommandCode error: ${message}]`,
+ type,
+ code: statusCode,
+ },
+ }),
+ {
+ status: statusCode,
+ statusText: statusCode === 503 ? "Service Unavailable" : (statusCode === 429 ? "Too Many Requests" : "Bad Gateway"),
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ },
+ }
+ );
+ }
+
+ const combinedStream = createReplayedStream(bufferedLines, buffer, reader);
+ return wrapNdjsonAsOpenAISse(combinedStream, model, originalResponse);
+}
+
+function createReplayedStream(bufferedLines, remainingBuffer, reader) {
+ const encoder = new TextEncoder();
+ let replayed = false;
+
+ return new ReadableStream({
+ async pull(controller) {
+ if (!replayed) {
+ replayed = true;
+ let prefix = bufferedLines.join("\n");
+ if (prefix && remainingBuffer) {
+ prefix += "\n" + remainingBuffer;
+ } else if (remainingBuffer) {
+ prefix = remainingBuffer;
+ } else if (prefix) {
+ prefix += "\n";
+ }
+ if (prefix) {
+ controller.enqueue(encoder.encode(prefix));
+ }
+ }
+
+ try {
+ const { value, done } = await reader.read();
+ if (done) {
+ controller.close();
+ } else {
+ controller.enqueue(value);
+ }
+ } catch (err) {
+ controller.error(err);
+ }
+ },
+ async cancel(reason) {
+ try {
+ await reader.cancel(reason);
+ } catch {
+ /* ignore */
+ }
+ },
+ });
+}
+
+function wrapNdjsonAsOpenAISse(streamBody, model, originalResponse = null) {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
@@ -70,7 +293,6 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) {
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);
}
},
@@ -83,11 +305,17 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) {
},
});
- const newBody = originalResponse.body.pipeThrough(transform);
+ const newBody = streamBody.pipeThrough(transform);
return new Response(newBody, {
- status: originalResponse.status,
- statusText: originalResponse.statusText,
- headers: originalResponse.headers,
+ status: originalResponse?.status || 200,
+ statusText: originalResponse?.statusText || "OK",
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ ...(originalResponse?.headers ? Object.fromEntries(originalResponse.headers.entries()) : {}),
+ "content-type": "text/event-stream",
+ },
});
}
diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js
index 1bdeb318..381c47e8 100644
--- a/open-sse/executors/opencode.js
+++ b/open-sse/executors/opencode.js
@@ -1,11 +1,13 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
+import { getThinkingLevels } from "../providers/thinkingLevels.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { resolveSessionId } from "../utils/sessionManager.js";
const OPENCODE_UA = "opencode";
-const MESSAGES_MODELS = new Set();
+// Models served by /zen/v1/responses; every other model stays on /chat/completions.
+const RESPONSES_MODELS = new Set(["muse-spark-1.2-contributor-free"]);
function generateRequestId() {
return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
@@ -15,19 +17,47 @@ function generateSessionId() {
return `ses_${crypto.randomUUID().replace(/-/g, "")}`;
}
-// Normalize any resolved id into opencode's ses_ format (stable per-conversation)
-function toOpencodeSession(id) {
- const stripped = String(id || "").replace(/^ses_/, "").replace(/-/g, "");
- return stripped ? `ses_${stripped}` : null;
+// Strip the thinking suffix "model(level)" so registry lookups hit the base id.
+function baseModelId(model) {
+ return String(model || "").replace(/\([^()]+\)\s*$/, "").trim();
+}
+
+function isResponsesModel(model) {
+ return RESPONSES_MODELS.has(baseModelId(model));
}
function resolveOpencodeSession(body, credentials) {
- return toOpencodeSession(resolveSessionId({
- headers: credentials?.rawHeaders,
+ const headers = credentials?.rawHeaders || {};
+ return resolveSessionId({
+ headers,
body,
connectionId: credentials?.connectionId,
scope: "opencode",
- }));
+ generate: generateSessionId,
+ });
+}
+
+function normalizeOpencodeReasoning(model, body) {
+ const current = body.reasoning;
+ const currentReasoning = current && typeof current === "object" && !Array.isArray(current)
+ ? current
+ : null;
+ const requestedEffort = typeof body.reasoning_effort === "string"
+ ? body.reasoning_effort
+ : currentReasoning?.effort;
+ if (typeof requestedEffort !== "string") return;
+
+ const cleanModel = baseModelId(model || body.model);
+ const supportedLevels = getThinkingLevels("opencode", cleanModel);
+ let effort = requestedEffort.toLowerCase().trim();
+ if ((effort === "max" || effort === "ultra") && supportedLevels?.length && !supportedLevels.includes(effort)) {
+ if (effort === "ultra" && supportedLevels.includes("max")) effort = "max";
+ else if (supportedLevels.includes("xhigh")) effort = "xhigh";
+ }
+
+ body.reasoning = { ...currentReasoning, effort };
+ if (!body.reasoning.summary) body.reasoning.summary = "auto";
+ delete body.reasoning_effort;
}
// OpenCode free tier is limited per egress IP — a 429/403 with a limit-ish
@@ -44,13 +74,24 @@ export class OpenCodeExecutor extends BaseExecutor {
transformRequest(model, body, stream, credentials) {
this._currentSessionId = resolveOpencodeSession(body, credentials);
+ if (isResponsesModel(model)) {
+ // Responses API names the output cap max_output_tokens and takes thinking
+ // as reasoning:{effort,summary} — normalize the Chat fields at this boundary.
+ if (body.max_output_tokens === undefined) {
+ if (body.max_completion_tokens !== undefined) body.max_output_tokens = body.max_completion_tokens;
+ else if (body.max_tokens !== undefined) body.max_output_tokens = body.max_tokens;
+ }
+ delete body.max_tokens;
+ delete body.max_completion_tokens;
+ normalizeOpencodeReasoning(model, body);
+ }
return injectReasoningContent({ provider: this.provider, model, body });
}
buildUrl(model) {
const base = this.config.baseUrl;
- return MESSAGES_MODELS.has(model)
- ? `${base}/zen/v1/messages`
+ return isResponsesModel(model)
+ ? `${base}/zen/v1/responses`
: `${base}/zen/v1/chat/completions`;
}
diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js
index c41d2d15..5b1d4024 100644
--- a/open-sse/handlers/chatCore.js
+++ b/open-sse/handlers/chatCore.js
@@ -28,6 +28,7 @@ import { compressWithPxpipe } from "../rtk/pxpipe.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
+import { defaultClaudeToolType } from "../translator/concerns/toolCall.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { markPoolUnfit, clearPoolUnfit } from "../services/proxyPoolFitness.js";
@@ -63,7 +64,7 @@ export function stripContinuityFields(body) {
return body;
}
-export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, resolveProxyConfig }) {
+export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, resolveProxyConfig }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();
// Stable per-session color so all lines of one CLI conversation share a tag
@@ -96,7 +97,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// differ — kimi/glm only do /chat/completions). Undeclared models keep the
// upstream default (use the transport), preserving behavior for glm/deepseek/...
const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null;
- const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials);
+ // A source-format-matched endpoint keeps the request lossless. Prefer it
+ // over a model-level targetFormat, which is only the fallback for clients
+ // whose wire format has no supported transport (for example MiniMax-M3:
+ // OpenAI clients should stay on /chat/completions; other clients can fall
+ // back to its declared Claude target).
+ const targetFormat = useTransport?.format || modelTargetFormat || getTargetFormat(provider, credentials);
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
const stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model);
@@ -241,6 +247,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
delete translatedBody.tools;
}
+ // Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax)
+ // reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing.
+ if (finalFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
+ translatedBody.tools = defaultClaudeToolType(translatedBody.tools);
+ }
+
// Per-request opt-out: client can bypass all token savers via header
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
@@ -251,7 +263,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Headroom: optional external proxy compression; fail open if proxy is absent.
const headroomDiagnostics = {};
- const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
+ const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, timeoutMs: headroomTimeoutMs, diagnostics: headroomDiagnostics });
const headroomLine = formatHeadroomLog(headroomStats);
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
if (headroomLine) {
diff --git a/open-sse/handlers/imageProviders/antigravity.js b/open-sse/handlers/imageProviders/antigravity.js
index a1f90519..4d4dc367 100644
--- a/open-sse/handlers/imageProviders/antigravity.js
+++ b/open-sse/handlers/imageProviders/antigravity.js
@@ -1,6 +1,6 @@
// Antigravity image adapter - delegates to the executor for correct request
// envelope (project, model, requestType, sessionId) and auth headers.
-import { nowSec } from "./_base.js";
+import { nowSec, sizeToAspectRatio } from "./_base.js";
import { getExecutor } from "../../executors/index.js";
// Convert image input (data URI or raw base64) to Gemini inlineData part
@@ -31,6 +31,19 @@ export default {
const executor = getExecutor("antigravity");
if (!executor) throw new Error("Antigravity executor not found");
+ // Ensure we use an image model for image generation
+ const isImageModel = (m) => /image|imagen|image-generation/i.test(m || "");
+ let targetModel = isImageModel(model) ? model : "gemini-3.1-flash-image";
+
+ // If body.size is provided, resolve aspect ratio and append to model
+ if (body.size && typeof body.size === "string") {
+ const ratio = sizeToAspectRatio(body.size);
+ const suffix = ratio.replace(":", "x");
+ if (!targetModel.includes(suffix)) {
+ targetModel = `${targetModel}-${suffix}`;
+ }
+ }
+
// Build parts: text prompt + optional input image for editing
const parts = [{ text: body.prompt }];
const imageInput = body.image || (Array.isArray(body.images) && body.images[0]);
@@ -44,7 +57,7 @@ export default {
};
const result = await executor.execute({
- model,
+ model: targetModel,
body: chatBody,
stream: false,
credentials,
diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js
index 3c02828e..5e3b3c09 100644
--- a/open-sse/handlers/search/callers.js
+++ b/open-sse/handlers/search/callers.js
@@ -347,6 +347,81 @@ function buildSearxngRequest(config, params) {
};
}
+function buildXquikRequest(config, params) {
+ const apiKey = params.token;
+ if (!apiKey) throw new Error("Xquik requires an API key");
+
+ const queryType = getProviderSetting(params, "queryType");
+ if (queryType && !["Latest", "Top"].includes(queryType)) {
+ throw new Error("Xquik queryType must be Latest or Top");
+ }
+
+ const qp = new URLSearchParams({
+ q: params.query,
+ limit: String(params.maxResults),
+ });
+ const cursor = getProviderSetting(params, "cursor");
+ if (cursor) qp.set("cursor", cursor);
+ if (queryType) qp.set("queryType", queryType);
+ if (params.language) qp.set("language", params.language);
+
+ return {
+ url: `${resolveBaseUrl(config, params)}?${qp}`,
+ init: {
+ method: "GET",
+ headers: { Accept: "application/json", "x-api-key": apiKey },
+ },
+ };
+}
+
+// ── Ollama Cloud web_search ──────────────────────────────────────────────
+// POST https://ollama.com/api/web_search { query, max_results }
+// Response: { results: [{ title, url, content, published_at? }] }
+function buildOllamaSearchRequest(config, params) {
+ const body = { query: params.query, max_results: params.maxResults };
+ if (params.country) body.country = params.country;
+ if (params.language) body.language = params.language;
+ return {
+ url: resolveBaseUrl(config, params),
+ init: {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}),
+ },
+ body: JSON.stringify(body),
+ },
+ };
+}
+
+// ── GLM Coding plan MCP web_search_prime ──────────────────────────────────
+// POST https://api.z.ai/api/mcp/web_search_prime/mcp
+// JSON-RPC envelope: { jsonrpc, id, method: "tools/call",
+// params: { name: "web_search_prime", arguments: { search_query, count } } }
+// Response: { result: { content: [{ type: "text", text: "" }] } }
+function buildGlmSearchRequest(config, params) {
+ const body = {
+ jsonrpc: "2.0",
+ id: `9r-${Date.now()}`,
+ method: "tools/call",
+ params: {
+ name: "web_search_prime",
+ arguments: { search_query: params.query, count: params.maxResults },
+ },
+ };
+ return {
+ url: resolveBaseUrl(config, params),
+ init: {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}),
+ },
+ body: JSON.stringify(body),
+ },
+ };
+}
+
// ── Dispatcher ──────────────────────────────────────────────────────────
const BUILDERS = {
@@ -360,6 +435,9 @@ const BUILDERS = {
"searchapi": buildSearchApiRequest,
"youcom": buildYouComRequest,
"searxng": buildSearxngRequest,
+ "xquik": buildXquikRequest,
+ "ollama-search": buildOllamaSearchRequest,
+ "glm": buildGlmSearchRequest,
};
/**
diff --git a/open-sse/handlers/search/chatSearch.js b/open-sse/handlers/search/chatSearch.js
index c5bfb3ad..75f5e02b 100644
--- a/open-sse/handlers/search/chatSearch.js
+++ b/open-sse/handlers/search/chatSearch.js
@@ -1,8 +1,10 @@
/**
* Wrap chat-completions endpoints (with built-in web search) into the unified
- * /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity.
+ * /v1/search response format. Supports gemini, antigravity, openai, xai, kimi,
+ * minimax, perplexity.
*/
import { PROVIDER_MEDIA } from "../../providers/index.js";
+import { ANTIGRAVITY_IDE_USER_AGENT } from "../../providers/shared.js";
// Default search model + endpoint derive from registry searchViaChat (single source)
const searchModel = (id) => PROVIDER_MEDIA[id]?.searchViaChat?.defaultModel;
@@ -28,13 +30,37 @@ function toResult(c, index, provider, retrievedAt) {
score: null,
published_at: null,
favicon_url: null,
- content: null,
+ content: c.content || null,
metadata: {},
citation: { provider, retrieved_at: retrievedAt, rank: index + 1 },
provider_raw: null
};
}
+// Antigravity search request envelope (mirrors the IDE client)
+const AG_CLIENT_NAME = "antigravity";
+const AG_SEARCH_GENERATION_CONFIG = { temperature: 1.0, maxOutputTokens: 8192 };
+const AG_CONTEXT_BEFORE = 150;
+const AG_CONTEXT_AFTER = 250;
+
+/** Widen a grounded segment to its surrounding sentence(s) in the answer text. */
+function expandSegment(text, segment) {
+ const { startIndex, endIndex } = segment || {};
+ if (!text || !Number.isInteger(startIndex) || !Number.isInteger(endIndex)) return "";
+ const start = Math.max(0, startIndex - AG_CONTEXT_BEFORE);
+ const end = Math.min(text.length, endIndex + AG_CONTEXT_AFTER);
+ let out = text.slice(start, end).trim();
+ // Drop the partial words the window cut off at either edge
+ if (start > 0) out = `...${out.replace(/^\S+/, "")}`;
+ if (end < text.length) out = `${out.replace(/\S+$/, "")}...`;
+ return out.trim();
+}
+
+/** Join deduped grounding pieces, skipping empties. */
+function joinPieces(set, sep) {
+ return [...(set || [])].filter(Boolean).join(sep).trim();
+}
+
/** Coerce a citation that might be a raw URL string or an object. */
function normalizeCitation(c) {
if (!c) return null;
@@ -46,6 +72,8 @@ function normalizeCitation(c) {
/**
* Provider-specific configuration map. All providers must implement:
* { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer }
+ * Optional: requireCredentials(credentials) → error string when a provider needs
+ * more than a token (returns null when satisfied).
*/
const CHAT_SEARCH_CONFIG = {
gemini: {
@@ -73,6 +101,71 @@ const CHAT_SEARCH_CONFIG = {
}
},
+ antigravity: {
+ endpoint: () => searchEndpoint("antigravity"),
+ // Upstream 403s on a missing or fabricated project — surface the real cause
+ requireCredentials: (credentials) =>
+ credentials?.projectId ? null : "Antigravity account has no projectId — reconnect the account",
+ buildBody: (query, model, credentials) => ({
+ project: credentials.projectId,
+ model,
+ userAgent: AG_CLIENT_NAME,
+ requestType: "search",
+ request: {
+ contents: [{ role: "user", parts: [{ text: query }] }],
+ tools: [{ googleSearch: {} }],
+ generationConfig: AG_SEARCH_GENERATION_CONFIG
+ }
+ }),
+ buildHeaders: (token) => ({
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ "User-Agent": ANTIGRAVITY_IDE_USER_AGENT
+ }),
+ extractAnswer: (data) => {
+ // Antigravity wraps the Gemini payload in { response: {...} }
+ const response = data?.response || data;
+ const candidate = response?.candidates?.[0];
+ const parts = candidate?.content?.parts || [];
+ const text = parts.map((p) => p?.text || "").filter(Boolean).join("");
+ const grounding = candidate?.groundingMetadata || {};
+ const chunks = grounding.groundingChunks || [];
+ const supports = grounding.groundingSupports || [];
+
+ // Upstream repeats the same source across chunks — key by URL so it stays one citation.
+ // Map, not a plain object: both the index and the URL come from upstream.
+ const sources = new Map();
+ const byIndex = chunks.map((ch) => {
+ const web = ch?.web;
+ const url = web?.uri || web?.url || "";
+ if (!url) return null;
+ if (!sources.has(url)) sources.set(url, { title: web.title || "", snippets: new Set(), contexts: new Set() });
+ return sources.get(url);
+ });
+
+ // Each support ties a sentence of the answer back to the chunks that grounded it
+ for (const s of supports) {
+ const segment = s?.segment;
+ const grounded = segment?.text || "";
+ const expanded = expandSegment(text, segment) || grounded;
+ for (const idx of s?.groundingChunkIndices || []) {
+ const source = Number.isInteger(idx) ? byIndex[idx] : null;
+ if (!source) continue;
+ if (grounded) source.snippets.add(grounded);
+ if (expanded) source.contexts.add(expanded);
+ }
+ }
+
+ const citations = [...sources].map(([url, src]) => {
+ const snippet = joinPieces(src.snippets, " | ") || src.title;
+ return { url, title: src.title, snippet, content: joinPieces(src.contexts, "\n\n") || snippet };
+ });
+
+ const tokens = response?.usageMetadata?.totalTokenCount || 0;
+ return { text, citations, tokens };
+ }
+ },
+
openai: {
endpoint: () => searchEndpoint("openai"),
buildBody: (query, model) => {
@@ -366,13 +459,18 @@ export async function handleChatSearch({
};
}
+ const credentialError = cfg.requireCredentials?.(credentials);
+ if (credentialError) {
+ return { success: false, status: 401, error: credentialError };
+ }
+
const limit =
Number.isFinite(maxResults) && maxResults > 0
? Math.floor(maxResults)
: DEFAULT_MAX_RESULTS;
const useModel = model || searchModel(provider);
const url = cfg.endpoint(useModel);
- const body = cfg.buildBody(query, useModel);
+ const body = cfg.buildBody(query, useModel, credentials);
const headers = cfg.buildHeaders(token);
const controller = new AbortController();
diff --git a/open-sse/handlers/search/index.js b/open-sse/handlers/search/index.js
index f5815471..662b293e 100644
--- a/open-sse/handlers/search/index.js
+++ b/open-sse/handlers/search/index.js
@@ -111,6 +111,13 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType);
const results = normalized.results.slice(0, params.maxResults);
const duration = Date.now() - startTime;
+ const usage = {
+ queries_used: 1,
+ search_cost_usd: providerConfig.costPerQuery ?? null,
+ };
+ if (Number.isFinite(providerConfig.creditsPerResult)) {
+ usage.provider_credits_used = results.length * providerConfig.creditsPerResult;
+ }
return {
success: true,
@@ -119,7 +126,8 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
query: params.query,
results,
answer: null,
- usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 },
+ usage,
+ ...(normalized.pagination ? { pagination: normalized.pagination } : {}),
metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults },
errors: []
}
diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js
index 898b271f..3b415ae5 100644
--- a/open-sse/handlers/search/normalizers.js
+++ b/open-sse/handlers/search/normalizers.js
@@ -199,6 +199,89 @@ function normalizeSearxng(data, _query, _searchType) {
return { results, totalResults: results.length };
}
+function normalizeXquik(data, _query, _searchType) {
+ const now = new Date().toISOString();
+ const items = Array.isArray(data.tweets) ? data.tweets : [];
+ const results = items.map((item, idx) => {
+ const username = typeof item?.author?.username === "string" ? item.author.username : "";
+ const authorName = typeof item?.author?.name === "string" ? item.author.name : "";
+ const tweetId = typeof item?.id === "string" ? item.id : String(item?.id || "");
+ const url = username && tweetId
+ ? `https://x.com/${encodeURIComponent(username)}/status/${encodeURIComponent(tweetId)}`
+ : tweetId
+ ? `https://x.com/i/web/status/${encodeURIComponent(tweetId)}`
+ : "";
+ const author = username ? `@${username}` : authorName || null;
+ const title = author ? `${author} on X` : "X post";
+ const imageUrl = Array.isArray(item?.media)
+ ? item.media.find((media) => typeof media?.mediaUrl === "string")?.mediaUrl
+ : null;
+
+ return makeResult("xquik", {
+ title,
+ url,
+ snippet: typeof item?.text === "string" ? item.text : "",
+ published_at: typeof item?.createdAt === "string" ? item.createdAt : null,
+ author,
+ image_url: imageUrl || null,
+ source_type: "x_post",
+ full_text: typeof item?.text === "string" ? item.text : undefined,
+ text_format: "text",
+ }, idx, now);
+ });
+ const nextCursor = typeof data.next_cursor === "string" && data.next_cursor ? data.next_cursor : null;
+ return {
+ results,
+ totalResults: null,
+ pagination: {
+ has_more: data.has_next_page === true,
+ next_cursor: nextCursor,
+ },
+ };
+}
+
+function normalizeOllamaSearch(data, _query, _searchType) {
+ const now = new Date().toISOString();
+ const items = Array.isArray(data?.results) ? data.results : (Array.isArray(data) ? data : []);
+ const results = items.map((item, idx) =>
+ makeResult("ollama-search", {
+ title: item.title,
+ url: item.url,
+ snippet: item.content || item.snippet || "",
+ full_text: item.content,
+ text_format: "text",
+ published_at: item.published_at || null,
+ source_type: item.source || null,
+ }, idx, now)
+ );
+ return { results, totalResults: results.length };
+}
+
+function normalizeGlmSearch(data, _query, _searchType) {
+ const now = new Date().toISOString();
+ // MCP envelope: { result: { content: [{ type: "text", text: "" }] } }
+ let payload = data;
+ const textContent = data?.result?.content?.[0]?.text;
+ if (typeof textContent === "string") {
+ try { payload = JSON.parse(textContent); } catch { payload = {}; }
+ }
+ const items = Array.isArray(payload?.results) ? payload.results
+ : Array.isArray(payload?.news) ? payload.news
+ : Array.isArray(payload) ? payload
+ : [];
+ const results = items.map((item, idx) =>
+ makeResult("glm", {
+ title: item.title,
+ url: item.link || item.url,
+ snippet: item.content || "",
+ published_at: item.publish_date || item.published_at || null,
+ favicon_url: item.icon || null,
+ source_type: item.media || null,
+ }, idx, now)
+ );
+ return { results, totalResults: results.length };
+}
+
const NORMALIZERS = {
"serper": normalizeSerper,
"brave-search": normalizeBrave,
@@ -210,11 +293,14 @@ const NORMALIZERS = {
"searchapi": normalizeSearchApi,
"youcom": normalizeYouCom,
"searxng": normalizeSearxng,
+ "xquik": normalizeXquik,
+ "ollama-search": normalizeOllamaSearch,
+ "glm": normalizeGlmSearch,
};
/**
* Dispatch to the appropriate normalizer based on providerId.
- * @returns {{results: Array, totalResults: number|null}}
+ * @returns {{results: Array, totalResults: number|null, pagination?: object}}
*/
export function normalizeSearchResponse(providerId, data, query, searchType) {
const fn = NORMALIZERS[providerId];
diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js
index 24a03b04..dcdac7ea 100644
--- a/open-sse/providers/capabilities.js
+++ b/open-sse/providers/capabilities.js
@@ -6,6 +6,16 @@
// 3. PATTERN_CAPABILITIES — glob match, ordered specific -> generic
// 4. DEFAULT_CAPABILITIES — safe floor (always returned)
//
+// Two extra layers then refine the result, and neither can override the hand
+// written tables above (steps 1-2 short-circuit before they are consulted):
+// • the synced catalog — modalities keyed by model, limits keyed by provider
+// + model, refreshed from models.dev in the background. It reads a file, so
+// the server installs it via setCatalogSource(); this module stays free of
+// node:fs because the dashboard bundles it into the browser too.
+// • visionPatterns.js — name-based vision detection, last resort so a model
+// nobody has catalogued yet still accepts images.
+// Both only ever turn a capability ON.
+//
// ── HOW TO ADD / UPDATE A MODEL ──────────────────────────────────────
// Authoritative data source: https://models.dev/api.json (145 providers, 4000+
// models, MIT). Each model exposes the exact fields we map below:
@@ -23,6 +33,7 @@
// 2.0+, Grok, Perplexity). Verify with: curl -s https://models.dev/api.json
import { matchPattern } from "./pricing.js";
+import { looksLikeVisionModel } from "./visionPatterns.js";
/**
* Safe floor — every resolved result is merged over this so consumers
@@ -46,6 +57,7 @@ export const DEFAULT_CAPABILITIES = {
thinkingFormat: null,
thinkingCanDisable: true, // false → model cannot turn thinking off (clamp to min instead of disable)
thinkingRange: null, // { min, max } for budget formats; null = no clamp
+ thinkingEffortSupported: false, // zai format only: model accepts a reasoning_effort level (GLM-5.2+; older GLM ignores it)
// limits (tokens)
contextWindow: 200000,
maxOutput: 64000,
@@ -94,8 +106,14 @@ export const MODEL_CAPABILITIES = {
// Gemini image-gen / OpenAI image / xai image variants
"gpt-image-1": { imageOutput: true, tools: false },
- // GLM vision variant (text GLM has no vision)
- "glm-4.6v": { vision: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000 },
+ // GLM vision variants (text GLM has no vision) — 5.3-Flash and 5V-Turbo are
+ // natively multimodal per z.ai, and 5.3-Flash carries the full 1M window.
+ "glm-5.3-flash": { vision: true, videoInput: true, pdf: true, reasoning: true, thinkingFormat: "zai", contextWindow: 1000000, maxOutput: 131072 },
+ "glm-4.6v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000, maxOutput: 32768 },
+ "glm-4.5v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 64000, maxOutput: 16384 },
+
+ // DeepSeek's first V4 model with image input; text limits match V4-Flash.
+ "deepseek-v4-flash-vision-exp": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 },
// Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases
"vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
@@ -108,6 +126,8 @@ export const MODEL_CAPABILITIES = {
"kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
"kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
"kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
+ // OpenCode Free Muse Spark — OpenAI Responses reasoning supports up to xhigh.
+ "muse-spark-1.2-contributor-free": { reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 },
};
const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 };
@@ -234,6 +254,8 @@ export const PATTERN_CAPABILITIES = [
// ── Grok (vision + Live Search) ──────────────────────────────────
{ pattern: "*grok*image*", caps: { imageOutput: true } },
{ pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } },
+ // Grok 4.6: 500k context, no text output limit (docs.x.ai/developers/grok-4-6)
+ { pattern: "*grok-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 500000 } },
// Grok 4.5 (Grok CLI / Grok Build): 500k context per cli-chat-proxy /v1/models
{ pattern: "*grok-4.5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 64000 } },
{ pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
@@ -261,6 +283,10 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } },
// ── GLM / Z.ai (thinking.enabled; disable via enable_thinking:false) ─
+ // reasoning_effort is only read by z.ai from GLM-5.2 onward (docs.z.ai/guides/capabilities/thinking) —
+ // older GLM (4.x, 5.0, 5.1, 5-turbo, 5v-turbo) ignore it, so gate it per exact version, not the "*glm-5*" catch-all.
+ { pattern: "*glm-5.3*", caps: { reasoning: true, thinkingFormat: "zai", thinkingEffortSupported: true, contextWindow: 200000, maxOutput: 128000 } },
+ { pattern: "*glm-5.2*", caps: { reasoning: true, thinkingFormat: "zai", thinkingEffortSupported: true, contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-5*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4.7*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } },
@@ -325,6 +351,46 @@ export const PATTERN_CAPABILITIES = [
* @param {string} model
* @returns {object} full capabilities object
*/
+const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
+
+// Catalog lookups, installed by the server at startup. Left as no-ops in the
+// browser bundle, where there is no file to read.
+let catalogSource = null;
+
+/**
+ * Install the synced catalog reader (server only).
+ * @param {{ getModalities: Function, getLimits: Function } | null} source
+ */
+export function setCatalogSource(source) {
+ catalogSource = source;
+}
+
+// Apply the synced catalog + name heuristic on top of a table-resolved result.
+// Strictly additive: a capability already true stays true, and a false one only
+// flips when an outside source positively declares support.
+function refine(base, provider, model) {
+ const result = { ...DEFAULT_CAPABILITIES, ...base };
+
+ if (catalogSource) {
+ const modalities = catalogSource.getModalities(model);
+ if (modalities) {
+ for (const key of MODALITY_KEYS) {
+ if (modalities[key] === true) result[key] = true;
+ }
+ }
+
+ const limits = catalogSource.getLimits(provider, model);
+ if (limits) {
+ if (limits.contextWindow > 0) result.contextWindow = limits.contextWindow;
+ if (limits.maxOutput > 0) result.maxOutput = limits.maxOutput;
+ }
+ }
+
+ if (!result.vision && looksLikeVisionModel(model)) result.vision = true;
+
+ return result;
+}
+
export function getCapabilitiesForModel(provider, model) {
if (!model) return { ...DEFAULT_CAPABILITIES };
@@ -342,13 +408,13 @@ export function getCapabilitiesForModel(provider, model) {
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
- // 3. Pattern match (first match wins)
+ // 3. Pattern match (first match wins), refined by catalog + name heuristic
for (const { pattern, caps } of PATTERN_CAPABILITIES) {
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
- return { ...DEFAULT_CAPABILITIES, ...caps };
+ return refine(caps, provider, model);
}
}
// 4. Floor
- return { ...DEFAULT_CAPABILITIES };
+ return refine(null, provider, model);
}
diff --git a/open-sse/providers/catalogOverride.js b/open-sse/providers/catalogOverride.js
new file mode 100644
index 00000000..12914b7d
--- /dev/null
+++ b/open-sse/providers/catalogOverride.js
@@ -0,0 +1,72 @@
+// Read side of the model catalog synced from models.dev.
+//
+// The file is the source of truth; the only thing held in memory is a parsed
+// copy dropped as soon as the file's mtime changes. getCapabilitiesForModel is
+// synchronous and runs per request, so the hot path is one stat (~1us) and the
+// parse (~0.1ms on a ~18KB file) only reruns after a sync.
+
+import fs from "node:fs";
+import path from "node:path";
+import { DATA_DIR } from "@/lib/dataDir.js";
+
+export const CATALOG_FILE = path.join(DATA_DIR, "model-catalog.json");
+// Trimmed upstream catalog, read by the add-models skill (not by the router).
+export const CATALOG_RAW_FILE = path.join(DATA_DIR, "model-catalog-raw.json");
+
+const EMPTY = { models: {}, providers: {} };
+let cache = EMPTY;
+let cachedMtime = -1;
+
+// "zai-org/GLM-4.6V:free" -> "glm-4.6v"
+function baseId(model) {
+ if (!model) return "";
+ const withoutVendor = model.includes("/") ? model.split("/").pop() : model;
+ return withoutVendor.toLowerCase().split(":")[0];
+}
+
+function load() {
+ let mtime;
+ try {
+ mtime = fs.statSync(CATALOG_FILE).mtimeMs;
+ } catch {
+ cache = EMPTY;
+ cachedMtime = -1;
+ return cache;
+ }
+ if (mtime === cachedMtime) return cache;
+
+ cachedMtime = mtime;
+ try {
+ const parsed = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8"));
+ cache = { models: parsed?.models || {}, providers: parsed?.providers || {} };
+ } catch {
+ cache = EMPTY;
+ }
+ return cache;
+}
+
+// Modality is a property of the model itself — any gateway serving it inherits
+// the same image/video/pdf support, so this is keyed by model id alone.
+export function getCatalogModalities(model) {
+ return load().models[baseId(model)] || null;
+}
+
+// Context and output limits are a property of the gateway, not the model: each
+// one truncates differently, so these stay keyed by provider + model.
+export function getCatalogLimits(provider, model) {
+ const byProvider = provider && load().providers[provider];
+ if (!byProvider) return null;
+ return byProvider[model] || byProvider[baseId(model)] || null;
+}
+
+// Force a re-read on the next lookup (called right after a sync writes the file).
+export function invalidateCatalog() {
+ cachedMtime = -1;
+}
+
+// Hand the reader to capabilities.js. That module is bundled into the browser
+// too, so it cannot import this file directly — the server pushes it in.
+export async function installCatalogSource() {
+ const { setCatalogSource } = await import("./capabilities.js");
+ setCatalogSource({ getModalities: getCatalogModalities, getLimits: getCatalogLimits });
+}
diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js
index 2666552b..1f14f415 100644
--- a/open-sse/providers/registry/antigravity.js
+++ b/open-sse/providers/registry/antigravity.js
@@ -17,7 +17,7 @@ export default {
deprecationNotice: "RISK_NOTICE",
},
category: "oauth",
- serviceKinds: ["llm", "image"],
+ serviceKinds: ["llm", "image", "webSearch"],
transport: {
baseUrls: [ANTIGRAVITY_IDE_BASE_URL],
format: "antigravity",
@@ -82,6 +82,11 @@ export default {
loadCodeAssistUserAgent: ANTIGRAVITY_IDE_USER_AGENT,
refreshLeadMs: 300000,
},
+ searchViaChat: {
+ defaultModel: "gemini-2.5-flash",
+ endpoint: `${ANTIGRAVITY_IDE_BASE_URL}/v1internal:generateContent`,
+ freeTier: "Free — Google Search grounding through an Antigravity OAuth account.",
+ },
features: {
usage: true,
},
diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js
index 86123b28..bb8015b0 100644
--- a/open-sse/providers/registry/deepseek.js
+++ b/open-sse/providers/registry/deepseek.js
@@ -45,6 +45,7 @@ export default {
{ id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro Max", upstreamModelId: "deepseek-v4-pro" },
{ id: "deepseek-v4-pro-none", name: "DeepSeek V4 Pro No Thinking", upstreamModelId: "deepseek-v4-pro" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
+ { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)" },
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
],
diff --git a/open-sse/providers/registry/glm-cn.js b/open-sse/providers/registry/glm-cn.js
index cff9eb92..73189464 100644
--- a/open-sse/providers/registry/glm-cn.js
+++ b/open-sse/providers/registry/glm-cn.js
@@ -22,10 +22,12 @@ export default {
},
models: [
{ id: "glm-5.3", name: "GLM 5.3" },
+ { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-4.7", name: "GLM-4.7" },
+ { id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
{ id: "glm-4.6", name: "GLM-4.6" },
{ id: "glm-4.5-air", name: "GLM-4.5-Air" },
],
diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js
index 9bc099b2..6c5f0f6e 100644
--- a/open-sse/providers/registry/glm.js
+++ b/open-sse/providers/registry/glm.js
@@ -46,12 +46,27 @@ export default {
],
models: [
{ id: "glm-5.3", name: "GLM 5.3" },
+ { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
],
+ serviceKinds: ["llm", "webSearch"],
+ // Coding plan bundles web search on the same API key as chat.
+ searchConfig: {
+ baseUrl: "https://api.z.ai/api/mcp/web_search_prime/mcp",
+ method: "POST",
+ authType: "apikey",
+ authHeader: "bearer",
+ costPerQuery: 0,
+ searchTypes: ["web"],
+ defaultMaxResults: 5,
+ maxMaxResults: 50,
+ timeoutMs: 10000,
+ cacheTTLMs: 300000,
+ },
features: {
usage: true,
usageApikey: true,
diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js
index 1e102b7f..02b94667 100644
--- a/open-sse/providers/registry/index.js
+++ b/open-sse/providers/registry/index.js
@@ -120,6 +120,8 @@ import p117 from "./selfhosted-tts.js";
import p118 from "./selfhosted-embedding.js";
import p119 from "./fish-audio.js";
import p120 from "./alitp-intl.js";
+import p121 from "./xquik.js";
+import p122 from "./ollama-search.js";
export default [
p0,
@@ -243,4 +245,6 @@ export default [
p118,
p119,
p120,
+ p121,
+ p122,
];
diff --git a/open-sse/providers/registry/ollama-search.js b/open-sse/providers/registry/ollama-search.js
new file mode 100644
index 00000000..f6bceaf9
--- /dev/null
+++ b/open-sse/providers/registry/ollama-search.js
@@ -0,0 +1,35 @@
+export default {
+ id: "ollama-search",
+ alias: "ollama-search",
+ display: {
+ name: "Ollama Search",
+ icon: "cloud",
+ color: "#ffffff",
+ textIcon: "OL",
+ website: "https://ollama.com",
+ notice: {
+ text: "Web search via Ollama Cloud subscription. Reuses the API key from the Ollama (chat) provider.",
+ apiKeyUrl: "https://ollama.com/settings/keys",
+ },
+ },
+ category: "apikey",
+ authType: "apikey",
+ authModes: ["apikey"],
+ serviceKinds: ["webSearch"],
+ // Credential fallback: reuses the API key registered under the `ollama`
+ // chat provider — one key, chat + search.
+ credentialFallback: "ollama",
+ searchConfig: {
+ baseUrl: "https://ollama.com/api/web_search",
+ method: "POST",
+ authType: "apikey",
+ authHeader: "bearer",
+ costPerQuery: 0,
+ freeMonthlyQuota: 1000,
+ searchTypes: ["web"],
+ defaultMaxResults: 5,
+ maxMaxResults: 10,
+ timeoutMs: 10000,
+ cacheTTLMs: 300000,
+ },
+};
diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js
index 4b189ba8..0dad175f 100644
--- a/open-sse/providers/registry/opencode-go.js
+++ b/open-sse/providers/registry/opencode-go.js
@@ -31,12 +31,14 @@ export default {
{ format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
],
models: [
+ { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] },
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
+ { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js
index e83ad3a7..469c64ca 100644
--- a/open-sse/providers/registry/opencode.js
+++ b/open-sse/providers/registry/opencode.js
@@ -19,7 +19,11 @@ export default {
},
noAuth: true,
},
- models: [],
+ models: [
+ // Only this model is served by /zen/v1/responses; the rest stay on
+ // /chat/completions, so the format is declared per-model, not per-provider.
+ { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses" },
+ ],
modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" },
passthroughModels: true,
};
diff --git a/open-sse/providers/registry/xai.js b/open-sse/providers/registry/xai.js
index 53a73c07..e9cbb6c2 100644
--- a/open-sse/providers/registry/xai.js
+++ b/open-sse/providers/registry/xai.js
@@ -27,6 +27,8 @@ export default {
refreshUrl: "https://auth.x.ai/oauth2/token",
},
models: [
+ { id: "grok-4.6", name: "Grok 4.6" },
+ { id: "grok-4.5", name: "Grok 4.5" },
{ 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" },
diff --git a/open-sse/providers/registry/xquik.js b/open-sse/providers/registry/xquik.js
new file mode 100644
index 00000000..bdac9bab
--- /dev/null
+++ b/open-sse/providers/registry/xquik.js
@@ -0,0 +1,35 @@
+export default {
+ id: "xquik",
+ alias: "xquik",
+ display: {
+ name: "Xquik",
+ icon: "tag",
+ color: "#5C3327",
+ textIcon: "XQ",
+ website: "https://docs.xquik.com/api-reference/x/search-tweets",
+ notice: {
+ apiKeyUrl: "https://xquik.com",
+ text: "Searches public X posts. Billing uses 1 Xquik credit per returned post."
+ }
+ },
+ category: "apikey",
+ authType: "apikey",
+ serviceKinds: [
+ "webSearch"
+ ],
+ searchConfig: {
+ baseUrl: "https://xquik.com/api/v1/x/tweets/search",
+ validateUrl: "https://xquik.com/api/v1/credits",
+ method: "GET",
+ authType: "apikey",
+ authHeader: "x-api-key",
+ searchTypes: [
+ "x"
+ ],
+ defaultMaxResults: 5,
+ maxMaxResults: 100,
+ timeoutMs: 10000,
+ cacheTTLMs: 60000,
+ creditsPerResult: 1
+ }
+};
diff --git a/open-sse/providers/visionPatterns.js b/open-sse/providers/visionPatterns.js
new file mode 100644
index 00000000..3c93afe6
--- /dev/null
+++ b/open-sse/providers/visionPatterns.js
@@ -0,0 +1,42 @@
+// Name-based vision detection — last resort when neither the catalog file nor
+// the capability tables know a model. Vendors put the modality in the id
+// ("qwen3-vl-plus", "glm-4.6v", "deepseek-v4-flash-vision-exp"), so a custom or
+// freshly released model still gets image input instead of silently dropping it.
+//
+// Only ever turns vision ON. Never used to turn a declared capability off.
+
+const SEP = "[-_/:.]";
+
+// Image GENERATION, video generation, and non-chat models also carry these
+// words but take no image input — checked first so they can never match.
+const NOT_VISION = new RegExp(
+ [
+ `(^|${SEP})(image|img)(${SEP}|$)`,
+ "stable-image", "gen[0-9]_image", "nanobanana", "imagine",
+ "t2v", "i2v", "flux", "dall", "sdxl", "diffusion",
+ "embed", "rerank", "guard", "moderation",
+ "tts", "stt", "whisper", "voice", "speech", "audio",
+ ].join("|"),
+ "i"
+);
+
+// Explicit modality words, plus the "v" suffix vendors use for vision
+// variants (glm-4.6v, glm-5v-turbo). The digit-v branch requires a dotted
+// version so the never-shipped `gpt-4v` cannot match.
+const VISION_NAME = new RegExp(
+ [
+ `(^|${SEP})(vision|vl|vlm|multimodal|omni|visual)(${SEP}|$)`,
+ `[0-9]\\.[0-9]+v(${SEP}|$)`,
+ `(^|${SEP})glm-[0-9]+v(${SEP}|$)`,
+ "(^|[-_/:.])(llava|pixtral|internvl|cogvlm|minicpm-v|moondream|idefics|fuyu)",
+ ].join("|"),
+ "i"
+);
+
+// Does this model id look like a vision model? Name signal only.
+export function looksLikeVisionModel(modelId) {
+ if (!modelId) return false;
+ const id = String(modelId).toLowerCase();
+ if (NOT_VISION.test(id)) return false;
+ return VISION_NAME.test(id);
+}
diff --git a/open-sse/rtk/headroom.js b/open-sse/rtk/headroom.js
index 2b15eed2..05004ac4 100644
--- a/open-sse/rtk/headroom.js
+++ b/open-sse/rtk/headroom.js
@@ -7,6 +7,12 @@ import {
const DEFAULT_TIMEOUT_MS = 3000;
+function normalizeTimeout(value) {
+ return typeof value === "number" && Number.isFinite(value) && value > 0
+ ? value
+ : DEFAULT_TIMEOUT_MS;
+}
+
function jsonBytes(value) {
try {
return new TextEncoder().encode(JSON.stringify(value) || "").length;
@@ -240,6 +246,7 @@ async function callCompress(url, messages, model, timeoutMs, compressUserMessage
// /v1/compress only understands OpenAI shape, so Claude bodies are translated
// to OpenAI, compressed, then translated back using 9Router's own translators.
export async function compressWithHeadroom(body, { enabled, url, model, format, compressUserMessages, timeoutMs = DEFAULT_TIMEOUT_MS, diagnostics = null } = {}) {
+ timeoutMs = normalizeTimeout(timeoutMs);
if (!enabled) {
setDiagnostic(diagnostics, "disabled");
return null;
@@ -281,7 +288,10 @@ export async function compressWithHeadroom(body, { enabled, url, model, format,
return null;
}
const oai = openaiResponsesToOpenAIRequest(model, body, false);
- if (!Array.isArray(oai?.messages)) return null;
+ if (!Array.isArray(oai?.messages)) {
+ setDiagnostic(diagnostics, "openai-responses request did not translate to messages[]");
+ return null;
+ }
const data = await callCompress(url, oai.messages, model, timeoutMs, compressUserMessages, diagnostics || {});
if (!data) return null;
// input: undefined so the translator rebuilds input from the compressed
diff --git a/open-sse/rtk/systemInject.js b/open-sse/rtk/systemInject.js
index 0d5af728..b60e15d3 100644
--- a/open-sse/rtk/systemInject.js
+++ b/open-sse/rtk/systemInject.js
@@ -3,96 +3,335 @@
// native-passthrough flows. Used by caveman.js and ponytail.js.
import { FORMATS } from "../translator/formats.js";
+import { OPENAI_BLOCK, CLAUDE_BLOCK, RESPONSES_ITEM } from "../translator/schema/blocks.js";
+import { ROLE } from "../translator/schema/roles.js";
const SEP = "\n\n";
export function injectSystemPrompt(body, format, prompt) {
- if (!body || !prompt) return;
+ try {
+ if (!body || !prompt) return;
+ if (typeof body !== "object") return;
- switch (format) {
- case FORMATS.CLAUDE:
+ // Kiro wire shape is unique (conversationState/systemPrompt) — handle directly.
+ if (isKiroBody(body) || format === FORMATS.KIRO) {
+ injectKiroSystem(body, prompt);
+ return;
+ }
+
+ // Claude/Gemini own a dedicated system field, yet their bodies also carry
+ // messages[]/contents[] — decide by format label before the shape sniff below.
+ // Anthropic rejects a "system" role inside messages[] (no such input role).
+ if (format === FORMATS.CLAUDE) {
injectClaudeSystem(body, prompt);
return;
- case FORMATS.GEMINI:
- case FORMATS.GEMINI_CLI:
- case FORMATS.VERTEX:
- case FORMATS.ANTIGRAVITY:
+ }
+ if (format === FORMATS.GEMINI || format === FORMATS.GEMINI_CLI
+ || format === FORMATS.VERTEX || format === FORMATS.ANTIGRAVITY) {
// Antigravity wraps Gemini shape in body.request → injectGeminiSystem handles it
injectGeminiSystem(body, prompt);
return;
- default:
- // OpenAI and OpenAI-shaped formats (responses/codex/cursor/kiro/ollama)
- injectMessagesSystem(body, prompt);
- }
-}
-
-// OpenAI-shaped: messages[] (chat) or input[] (responses) or instructions (responses string)
-function injectMessagesSystem(body, prompt) {
- // OpenAI Responses API: top-level string field
- if (typeof body.instructions === "string") {
- body.instructions = body.instructions
- ? `${body.instructions}${SEP}${prompt}`
- : prompt;
- return;
- }
-
- const arr = Array.isArray(body.messages) ? body.messages
- : Array.isArray(body.input) ? body.input
- : null;
- if (!arr) return;
-
- const idx = arr.findIndex(m => m && (m.role === "system" || m.role === "developer"));
- if (idx >= 0) {
- appendToOpenAIMessage(arr[idx], prompt);
- } else {
- arr.unshift({ role: "system", content: prompt });
- }
-}
-
-function appendToOpenAIMessage(msg, prompt) {
- if (typeof msg.content === "string") {
- msg.content = `${msg.content}${SEP}${prompt}`;
- } else if (Array.isArray(msg.content)) {
- // Responses-style array of parts {type:"input_text"|"text", text}
- msg.content.push({ type: "input_text", text: prompt });
- } else {
- msg.content = prompt;
- }
-}
-
-// Claude shape: body.system as string | array of {type:"text", text}
-// Insert before the last cache_control block to keep injection inside the cached prefix.
-function injectClaudeSystem(body, prompt) {
- if (typeof body.system === "string" && body.system.length > 0) {
- body.system = `${body.system}${SEP}${prompt}`;
- return;
- }
- if (Array.isArray(body.system)) {
- const block = { type: "text", text: prompt };
- let lastCacheIdx = -1;
- for (let i = body.system.length - 1; i >= 0; i--) {
- if (body.system[i]?.cache_control) { lastCacheIdx = i; break; }
}
- if (lastCacheIdx >= 0) {
- body.system.splice(lastCacheIdx, 0, block);
+
+ // Dispatch by actual wire shape for OpenAI-shaped formats.
+ // instructions string takes precedence; messages[] means Chat; input[] means Responses.
+ if (typeof body.instructions === "string") {
+ injectInstructionsSystem(body, prompt);
+ return;
+ }
+ if (Array.isArray(body.messages)) {
+ injectChatSystem(body, prompt);
+ return;
+ }
+ if (Array.isArray(body.input)) {
+ // Responses input[]: empty array already normalized elsewhere; string stays untouched here
+ injectResponsesInputSystem(body, prompt);
+ return;
+ }
+ if (typeof body.input === "string") {
+ // string input must stay untouched
+ return;
+ }
+
+ // OpenAI-shaped but no array (e.g. empty body) — no-op
+ } catch (_) {
+ // fail-open
+ }
+}
+
+function isKiroBody(body) {
+ if (!body || typeof body !== "object") return false;
+ if (typeof body.systemPrompt !== "string") return false;
+ const cs = body.conversationState;
+ if (!cs || typeof cs !== "object") return false;
+ return Array.isArray(cs.history) || !!(cs.currentMessage && typeof cs.currentMessage === "object");
+}
+
+// Exact idempotency: prompt present as its own SEP-delimited segment (or the
+// whole string), not as a substring of unrelated text.
+function hasPrompt(haystack, prompt) {
+ if (!haystack || typeof haystack !== "string") return false;
+ if (haystack === prompt) return true;
+ return haystack.split(SEP).includes(prompt);
+}
+
+function dedupStringAppend(curr, prompt) {
+ if (!curr) return prompt;
+ if (hasPrompt(curr, prompt)) return curr;
+ return `${curr}${SEP}${prompt}`;
+}
+
+// ---- OpenAI instructions string ----
+function injectInstructionsSystem(body, prompt) {
+ try {
+ const curr = body.instructions;
+ if (typeof curr !== "string") return;
+ if (hasPrompt(curr, prompt)) return;
+ const next = curr ? `${curr}${SEP}${prompt}` : prompt;
+ try { body.instructions = next; } catch (_) { /* frozen/proxy fail-open */ }
+ } catch (_) {}
+}
+
+// ---- Chat messages[] ----
+function injectChatSystem(body, prompt) {
+ try {
+ const arr = body.messages;
+ if (!Array.isArray(arr)) return;
+ // Exact idempotency: scan existing system/developer content for full prompt
+ if (containsPromptInMessages(arr, prompt)) return;
+ let idx = -1;
+ try { idx = arr.findIndex(m => m && (m.role === ROLE.SYSTEM || m.role === ROLE.DEVELOPER)); } catch (_) { return; }
+ if (idx >= 0) {
+ appendToChatMessage(arr[idx], prompt);
} else {
- body.system.push(block);
+ // create typed system message at index 0; fail-open on frozen/proxy
+ try { arr.unshift({ role: ROLE.SYSTEM, content: prompt }); } catch (_) {}
}
- return;
- }
- body.system = prompt;
+ } catch (_) {}
}
-// Gemini shape: body.system_instruction | body.systemInstruction | body.request.systemInstruction
-// Each shape: { parts: [{ text }] }
+function containsPromptInMessages(arr, prompt) {
+ try {
+ for (const m of arr) {
+ if (!m || (m.role !== ROLE.SYSTEM && m.role !== ROLE.DEVELOPER)) continue;
+ const c = m.content;
+ if (typeof c === "string" && hasPrompt(c, prompt)) return true;
+ if (Array.isArray(c)) {
+ for (const part of c) {
+ if (part && typeof part.text === "string" && hasPrompt(part.text, prompt)) return true;
+ }
+ }
+ }
+ } catch (_) {}
+ return false;
+}
+
+function appendToChatMessage(msg, prompt) {
+ try {
+ if (!msg || typeof msg !== "object") return;
+ const c = msg.content;
+ if (typeof c === "string") {
+ const next = dedupStringAppend(c, prompt);
+ if (next === c) return;
+ // avoid partial mutation: try assignment, bail if setter throws
+ try { msg.content = next; } catch (_) {}
+ return;
+ }
+ if (Array.isArray(c)) {
+ // already deduped at message level; but guard block-level too
+ try {
+ if (c.some(b => b && b.text === prompt)) return;
+ } catch (_) {}
+ try { c.push({ type: OPENAI_BLOCK.TEXT, text: prompt }); } catch (_) {}
+ return;
+ }
+ try { msg.content = prompt; } catch (_) {}
+ } catch (_) {}
+}
+
+// ---- Responses input[] ----
+function injectResponsesInputSystem(body, prompt) {
+ try {
+ const arr = body.input;
+ if (!Array.isArray(arr)) return;
+ // instructions already handled above
+ if (containsPromptInResponsesInput(arr, prompt)) return;
+ // find system/developer message items only (type === message)
+ let idx = -1;
+ try {
+ idx = arr.findIndex(m => m && m.type === RESPONSES_ITEM.MESSAGE && (m.role === ROLE.SYSTEM || m.role === ROLE.DEVELOPER));
+ } catch (_) { return; }
+ if (idx >= 0) {
+ appendToResponsesMessage(arr[idx], prompt);
+ } else {
+ const msg = { type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: prompt }] };
+ try { arr.unshift(msg); } catch (_) {}
+ }
+ } catch (_) {}
+}
+
+function containsPromptInResponsesInput(arr, prompt) {
+ try {
+ for (const item of arr) {
+ if (!item || item.type !== RESPONSES_ITEM.MESSAGE) continue;
+ if (item.role !== ROLE.SYSTEM && item.role !== ROLE.DEVELOPER) continue;
+ const c = item.content;
+ if (typeof c === "string" && hasPrompt(c, prompt)) return true;
+ if (Array.isArray(c)) {
+ for (const part of c) {
+ if (part && typeof part.text === "string" && hasPrompt(part.text, prompt)) return true;
+ }
+ }
+ }
+ } catch (_) {}
+ return false;
+}
+
+function appendToResponsesMessage(msg, prompt) {
+ try {
+ if (!msg || typeof msg !== "object") return;
+ const c = msg.content;
+ if (typeof c === "string") {
+ const next = dedupStringAppend(c, prompt);
+ if (next === c) return;
+ try { msg.content = next; } catch (_) {}
+ return;
+ }
+ if (Array.isArray(c)) {
+ try { if (c.some(b => b && b.text === prompt)) return; } catch (_) {}
+ try { c.push({ type: RESPONSES_ITEM.INPUT_TEXT, text: prompt }); } catch (_) {}
+ return;
+ }
+ try { msg.content = [{ type: RESPONSES_ITEM.INPUT_TEXT, text: prompt }]; } catch (_) {}
+ } catch (_) {}
+}
+
+// ---- Claude ----
+function injectClaudeSystem(body, prompt) {
+ try {
+ const sys = body.system;
+ if (typeof sys === "string") {
+ if (hasPrompt(sys, prompt)) return;
+ const next = sys.length > 0 ? `${sys}${SEP}${prompt}` : prompt;
+ try { body.system = next; } catch (_) {}
+ return;
+ }
+ if (Array.isArray(sys)) {
+ try { if (sys.some(b => b && b.text === prompt)) return; } catch (_) {}
+ const block = { type: CLAUDE_BLOCK.TEXT, text: prompt };
+ let lastCacheIdx = -1;
+ try {
+ for (let i = sys.length - 1; i >= 0; i--) {
+ if (sys[i]?.cache_control) { lastCacheIdx = i; break; }
+ }
+ } catch (_) {}
+ try {
+ if (lastCacheIdx >= 0) sys.splice(lastCacheIdx, 0, block);
+ else sys.push(block);
+ } catch (_) {}
+ return;
+ }
+ // absent/null
+ try { body.system = prompt; } catch (_) {}
+ } catch (_) {}
+}
+
+// ---- Gemini ----
function injectGeminiSystem(body, prompt) {
- const target = body.request && typeof body.request === "object" ? body.request : body;
- const useSnake = Object.prototype.hasOwnProperty.call(target, "system_instruction");
- const key = useSnake ? "system_instruction" : "systemInstruction";
- const sys = target[key];
- if (sys && Array.isArray(sys.parts)) {
- sys.parts.push({ text: prompt });
- return;
- }
- target[key] = { parts: [{ text: prompt }] };
+ try {
+ let target = body;
+ try {
+ if (body.request && typeof body.request === "object") target = body.request;
+ } catch (_) {}
+ let useSnake = false;
+ try { useSnake = Object.prototype.hasOwnProperty.call(target, "system_instruction"); } catch (_) {}
+ const key = useSnake ? "system_instruction" : "systemInstruction";
+ let sys;
+ try { sys = target[key]; } catch (_) { sys = undefined; }
+ if (sys && Array.isArray(sys.parts)) {
+ try { if (sys.parts.some(p => p && p.text === prompt)) return; } catch (_) {}
+ try { sys.parts.push({ text: prompt }); } catch (_) {}
+ return;
+ }
+ try { target[key] = { parts: [{ text: prompt }] }; } catch (_) {}
+ } catch (_) {}
+}
+
+// ---- Kiro ----
+// Updates top-level systemPrompt and only the mirrored leading prefix of the
+// first user history turn, else current user. next = old + SEP + prompt.
+// Replace old leading prefix only; preserve time context and user tail.
+function injectKiroSystem(body, prompt) {
+ try {
+ let oldPrompt = typeof body.systemPrompt === "string" ? body.systemPrompt : "";
+ // Repair path: a previous partial write left systemPrompt updated but user
+ // content still mirroring the pre-write prefix. Re-derive the effective old
+ // prefix from content so this pass converges instead of early-returning.
+ const cs0 = body.conversationState;
+ let firstUser0 = cs0 && Array.isArray(cs0.history)
+ ? (cs0.history.find(it => it && it.userInputMessage)?.userInputMessage ?? null)
+ : null;
+ if (!firstUser0 && cs0?.currentMessage?.userInputMessage) firstUser0 = cs0.currentMessage.userInputMessage;
+
+ if (firstUser0 && typeof firstUser0.content === "string" && oldPrompt && !hasPrompt(oldPrompt, prompt)) {
+ const c0 = firstUser0.content;
+ if (c0 === oldPrompt || (c0.startsWith(oldPrompt) && !c0.startsWith(`${oldPrompt}${SEP}`))) {
+ // systemPrompt advanced past mirrored prefix → stale; treat as un-mirrored
+ oldPrompt = "";
+ }
+ }
+ if (oldPrompt && hasPrompt(oldPrompt, prompt)) return;
+ const next = oldPrompt ? `${oldPrompt}${SEP}${prompt}` : prompt;
+
+ // Atomicity: write user content first, then systemPrompt only if content
+ // write succeeded (or was a no-op). If systemPrompt write then fails, the
+ // repair heuristic above re-derives from content on retry — no permanent
+ // half-applied state.
+ const cs = body.conversationState;
+ let targetMsg = null;
+ try {
+ const hist = Array.isArray(cs?.history) ? cs.history : null;
+ if (hist) {
+ for (const item of hist) {
+ if (item && item.userInputMessage) { targetMsg = item.userInputMessage; break; }
+ }
+ }
+ if (!targetMsg && cs?.currentMessage?.userInputMessage) {
+ targetMsg = cs.currentMessage.userInputMessage;
+ }
+ } catch (_) { targetMsg = null; }
+
+ let sysWritten = false;
+ try { body.systemPrompt = next; sysWritten = true; } catch (_) {}
+
+ const applyContent = () => {
+ const content = typeof targetMsg.content === "string" ? targetMsg.content : "";
+ if (oldPrompt === "") {
+ // Empty old prompt: prepend unless already at head (exact, not substring)
+ if (content.startsWith(prompt) || content.startsWith(next)) return;
+ const newContent = content ? `${next}${SEP}${content}` : next;
+ try { targetMsg.content = newContent; } catch (_) {}
+ return;
+ }
+ if (!content.startsWith(oldPrompt)) return; // not mirrored at head — leave alone
+ if (content.startsWith(next)) return; // already applied → idempotent
+ const tail = content.slice(oldPrompt.length);
+ try { targetMsg.content = `${next}${tail}`; } catch (_) {}
+ };
+
+ try {
+ if (targetMsg) applyContent();
+ } catch (_) {}
+ if (sysWritten && targetMsg) {
+ // verify convergence: content should now start with next (or be un-mirrored)
+ let ok = false;
+ try {
+ const c = targetMsg.content;
+ ok = typeof c !== "string" || c.startsWith(next) || !c.startsWith(oldPrompt);
+ } catch (_) {}
+ if (!ok) {
+ try { body.systemPrompt = oldPrompt; } catch (_) {} // rollback
+ }
+ }
+ } catch (_) {}
}
diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js
index 3160f4a7..dbf11ac2 100644
--- a/open-sse/services/tokenRefresh.js
+++ b/open-sse/services/tokenRefresh.js
@@ -4,6 +4,7 @@ import {
refreshXaiToken,
refreshAccessToken,
refreshKimiToken,
+ refreshClineToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshCodexToken,
@@ -23,6 +24,7 @@ import {
export {
refreshAccessToken,
refreshKimiToken,
+ refreshClineToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshCodexToken,
@@ -145,6 +147,7 @@ const REFRESH_HANDLERS = {
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
"codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log),
trae: (c, log) => refreshTraeToken(c.refreshToken, c, log),
+ cline: (c, log) => refreshClineToken(c.refreshToken, log),
zed: () => refreshZedToken(),
windsurf: (c, log) => refreshWindsurfToken(c, log),
// Kimi Code OAuth (merged into id `kimi`); legacy id still routes here
diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js
index 40f27f51..ca313923 100644
--- a/open-sse/services/tokenRefresh/providers.js
+++ b/open-sse/services/tokenRefresh/providers.js
@@ -147,6 +147,53 @@ export async function refreshKimiToken(refreshToken, credentials, log) {
return refreshAccessToken("kimi", refreshToken, credentials, log);
}
+export async function refreshClineToken(refreshToken, log) {
+ if (!refreshToken) return null;
+
+ return dedupRefresh("cline", refreshToken, async () => {
+ try {
+ const response = await fetch(PROVIDERS.cline?.refreshUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify({
+ refreshToken,
+ grantType: "refresh_token",
+ clientType: "extension",
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", {
+ status: response.status,
+ error: errorText,
+ });
+ return null;
+ }
+
+ const body = await response.json();
+ const tokens = body?.data || body;
+ if (!tokens?.accessToken) return null;
+
+ const expiresIn = tokens.expiresAt
+ ? Math.max(1, Math.floor((new Date(tokens.expiresAt).getTime() - Date.now()) / 1000))
+ : (tokens.expiresIn || tokens.expires_in || 3600);
+
+ return {
+ accessToken: tokens.accessToken,
+ refreshToken: tokens.refreshToken || refreshToken,
+ expiresIn,
+ };
+ } catch (error) {
+ log?.error?.("TOKEN_REFRESH", `Error refreshing Cline token: ${error.message}`);
+ return null;
+ }
+ }, log);
+}
+
// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...).
export async function refreshClaudeOAuthToken(refreshToken, log) {
return refreshAccessToken("claude", refreshToken, {}, log);
diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js
index 10bb4bdc..5d39547a 100644
--- a/open-sse/services/usage.js
+++ b/open-sse/services/usage.js
@@ -15,11 +15,12 @@ import { getGrokCliUsage } from "./usage/grok-cli.js";
import { getKimiUsage } from "./usage/kimi.js";
import { getDeepseekUsage } from "./usage/deepseek.js";
import { getFreebuffUsage } from "./usage/freebuff.js";
+import { getZedUsage } from "./usage/zed.js";
import { resolveQoderCredentials } from "./qoderModels.js";
+import { getGlmUsage } from "./usage/glm.js";
import {
getIflowUsage,
getOllamaUsage,
- getGlmUsage,
getVercelAiGatewayUsage,
getQoderUsage,
} from "./usage/misc.js";
@@ -56,6 +57,7 @@ const USAGE_HANDLERS = {
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
freebuff: (c) => getFreebuffUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
+ zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
};
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
diff --git a/open-sse/services/usage/codex.js b/open-sse/services/usage/codex.js
index 960af333..64d3cbbc 100644
--- a/open-sse/services/usage/codex.js
+++ b/open-sse/services/usage/codex.js
@@ -80,6 +80,23 @@ function getCodexReviewRateLimit(data) {
}) || null;
}
+function getCodexSparkRateLimit(data) {
+ if (data.spark_rate_limit || data.gpt_5_3_codex_spark_rate_limit) {
+ return data.spark_rate_limit || data.gpt_5_3_codex_spark_rate_limit;
+ }
+
+ const byLimitId = data.rate_limits_by_limit_id;
+ if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) {
+ return byLimitId["gpt-5.3-codex-spark"] || byLimitId.gpt_5_3_codex_spark || byLimitId.spark || null;
+ }
+
+ const additional = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : [];
+ return additional.find((entry) => {
+ const id = String(entry?.limit_name || entry?.metered_feature || entry?.id || "").toLowerCase();
+ return id.includes("spark") || id.includes("5.3-codex-spark");
+ }) || null;
+}
+
export async function getCodexUsage(accessToken, proxyOptions = null) {
try {
const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, {
@@ -97,16 +114,19 @@ export async function getCodexUsage(accessToken, proxyOptions = null) {
const data = await response.json();
const normalRateLimit = data.rate_limit || data.rate_limits || data.rate_limits_by_limit_id?.codex || {};
const reviewRateLimit = getCodexReviewRateLimit(data);
+ const sparkRateLimit = getCodexSparkRateLimit(data);
const availableResetCredits = Math.max(0, toFiniteNumber(data.rate_limit_reset_credits?.available_count, 0));
const quotas = {};
appendCodexQuotaWindows(quotas, "", normalRateLimit);
appendCodexQuotaWindows(quotas, "review", reviewRateLimit);
+ appendCodexQuotaWindows(quotas, "spark", sparkRateLimit);
return {
plan: data.plan_type || data.summary?.plan || "unknown",
limitReached: getCodexRateLimitBody(normalRateLimit)?.limit_reached || false,
reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false,
+ sparkLimitReached: getCodexRateLimitBody(sparkRateLimit)?.limit_reached || false,
resetCredits: { availableCount: availableResetCredits },
quotas,
};
diff --git a/open-sse/services/usage/glm.js b/open-sse/services/usage/glm.js
new file mode 100644
index 00000000..f4064af6
--- /dev/null
+++ b/open-sse/services/usage/glm.js
@@ -0,0 +1,88 @@
+/**
+ * GLM Coding Plan usage (international + China regions)
+ */
+
+import { proxyAwareFetch } from "../../utils/proxyFetch.js";
+import { U } from "./shared.js";
+
+// GLM quota endpoints (region-aware) — url from registry transport.usage
+const GLM_QUOTA_URLS = {
+ international: U("glm").url,
+ china: U("glm-cn").url,
+};
+
+/**
+ * GLM Coding Plan usage (international + China regions)
+ * Supports both TOKENS_LIMIT and CREDIT_LIMIT and dynamic intervals (e.g. session 5h, weekly 7d).
+ */
+export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
+ if (!apiKey) {
+ return { message: "GLM API key not available." };
+ }
+
+ const region = provider === "glm-cn" ? "china" : "international";
+ const quotaUrl = GLM_QUOTA_URLS[region];
+
+ try {
+ const response = await proxyAwareFetch(
+ quotaUrl,
+ {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: "application/json",
+ },
+ },
+ proxyOptions,
+ );
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return { message: "GLM API key invalid or expired." };
+ }
+ return { message: `GLM quota API error (${response.status}).` };
+ }
+
+ const json = await response.json();
+ const data = json?.data && typeof json.data === "object" ? json.data : {};
+ const limits = Array.isArray(data.limits) ? data.limits : [];
+ const quotas = {};
+
+ for (const limit of limits) {
+ // 1. Accept both TOKENS_LIMIT and CREDIT_LIMIT from GLM API
+ if (!limit || (limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT")) continue;
+ const usedPercent = Number(limit.percentage) || 0;
+ const resetMs = Number(limit.nextResetTime) || 0;
+ const remaining = Math.max(0, 100 - usedPercent);
+
+ // 2. Map key dynamically based on type and period (unit) to avoid overwriting
+ let key = "session";
+ if (limit.unit === 3) {
+ key = `Session (${limit.number}h)`;
+ } else if (limit.unit === 6) {
+ key = "Weekly (7d)";
+ } else if (limit.type === "TOKENS_LIMIT") {
+ key = "Tokens";
+ } else {
+ key = `Limit (${limit.number})`;
+ }
+
+ quotas[key] = {
+ used: usedPercent,
+ total: 100,
+ remaining,
+ remainingPercentage: remaining,
+ resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
+ unlimited: false,
+ };
+ }
+
+ const levelRaw = typeof data.level === "string" ? data.level : "";
+ const plan = levelRaw
+ ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
+ : "Unknown";
+
+ return { plan, quotas };
+ } catch (error) {
+ return { message: `GLM error: ${error.message}` };
+ }
+}
diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js
index fc133eff..e4b04589 100644
--- a/open-sse/services/usage/misc.js
+++ b/open-sse/services/usage/misc.js
@@ -5,11 +5,8 @@
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U } from "./shared.js";
-// GLM quota endpoints (region-aware) — url from registry transport.usage
-const GLM_QUOTA_URLS = {
- international: U("glm").url,
- china: U("glm-cn").url,
-};
+export { getGlmUsage } from "./glm.js";
+
// Vercel AI Gateway credits endpoint
// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings).
@@ -112,63 +109,7 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions
}
}
-/**
- * GLM Coding Plan usage (international + China regions)
- */
-export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
- if (!apiKey) {
- return { message: "GLM API key not available." };
- }
- const region = provider === "glm-cn" ? "china" : "international";
- const quotaUrl = GLM_QUOTA_URLS[region];
-
- try {
- const response = await proxyAwareFetch(quotaUrl, {
- headers: {
- Authorization: `Bearer ${apiKey}`,
- Accept: "application/json",
- },
- }, proxyOptions);
-
- if (!response.ok) {
- if (response.status === 401) {
- return { message: "GLM API key invalid or expired." };
- }
- return { message: `GLM quota API error (${response.status}).` };
- }
-
- const json = await response.json();
- const data = json?.data && typeof json.data === "object" ? json.data : {};
- const limits = Array.isArray(data.limits) ? data.limits : [];
- const quotas = {};
-
- for (const limit of limits) {
- if (!limit || limit.type !== "TOKENS_LIMIT") continue;
- const usedPercent = Number(limit.percentage) || 0;
- const resetMs = Number(limit.nextResetTime) || 0;
- const remaining = Math.max(0, 100 - usedPercent);
-
- quotas["session"] = {
- used: usedPercent,
- total: 100,
- remaining,
- remainingPercentage: remaining,
- resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
- unlimited: false,
- };
- }
-
- const levelRaw = typeof data.level === "string" ? data.level : "";
- const plan = levelRaw
- ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
- : "Unknown";
-
- return { plan, quotas };
- } catch (error) {
- return { message: `GLM error: ${error.message}` };
- }
-}
/**
* Vercel AI Gateway usage — credit balance for the API key
diff --git a/open-sse/services/usage/zed.js b/open-sse/services/usage/zed.js
new file mode 100644
index 00000000..c81e7cd5
--- /dev/null
+++ b/open-sse/services/usage/zed.js
@@ -0,0 +1,222 @@
+/**
+ * Zed usage — GET https://cloud.zed.dev/client/users/me
+ * Auth: Authorization: {user_id} {access_token}
+ *
+ * Quota rows are derived from plan.usage (edit_predictions, optional model_requests)
+ * and subscription_period.ended_at for billing-cycle reset.
+ */
+
+import { fetchZedAuthenticatedUser } from "../../shared/zedAuth.js";
+import { parseResetTime, toFiniteNumber } from "./shared.js";
+
+/** Map plan_v3 ids to dashboard labels (CodexBar-compatible). */
+export function formatZedPlanLabel(rawPlan) {
+ const raw = String(rawPlan || "").trim();
+ if (!raw) return "Zed";
+ switch (raw.toLowerCase()) {
+ case "zed_free":
+ return "Zed Free";
+ case "zed_pro":
+ return "Zed Pro";
+ case "zed_pro_trial":
+ return "Zed Pro Trial";
+ case "zed_student":
+ return "Zed Student";
+ case "zed_business":
+ return "Zed Business";
+ default:
+ return raw
+ .replace(/_/g, " ")
+ .split(/\s+/)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(" ");
+ }
+}
+
+/**
+ * Parse Zed UsageLimit JSON: "unlimited", a number, or { limited: N }.
+ */
+export function parseZedUsageLimit(limit) {
+ if (limit == null) return { unlimited: false, total: 0 };
+
+ if (limit === "unlimited" || limit?.unlimited === true) {
+ return { unlimited: true, total: 0 };
+ }
+
+ if (typeof limit === "number" && Number.isFinite(limit)) {
+ return { unlimited: false, total: Math.max(0, limit) };
+ }
+
+ if (typeof limit === "string") {
+ const trimmed = limit.trim();
+ if (trimmed === "unlimited") return { unlimited: true, total: 0 };
+ const parsed = Number(trimmed);
+ if (Number.isFinite(parsed)) return { unlimited: false, total: Math.max(0, parsed) };
+ }
+
+ const limited = limit.limited ?? limit.Limited;
+ if (typeof limited === "number" && Number.isFinite(limited)) {
+ return { unlimited: false, total: Math.max(0, limited) };
+ }
+
+ return { unlimited: false, total: 0 };
+}
+
+/** limit `{ limited: 0 }` on Pro/Student means token billing, not a 0-cap request quota. */
+export function isZedTokenBillingModelRequestsLimit(limitRaw) {
+ const info = parseZedUsageLimit(limitRaw);
+ return !info.unlimited && info.total === 0;
+}
+
+function makeZedQuotaRow(name, usedRaw, limitRaw, resetAt = null) {
+ const used = Math.max(0, toFiniteNumber(usedRaw, 0));
+ const limitInfo = parseZedUsageLimit(limitRaw);
+
+ if (limitInfo.unlimited) {
+ return {
+ used,
+ total: 0,
+ remainingPercentage: 100,
+ resetAt: resetAt || null,
+ unlimited: true,
+ };
+ }
+
+ const total = limitInfo.total;
+ if (total <= 0) {
+ return {
+ used,
+ total: 0,
+ remainingPercentage: 0,
+ resetAt: resetAt || null,
+ unlimited: false,
+ };
+ }
+
+ const clampedUsed = Math.min(used, total);
+ const remaining = Math.max(0, total - clampedUsed);
+ return {
+ used: clampedUsed,
+ total,
+ remainingPercentage: (remaining / total) * 100,
+ resetAt: resetAt || null,
+ unlimited: false,
+ };
+}
+
+function usageBucketLimit(bucket) {
+ if (!bucket || typeof bucket !== "object") return null;
+ if (bucket.limit != null) return bucket.limit;
+ return bucket;
+}
+
+/**
+ * Map /client/users/me JSON → { plan, quotas, message } for the dashboard.
+ */
+export function parseZedAuthenticatedUserUsage(userInfo) {
+ const plan = userInfo?.plan || {};
+ const planId =
+ plan.plan_v3 || plan.plan_v2 || plan.plan || userInfo?.plan_v3 || null;
+ const resetAt =
+ parseResetTime(plan.subscription_period?.ended_at) ||
+ parseResetTime(plan.subscriptionPeriod?.endedAt) ||
+ null;
+
+ const quotas = {};
+ const usage = plan.usage || {};
+
+ const editPredictions = usage.edit_predictions || usage.editPredictions;
+ if (editPredictions) {
+ quotas["Edit Predictions"] = makeZedQuotaRow(
+ "Edit Predictions",
+ editPredictions.used,
+ editPredictions.limit,
+ resetAt,
+ );
+ }
+
+ const modelRequests = usage.model_requests || usage.modelRequests;
+ if (modelRequests) {
+ const limitRaw =
+ modelRequests.limit != null
+ ? modelRequests.limit
+ : usageBucketLimit(modelRequests)?.limit;
+ const limitInfo = parseZedUsageLimit(limitRaw);
+ // Token-billed plans report model_requests.limit=0 — not a request quota.
+ if (limitInfo.unlimited || limitInfo.total > 0) {
+ quotas["Hosted Model Requests"] = makeZedQuotaRow(
+ "Hosted Model Requests",
+ modelRequests.used,
+ limitRaw,
+ resetAt,
+ );
+ }
+ }
+
+ const tokenBillingNote =
+ modelRequests &&
+ isZedTokenBillingModelRequestsLimit(
+ modelRequests.limit ?? usageBucketLimit(modelRequests)?.limit,
+ )
+ ? "Hosted AI models are billed per token (not request count). Edit Predictions are tracked below. Token spend is on dashboard.zed.dev."
+ : null;
+
+ let planLabel = formatZedPlanLabel(planId);
+ if (plan.trial_started_at || plan.trialStartedAt) {
+ if (!/trial/i.test(planLabel)) planLabel = `${planLabel} (Trial active)`;
+ }
+
+ let message = tokenBillingNote;
+ if (plan.has_overdue_invoices || plan.hasOverdueInvoices) {
+ message = "This Zed account has overdue invoices. Usage may be blocked until billing is resolved.";
+ }
+
+ return {
+ plan: planLabel,
+ quotas,
+ message,
+ hasOverdueInvoices: !!(plan.has_overdue_invoices || plan.hasOverdueInvoices),
+ trialStarted: !!(plan.trial_started_at || plan.trialStartedAt),
+ planId: planId || null,
+ resetAt,
+ };
+}
+
+/**
+ * @param {string|null|undefined} accessToken
+ * @param {object|null|undefined} providerSpecificData
+ * @param {object|null|undefined} proxyOptions
+ */
+export async function getZedUsage(
+ accessToken = null,
+ providerSpecificData = {},
+ proxyOptions = null,
+) {
+ const psd = providerSpecificData || {};
+ const userId = psd.userId;
+
+ if (!accessToken || typeof accessToken !== "string" || !accessToken.trim()) {
+ return { message: "Zed access token not available. Re-connect Zed to view quota." };
+ }
+ if (!userId) {
+ return { message: "Zed credential is missing user id. Re-connect Zed to view quota." };
+ }
+
+ const credentials = {
+ accessToken: accessToken.trim(),
+ providerSpecificData: psd,
+ };
+
+ try {
+ const userInfo = await fetchZedAuthenticatedUser(credentials, { proxyOptions });
+ return parseZedAuthenticatedUserUsage(userInfo);
+ } catch (error) {
+ const status = error?.status;
+ if (status === 401 || status === 403) {
+ return {
+ message: "Zed authentication failed. Sign in again from the dashboard or Zed editor.",
+ };
+ }
+ return { message: `Zed error: ${error.message || "Failed to fetch quota"}` };
+ }
+}
diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js
index aa3337d7..e3d8371a 100644
--- a/open-sse/shared/zedAuth.js
+++ b/open-sse/shared/zedAuth.js
@@ -172,8 +172,8 @@ function getSystemId(credentials) {
);
}
-async function fetchJson(url, options) {
- const res = await proxyAwareFetch(url, options);
+async function fetchJson(url, options, proxyOptions = null) {
+ const res = await proxyAwareFetch(url, options, proxyOptions);
const text = await res.text();
let data = null;
if (text) {
@@ -203,11 +203,15 @@ export async function fetchZedAuthenticatedUser(credentials, options = {}) {
const systemId = getSystemId(credentials);
if (systemId) headers[ZED_HEADERS.systemId] = systemId;
- return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), {
- method: "GET",
- headers,
- signal: options.signal ?? undefined,
- });
+ return fetchJson(
+ zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL),
+ {
+ method: "GET",
+ headers,
+ signal: options.signal ?? undefined,
+ },
+ options.proxyOptions ?? null,
+ );
}
function normalizeOrganizationId(value) {
diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js
index d18f47b6..9902c314 100644
--- a/open-sse/translator/concerns/thinkingUnified.js
+++ b/open-sse/translator/concerns/thinkingUnified.js
@@ -58,6 +58,15 @@ export function extractThinking(body) {
return { mode: "level", level: e };
}
+ // OpenAI chat / Responses shape — check effort first (zai sends both thinking object and reasoning.effort)
+ const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null);
+ if (typeof effort === "string" && effort) {
+ const e = effort.toLowerCase();
+ if (e === "none" || e === "off") return { mode: "none" };
+ if (e === "auto") return { mode: "auto" };
+ return { mode: "level", level: e };
+ }
+
// Claude shape
const t = body.thinking;
if (t && typeof t === "object") {
@@ -69,15 +78,6 @@ export function extractThinking(body) {
}
}
- // OpenAI chat / Responses shape
- const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null);
- if (typeof effort === "string" && effort) {
- const e = effort.toLowerCase();
- if (e === "none" || e === "off") return { mode: "none" };
- if (e === "auto") return { mode: "auto" };
- return { mode: "level", level: e };
- }
-
// Gemini shape (top-level, generationConfig, or request envelope)
const tc = body.thinkingConfig || body.generationConfig?.thinkingConfig || body.request?.generationConfig?.thinkingConfig;
if (tc && typeof tc === "object") {
@@ -270,6 +270,18 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) {
// Z.ai ignores thinking.disabled → must use enable_thinking:false to turn off.
if (none && canDisable) { body.enable_thinking = false; delete body.thinking; break; }
body.thinking = { type: "enabled" };
+ // reasoning_effort is only read by z.ai from GLM-5.2 onward — older GLM ignores it
+ // (see thinkingEffortSupported in capabilities.js). Skip on unsupported models so we
+ // don't send a field the API doesn't recognize.
+ if (caps.thinkingEffortSupported) {
+ const zaiLvl = toLevel(eff);
+ // GLM-5.3 only accepts exactly low|high|max (anything else errors); GLM-5.2 accepts
+ // a wider set but z.ai maps low/medium->high and xhigh->max server-side anyway, so
+ // this 3-value mapping matches both.
+ body.reasoning_effort = (zaiLvl === "low" || zaiLvl === "minimal") ? "low"
+ : (zaiLvl === "high" || zaiLvl === "medium") ? "high"
+ : "max";
+ }
break;
}
case "qwen": {
diff --git a/open-sse/translator/concerns/toolCall.js b/open-sse/translator/concerns/toolCall.js
index 8de82f71..958764dd 100644
--- a/open-sse/translator/concerns/toolCall.js
+++ b/open-sse/translator/concerns/toolCall.js
@@ -151,3 +151,17 @@ export function fixMissingToolResponses(body) {
return body;
}
+// Default `type: "custom"` on Claude-format tools that arrive without one.
+// Anthropic's Claude tool schema requires `type` to be explicitly set; strict gateways
+// (e.g., MiniMax Anthropic-compatible endpoint, error 2013) reject legacy payloads that
+// omit it with HTTP 400. Tools that already carry a truthy `type` (e.g., `computer_use`,
+// `bash`, `web_search_20250305`) are passed through untouched.
+//
+// Spread order matters: `{ ...tool, type: "custom" }` (spread first, override last)
+// ensures that falsy `type` values (null, undefined, "") in the original tool don't
+// overwrite the default. `{ type: "custom", ...tool }` would let `type: null` survive.
+export function defaultClaudeToolType(tools) {
+ if (!Array.isArray(tools)) return tools;
+ return tools.map(tool => tool?.type ? tool : { ...tool, type: "custom" });
+}
+
diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js
index e2f45339..2fcbcbf8 100644
--- a/open-sse/translator/index.js
+++ b/open-sse/translator/index.js
@@ -1,7 +1,7 @@
import { FORMATS } from "./formats.js";
import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js";
import { prepareClaudeRequest } from "./formats/claude.js";
-import { cloakClaudeTools } from "../utils/claudeCloaking.js";
+import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js";
import { filterToOpenAIFormat } from "./formats/openai.js";
import { normalizeThinkingConfig } from "../services/provider.js";
import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js";
@@ -133,7 +133,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
result = prepareClaudeRequest(result, provider, apiKey, connectionId, credentials?.rawHeaders, clientSessionId);
}
- // Claude cloaking: rename client tools with _cc suffix (anti-ban)
+ // Claude cloaking: rename client tools with CLAUDE_TOOL_SUFFIX (anti-ban)
// quirk: only providers flagged cloakToolsOnOAuth, and only with an OAuth token
if (PROVIDERS[provider]?.quirks?.cloakToolsOnOAuth) {
const apiKey = credentials?.accessToken || credentials?.apiKey || null;
@@ -161,9 +161,12 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
// Translate response chunk: target -> openai -> source
export function translateResponse(targetFormat, sourceFormat, chunk, state) {
ensureInitialized();
- // If same format, return as-is
+ // If same format, return as-is — except the tool name may still be cloaked:
+ // translateRequest() suffixes client tools for OAuth-cloaked Claude providers
+ // even when no format conversion is needed, so streamed tool_use blocks must
+ // be decloaked here or the client sees an unknown ("_ide"-suffixed) tool.
if (sourceFormat === targetFormat) {
- return [chunk];
+ return [decloakStreamChunk(chunk, state?.toolNameMap)];
}
let results = [chunk];
diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js
index 29e43152..cf5bc529 100644
--- a/open-sse/translator/request/openai-responses.js
+++ b/open-sse/translator/request/openai-responses.js
@@ -300,7 +300,16 @@ function buildReasoningInputItem(msg) {
*/
export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) {
// Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[])
- if (body.input) return { ...body, model, stream: true };
+ if (body.input) {
+ const out = { ...body, model, stream: true };
+ if (out.max_output_tokens === undefined) {
+ if (out.max_completion_tokens !== undefined) out.max_output_tokens = out.max_completion_tokens;
+ else if (out.max_tokens !== undefined) out.max_output_tokens = out.max_tokens;
+ }
+ delete out.max_tokens;
+ delete out.max_completion_tokens;
+ return out;
+ }
const result = {
model,
@@ -416,7 +425,13 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
// Pass through other relevant fields
if (body.temperature !== undefined) result.temperature = body.temperature;
- if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens;
+ if (body.max_output_tokens !== undefined) {
+ result.max_output_tokens = body.max_output_tokens;
+ } else if (body.max_completion_tokens !== undefined) {
+ result.max_output_tokens = body.max_completion_tokens;
+ } else if (body.max_tokens !== undefined) {
+ result.max_output_tokens = body.max_tokens;
+ }
if (body.top_p !== undefined) result.top_p = body.top_p;
if (body.reasoning !== undefined) result.reasoning = body.reasoning;
if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" };
diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js
index 46a44e4f..4f43a670 100644
--- a/open-sse/utils/claudeCloaking.js
+++ b/open-sse/utils/claudeCloaking.js
@@ -31,8 +31,8 @@ function generateFakeUserID(sessionId, apiKey) {
/**
* Cloak tools before sending to Claude provider (anti-ban):
- * - Rename non-CC client tools with _cc suffix in tools[] and messages[]
- * - Skip tools that are already CC default names (they become decoys as-is)
+ * - Rename client tools with the CLAUDE_TOOL_SUFFIX ("_ide") in tools[] and messages[]
+ * - Skip tools that carry a `type` (server-side built-ins) — sent as-is
* - Inject CC_DECOY_TOOLS after client tools
* Returns { body, toolNameMap } where toolNameMap maps suffixed → original
* @param {object} body - Claude API request body
@@ -101,6 +101,33 @@ export function decloakToolNames(body, toolNameMap) {
return { ...body, content };
}
+/**
+ * Decloak the tool name inside a single streamed Claude SSE event.
+ *
+ * Streaming counterpart of decloakToolNames(). Required for claude→claude
+ * proxying: translateResponse() returns same-format chunks untouched, so
+ * without this the client receives the cloaked ("_ide"-suffixed) tool name
+ * and rejects the call as an unknown tool. In a Claude SSE stream a tool
+ * name appears exactly once per call — on the content_block_start event of
+ * a tool_use block; argument deltas carry no name.
+ *
+ * Unknown names (e.g. a CC decoy tool the model called anyway) pass through
+ * unchanged, matching the non-streaming decloak behavior.
+ *
+ * @param {object|null} chunk - Parsed SSE event (may be null on stream flush)
+ * @param {Map|null} toolNameMap - Suffixed → original name map from cloakClaudeTools()
+ * @returns {object|null} The chunk, with the tool_use name restored when cloaked
+ */
+export function decloakStreamChunk(chunk, toolNameMap) {
+ if (!toolNameMap?.size || !chunk || typeof chunk !== "object") return chunk;
+ if (chunk.type !== "content_block_start") return chunk;
+ const block = chunk.content_block;
+ if (block?.type !== "tool_use" || typeof block.name !== "string") return chunk;
+ const original = toolNameMap.get(block.name);
+ if (!original) return chunk;
+ return { ...chunk, content_block: { ...block, name: original } };
+}
+
// CC decoy tools — Claude Code native tool names, marked unavailable
const CC_DECOY_TOOLS = [
{ name: "Task", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } },
diff --git a/open-sse/utils/claudeToolTypeSelfCheck.mjs b/open-sse/utils/claudeToolTypeSelfCheck.mjs
new file mode 100644
index 00000000..5b3b2a6c
--- /dev/null
+++ b/open-sse/utils/claudeToolTypeSelfCheck.mjs
@@ -0,0 +1,117 @@
+// Claude tool type default self-check.
+// Run: node open-sse/utils/claudeToolTypeSelfCheck.mjs
+// No framework, no deps. Uses assert. Mirrors toolPairingSelfCheck.mjs style.
+import { defaultClaudeToolType } from "../translator/concerns/toolCall.js";
+
+const results = [];
+function run(name, fn) {
+ try {
+ fn();
+ results.push({ name, ok: true });
+ } catch (err) {
+ results.push({ name, ok: false, err: err.message });
+ }
+}
+const assert = {
+ equal(a, b, msg) { if (a !== b) throw new Error(`${msg || ""} expected ${b}, got ${a}`); },
+ ok(v, msg) { if (!v) throw new Error(msg || "expected truthy"); },
+};
+
+// 1. Tool without `type` property → defaults to "custom"
+run("Tool without type property defaults to custom", () => {
+ const tools = [{ name: "foo", description: "bar", input_schema: {} }];
+ const out = defaultClaudeToolType(tools);
+ assert.equal(out[0].type, "custom", "type defaulted");
+ assert.equal(out[0].name, "foo", "other fields preserved");
+});
+
+// 2. Tool with type:null → defaults to "custom" (the spread-order bug case)
+run("Tool with type:null defaults to custom", () => {
+ const tools = [{ name: "foo", type: null, input_schema: {} }];
+ const out = defaultClaudeToolType(tools);
+ assert.equal(out[0].type, "custom", "null type overwritten to custom");
+});
+
+// 3. Tool with type:undefined → defaults to "custom"
+run("Tool with type:undefined defaults to custom", () => {
+ const tools = [{ name: "foo", type: undefined, input_schema: {} }];
+ const out = defaultClaudeToolType(tools);
+ assert.equal(out[0].type, "custom", "undefined type overwritten to custom");
+});
+
+// 4. Tool with type:"" (empty string) → defaults to "custom"
+run("Tool with type:empty-string defaults to custom", () => {
+ const tools = [{ name: "foo", type: "", input_schema: {} }];
+ const out = defaultClaudeToolType(tools);
+ assert.equal(out[0].type, "custom", "empty-string type overwritten to custom");
+});
+
+// 5. Built-in tool with type:"computer_use" → passed through untouched
+run("Built-in tool (computer_use) passed through", () => {
+ const tools = [{ type: "computer_use", name: "computer", display_width: 1024 }];
+ const out = defaultClaudeToolType(tools);
+ assert.equal(out[0].type, "computer_use", "built-in type preserved");
+ assert.equal(out[0], tools[0], "same reference — not cloned");
+});
+
+// 6. Tool already with type:"custom" → passed through untouched
+run("Tool already with type:custom passed through", () => {
+ const tools = [{ type: "custom", name: "foo", input_schema: {} }];
+ const out = defaultClaudeToolType(tools);
+ assert.equal(out[0].type, "custom", "existing custom type preserved");
+ assert.equal(out[0], tools[0], "same reference — not cloned");
+});
+
+// 7. Mixed: built-in + function tool → only function tool gets default
+run("Mixed: built-in kept, function tool defaulted", () => {
+ const tools = [
+ { type: "computer_use", name: "computer", display_width: 1024 },
+ { name: "search", description: "search the web", input_schema: {} },
+ { type: "web_search_20250305", name: "web_search" },
+ ];
+ const out = defaultClaudeToolType(tools);
+ assert.equal(out[0].type, "computer_use", "built-in preserved");
+ assert.equal(out[1].type, "custom", "function tool defaulted");
+ assert.equal(out[2].type, "web_search_20250305", "web_search preserved");
+});
+
+// 8. Non-array input → returned unchanged
+run("Non-array input returned unchanged", () => {
+ assert.equal(defaultClaudeToolType(null), null, "null returned as-is");
+ assert.equal(defaultClaudeToolType(undefined), undefined, "undefined returned as-is");
+ assert.equal(defaultClaudeToolType("not array"), "not array", "string returned as-is");
+});
+
+// 9. Empty array → empty array
+run("Empty array returns empty array", () => {
+ const out = defaultClaudeToolType([]);
+ assert.equal(Array.isArray(out), true, "returns array");
+ assert.equal(out.length, 0, "empty array");
+});
+
+// 10. Original tools not mutated by reference (new objects for defaulted tools)
+run("Original tools not mutated by reference", () => {
+ const original = { name: "foo", input_schema: {} };
+ const tools = [original];
+ defaultClaudeToolType(tools);
+ assert.equal(original.type, undefined, "original tool not mutated");
+ assert.ok(!("type" in original), "type property not added to original");
+});
+
+// 11. Array with null entry → defaults to { type: "custom" } (optional chaining guard)
+// tool?.type returns undefined for null, and { ...null, type: "custom" } === { type: "custom" }
+run("Array with null entry defaults to custom", () => {
+ const tools = [null];
+ const out = defaultClaudeToolType(tools);
+ assert.equal(out[0].type, "custom", "null tool gets type custom");
+ assert.equal(Object.keys(out[0]).length, 1, "no other keys from spread of null");
+});
+
+// Summary
+const passed = results.filter(r => r.ok).length;
+const total = results.length;
+for (const r of results) {
+ console.log(`${r.ok ? "ok" : "FAIL"} - ${r.name}${r.ok ? "" : ` :: ${r.err}`}`);
+}
+console.log(`\n${passed}/${total} checks passed`);
+if (passed !== total) process.exit(1);
diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js
index b6f16f1a..4f701b88 100644
--- a/open-sse/utils/sessionManager.js
+++ b/open-sse/utils/sessionManager.js
@@ -94,6 +94,7 @@ const MAX_CONTINUATION_SESSIONS = 5000;
// Client headers/body fields that carry an upstream session id (priority order)
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"];
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
+const CLAUDE_CODE_SESSION_HEADER = "x-claude-code-session-id";
function sha16(text) {
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
@@ -135,7 +136,10 @@ function extractAntigravitySession(body) {
}
function extractClientSessionId(headers, body, scope = "") {
- const claude = extractClaudeCodeSession(body?.metadata?.user_id);
+ // Claude Code sends the session in a header AND in metadata.user_id; the header
+ // survives translation to formats that drop metadata (e.g. Responses API).
+ const claude = extractClaudeCodeSession(body?.metadata?.user_id)
+ || headerValue(headers, CLAUDE_CODE_SESSION_HEADER);
if (claude) return `claude:${claude}`;
const antigravity = extractAntigravitySession(body);
if (antigravity) return `antigravity:${antigravity}`;
diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js
index 87681089..8fa8c11f 100644
--- a/open-sse/utils/stream.js
+++ b/open-sse/utils/stream.js
@@ -75,6 +75,35 @@ export function createSSEStream(options = {}) {
let openAIResponsesTerminalSeen = false;
let openAIResponsesDoneSent = false;
let streamDoneSent = false; // track duplicate [DONE] across transform + flush
+ let finalized = false;
+
+ // Usage/logging tail, callable from transform() as well as flush(): a client that
+ // closes right after the terminal event cancels the reader, and flush() never runs.
+ const finalizeStream = () => {
+ if (finalized) return;
+ finalized = true;
+
+ const isPassthrough = mode === STREAM_MODE.PASSTHROUGH;
+ let finalUsage = isPassthrough ? usage : state?.usage;
+
+ if (!hasValidUsage(finalUsage) && totalContentLength > 0) {
+ finalUsage = estimateUsage(body, totalContentLength, isPassthrough ? FORMATS.OPENAI : sourceFormat);
+ if (isPassthrough) usage = finalUsage; else state.usage = finalUsage;
+ }
+
+ if (hasValidUsage(finalUsage)) {
+ logUsage(isPassthrough ? provider : (state?.provider || targetFormat), finalUsage, model, connectionId, apiKey);
+ } else {
+ appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
+ }
+
+ if (onStreamComplete) {
+ onStreamComplete({
+ content: accumulatedContent,
+ thinking: accumulatedThinking
+ }, finalUsage, ttftAt);
+ }
+ };
return new TransformStream({
transform(chunk, controller) {
@@ -105,6 +134,7 @@ export function createSSEStream(options = {}) {
if (mode === STREAM_MODE.PASSTHROUGH) {
let output;
let injectedUsage = false;
+ let responsesTerminal = false;
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
try {
@@ -168,6 +198,8 @@ export function createSSEStream(options = {}) {
usage = mergeUsage(usage, extracted);
}
+ responsesTerminal = isOpenAIResponsesTerminalEvent(currentOpenAIResponsesEvent, parsed);
+
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
@@ -202,6 +234,8 @@ export function createSSEStream(options = {}) {
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(sharedEncoder.encode(output));
+ // Responses clients (codex CLI) close on response.completed instead of [DONE]
+ if (responsesTerminal) finalizeStream();
continue;
}
@@ -292,6 +326,8 @@ export function createSSEStream(options = {}) {
controller.enqueue(sharedEncoder.encode(output));
currentOpenAIResponsesEvent = null;
sseEmittedCount++;
+ // Responses clients (codex) close on response.completed instead of [DONE]
+ if (openAIResponsesTerminalSeen) finalizeStream();
continue;
}
@@ -353,16 +389,6 @@ export function createSSEStream(options = {}) {
controller.enqueue(sharedEncoder.encode(output));
}
- if (!hasValidUsage(usage) && totalContentLength > 0) {
- usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
- }
-
- if (hasValidUsage(usage)) {
- logUsage(provider, usage, model, connectionId, apiKey);
- } else {
- appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
- }
-
// IMPORTANT: In passthrough mode we still must terminate the SSE stream.
// Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel:
// data: [DONE]\n\n
@@ -375,18 +401,26 @@ export function createSSEStream(options = {}) {
controller.enqueue(sharedEncoder.encode(doneOutput));
}
- if (onStreamComplete) {
- onStreamComplete({
- content: accumulatedContent,
- thinking: accumulatedThinking
- }, usage, ttftAt);
- }
+ finalizeStream();
return;
}
if (buffer.trim()) {
- const parsed = parseSSELine(buffer.trim());
- if (parsed && !parsed.done) {
+ // Same parse as the transform loop: without targetFormat this only
+ // accepts "data: " lines, so an NDJSON provider (Ollama) lost whatever
+ // arrived without its closing newline.
+ const parsed = parseSSELine(buffer.trim(), targetFormat);
+ // parseSSELine turns the SSE sentinel "data: [DONE]" into { done: true },
+ // which must not be translated. An Ollama chunk also carries done:true,
+ // but it is the real final chunk — it holds finish_reason and the token
+ // counts — so it has to go through.
+ const isDoneSentinel = parsed?.done && targetFormat !== FORMATS.OLLAMA;
+ if (parsed && !isDoneSentinel) {
+ // Same accumulation the transform loop does, so finalizeStream() can
+ // log a tail chunk's tokens instead of falling back to null.
+ const extracted = extractUsage(parsed);
+ if (extracted) state.usage = mergeUsage(state.usage, extracted);
+
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
if (translated?._openaiIntermediate) {
@@ -442,24 +476,10 @@ export function createSSEStream(options = {}) {
streamDoneSent = true;
}
- if (!hasValidUsage(state?.usage) && totalContentLength > 0) {
- state.usage = estimateUsage(body, totalContentLength, sourceFormat);
- }
-
- if (hasValidUsage(state?.usage)) {
- logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey);
- } else {
- appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
- }
-
- if (onStreamComplete) {
- onStreamComplete({
- content: accumulatedContent,
- thinking: accumulatedThinking
- }, state?.usage, ttftAt);
- }
+ finalizeStream();
} catch (error) {
console.log("Error in flush:", error);
+ finalizeStream();
}
}
});
diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js
index e76dce90..6846c557 100644
--- a/open-sse/utils/streamHandler.js
+++ b/open-sse/utils/streamHandler.js
@@ -41,7 +41,8 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
if (disconnected) return;
disconnected = true;
- logStream("⚡", `DISCONNECT: ${reason}`);
+ // Debug-only: Responses API has no [DONE] sentinel, so codex/droid close the
+ // socket on every completed request. "📊 done" is the authoritative outcome line.
dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`);
// Delay abort to allow cleanup
diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js
index 24518ef3..0d66f6f3 100644
--- a/open-sse/utils/usageTracking.js
+++ b/open-sse/utils/usageTracking.js
@@ -190,7 +190,10 @@ export function canonicalizeUsage(usage) {
prompt = prompt + cached + cacheCreation;
} else {
// OpenAI/Gemini path (or already-canonical input): prompt already includes cached_tokens.
- cached = num(usage.cached_tokens);
+ // Mirror the cacheCreation fallback above: buildUsage() only ever emits the
+ // nested prompt_tokens_details.cached_tokens shape, so without this the
+ // cache-read count is silently dropped on every buildUsage()-derived usage.
+ cached = num(usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens);
}
const result = {
diff --git a/package.json b/package.json
index f931b4ea..b36fa38c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "9router-app",
- "version": "0.5.55",
+ "version": "0.5.59",
"description": "9Router web dashboard",
"private": true,
"scripts": {
diff --git a/public/i18n/literals/pt-BR.json b/public/i18n/literals/pt-BR.json
index a4186636..9c928d60 100644
--- a/public/i18n/literals/pt-BR.json
+++ b/public/i18n/literals/pt-BR.json
@@ -4,12 +4,14 @@
"(PXPIPE)": "(PXPIPE)",
"(Ponytail)": "(Ponytail)",
"(RTK)": "(RTK)",
+ "9Remote": "9Remote",
"9Router (Entry)": "9Router (Inicial)",
"API": "API",
"API Endpoint": "Endpoint da API",
"API Key": "Chave API",
"API Key (for Check)": "Chave API (para Verificação)",
"API Key Created": "Chave API Criada",
+ "API Key Name": "Nome da chave de API",
"API Keys": "Chaves de API",
"API Token": "Token de API",
"API Tokens": "Tokens de API",
@@ -23,6 +25,7 @@
"AWS region for your Identity Center (default: us-east-1)": "Região AWS para seu Identity Center (padrão: us-east-1)",
"About": "Sobre",
"Access token will be auto-filled...": "O token de acesso será preenchido automaticamente...",
+ "Access your terminal, desktop & files from anywhere": "Acesse seu terminal, desktop e arquivos de qualquer lugar",
"Account": "Conta",
"Account ID": "ID da Conta",
"Account Resources": "Recursos da Conta",
@@ -30,6 +33,7 @@
"Action": "Ação",
"Actions": "Ações",
"Activate": "Ativar",
+ "Activated": "Ativado",
"Active": "Ativo",
"Active All": "Ativar Todos",
"Active:": "Ativo:",
@@ -46,6 +50,7 @@
"Add Model to Combo": "Adicionar Modelo ao Combo",
"Add New Provider": "Adicionar Novo Provedor",
"Add OpenAI Compatible": "Adicionar Compatível com OpenAI",
+ "Add Pool": "Adicionar Pool",
"Add Provider": "Adicionar Provedor",
"Add Proxy Pool": "Adicionar Pool de Proxy",
"Add a connection to enable importing models.": "Adicione uma conexão para ativar a importação de modelos.",
@@ -55,6 +60,7 @@
"After PXPIPE": "Após PXPIPE",
"After authorization, copy the full URL from your browser address bar.": "Após a autorização, copie a URL completa da barra de endereço do navegador.",
"After installation, run": "Após a instalação, execute",
+ "Agent Skills": "Agent Skills",
"All": "Todos",
"All AI Providers": "Todos os Provedores de IA",
"All Providers": "Todos os Provedores",
@@ -70,6 +76,7 @@
"Apply Proxy": "Aplicar Proxy",
"Applying...": "Aplicando...",
"Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?",
+ "Audio": "Áudio",
"Audio File": "Arquivo de Áudio",
"Auth Mode": "Modo de Autenticação",
"Authenticate": "Autenticar",
@@ -100,6 +107,7 @@
"Batch Size": "Tamanho do lote",
"Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "Tendenciar o modelo para código mínimo: YAGNI, reutilizar stdlib, deletar ao invés de adicionar",
"Binary File": "Arquivo Binário",
+ "Browse & edit files": "Navegue e edite arquivos",
"Browse MCP Marketplace": "Explorar Marketplace MCP",
"Browse source, README, and examples.": "Navegue pelo código fonte, README e exemplos.",
"Browser Control (Browser MCP)": "Controle do Navegador (Browser MCP)",
@@ -109,6 +117,7 @@
"Cache Creation": "Criação de Cache",
"Cache Creation:": "Criação de Cache:",
"Cached": "Em Cache",
+ "Cached Cost": "Custo em cache",
"Cached Tokens": "Tokens em Cache",
"Cached Tokens:": "Tokens em Cache:",
"Cached:": "Em Cache:",
@@ -118,6 +127,7 @@
"Capacity auto-switch": "Troca automática de capacidade",
"Cert": "Certificado",
"Change Log": "Registro de Alterações",
+ "Change the default dashboard password before activating the tunnel.": "Altere a senha padrão do painel antes de ativar o túnel.",
"Changelog": "Registro de Alterações",
"Chat": "Chat",
"Chat / code-gen via OpenAI or Anthropic format with streaming.": "Chat / geração de código via formato OpenAI ou Anthropic com streaming.",
@@ -172,6 +182,8 @@
"Codex CLI - Manual Configuration": "Codex CLI - Configuração Manual",
"Codex CLI not detected locally": "Codex CLI não detectado localmente",
"Codex Reset Credit Expiry": "Redefinir Expiração de Crédito do Codex",
+ "Combo": "Combo",
+ "Combo & Vision Adapter": "Combo e Adaptador de Visão",
"Combo Name": "Nome do Combo",
"Combo Round Robin": "Combo Round Robin",
"Combo Sticky Limit": "Limite Fixo do Combo",
@@ -181,6 +193,7 @@
"Company": "Empresa",
"Compress LLM output": "Comprimir saída do LLM",
"Compress context": "Comprimir contexto",
+ "Compress prompts and outputs to save tokens": "Comprimir prompts e saídas para economizar tokens",
"Compress prompts as images": "Comprimir prompts como imagens",
"Compress prompts via /v1/compress before routing to the model": "Comprimir prompts via /v1/compress antes de rotear para o modelo",
"Compress tool output": "Comprimir saída da ferramenta",
@@ -199,6 +212,7 @@
"Connect Cursor IDE": "Conectar Cursor IDE",
"Connect GitLab Duo": "Conectar GitLab Duo",
"Connect Kiro": "Conectar Kiro",
+ "Connect to providers with OAuth to track your API quota limits and usage.": "Conectar-se aos provedores via OAuth para acompanhar seus limites de cota e uso da API.",
"Connect with OAuth2": "Conectar com OAuth2",
"Connect your account using OAuth2 authentication.": "Conecte sua conta usando autenticação OAuth2.",
"Connected": "Conectado",
@@ -208,6 +222,7 @@
"Connections": "Conexões",
"Console Log": "Log do console",
"Content": "Conteúdo",
+ "Context window": "Janela de contexto",
"Continue": "Continuar",
"Continue to summary": "Continuar para resumo",
"Continue with GitHub": "Continuar com GitHub",
@@ -218,6 +233,7 @@
"Copied!": "Copiado!",
"Copy": "Copiar",
"Copy This URL": "Copiar Esta URL",
+ "Copy a link and paste to your AI to use 9Router — no install needed": "Copiar um link e cole no seu IA para usar o 9Router — sem precisar instalar",
"Copy combo name": "Copiar nome do combo",
"Copy install command": "Copiar comando de instalação",
"Copy link": "Copiar link",
@@ -232,6 +248,7 @@
"Create Combo": "Criar Combo",
"Create Cowork Combo": "Criar Combo Cowork",
"Create Key": "Criar Chave",
+ "Create Pool": "Criar Pool",
"Create Provider": "Criar Provedor",
"Create Token": "Criar Token",
"Create a": "Criar um(a)",
@@ -263,6 +280,7 @@
"Date": "Data",
"DateTime": "Data e Hora",
"Deactivate": "Desativar",
+ "Deactivated": "Desativado",
"Debug": "Depuração",
"Debug translation flow between formats": "Depurar fluxo de tradução entre formatos",
"DeepSeek TUI - Manual Configuration": "DeepSeek TUI - Configuração Manual",
@@ -270,6 +288,8 @@
"Default": "Padrão",
"Default Model": "Modelo Padrão",
"Delete": "Excluir",
+ "Delete Proxy Pool": "Excluir Pool de Proxy",
+ "Delete Proxy Pools": "Excluir Pools de Proxy",
"Delete connection": "Excluir conexão",
"Delete saved endpoint": "Excluir endpoint salvo",
"Delete selected preset": "Excluir predefinição selecionada",
@@ -284,11 +304,13 @@
"Deploy multiple relays on different accounts for more IP diversity": "Implantar múltiplos relays em contas diferentes para mais diversidade de IP",
"Deployment Name": "Nome da Implantação",
"Description": "Descrição",
+ "Desktop": "Desktop",
"Detail": "Detalhe",
"Details": "Detalhes",
"Dimensions": "Dimensões",
"Disable": "Desativar",
"Disable All": "Desativar Todos",
+ "Disable Dead Proxies": "Desativar Proxies Inativos",
"Disable Tailscale": "Desativar Tailscale",
"Disable Tunnel": "Desativar Túnel",
"Disable connections with depleted quota on the current page": "Desativar conexões com cota esgotada na página atual",
@@ -312,8 +334,10 @@
"Edit connection": "Editar conexão",
"Edit hosts file manually to add the following entries:": "Edite o arquivo hosts manualmente para adicionar as seguintes entradas:",
"Email": "E-mail",
+ "Embedding": "Embedding",
"Embeddings": "Embeddings",
"Enable": "Ativar",
+ "Enable \"Require login\" and set a custom password before activating the tunnel.": "Ativar a opção \"Exigir login\" e defina uma senha personalizada antes de ativar o túnel.",
"Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação",
"Enable Observability": "Ativar observabilidade",
"Enable Tunnel": "Ativar Túnel",
@@ -331,6 +355,7 @@
"Enter password": "Digite a senha",
"Enter sudo password": "Digite a senha sudo",
"Enter the model ID exactly as your compatible endpoint expects it.": "Digite o ID do modelo exatamente como seu endpoint compatível espera.",
+ "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.": "Digite o ID do modelo exatamente como seu endpoint compatível espera. Este modelo será salvo como padrão da conexão.",
"Enter your API key": "Digite sua chave de API",
"Enter your password to access the dashboard": "Digite sua senha para acessar o painel",
"Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM",
@@ -351,12 +376,21 @@
"Factory Droid CLI not detected locally": "Factory Droid CLI não detectado localmente",
"Fail request if proxy is unreachable instead of falling back to direct.": "Falhar requisição se o proxy estiver inacessível ao invés de cair para direto.",
"Failed": "Falha",
+ "Failed to delete proxy pool": "Falha ao excluir o pool de proxy",
+ "Failed to fetch chart data:": "Falha ao buscar dados do gráfico:",
+ "Failed to fetch quota": "Falha ao buscar a cota",
"Failed to load usage statistics.": "Falha ao carregar estatísticas de uso.",
+ "Failed to reset Codex limit": "Falha ao redefinir o limite do Codex",
+ "Failed to save proxy pool": "Falha ao salvar o pool de proxy",
"Failed to start proxy": "Falha ao iniciar proxy",
"Failed to start server": "Falha ao iniciar o servidor",
"Failed to stop server": "Falha ao parar o servidor",
+ "Failed to test proxy": "Falha ao testar o proxy",
+ "Failed to update active state": "Falha ao atualizar o estado ativo",
"Fallback": "Fallback",
+ "Fallback — try in order": "Fallback — tentar em ordem",
"Features": "Recursos",
+ "Files": "Arquivos",
"Filter": "Filtrar",
"Filter accounts by status": "Filtrar contas por status",
"Filter naming": "Filtrar nomenclatura",
@@ -374,7 +408,9 @@
"Free tier: 100,000 requests per day": "Camada gratuita: 100.000 requisições por dia",
"Free tier: 100GB bandwidth/month, 500K edge invocations": "Camada gratuita: 100GB largura de banda/mês, 500K invocações edge",
"Fresh API key obtained": "Nova chave de API obtida",
+ "Full shell access": "Acesso completo ao shell",
"Fusion": "Fusão",
+ "Fusion — panel + judge": "Fusion — painel + juiz",
"General": "Geral",
"Get 9Remote": "Obter 9Remote",
"Get API Key": "Obter Chave de API",
@@ -415,6 +451,7 @@
"How to get cookie:": "Como obter o cookie:",
"ID:": "ID:",
"Image Generation": "Geração de Imagens",
+ "Image to Text": "Imagem para Texto",
"Images": "Imagens",
"Import": "Importar",
"Import Backup": "Importar backup",
@@ -426,6 +463,7 @@
"Info": "Informações",
"Initializing...": "Inicializando...",
"Input": "Entrada",
+ "Input Cost": "Custo de entrada (input)",
"Input Tokens": "Tokens de Entrada",
"Input Tokens:": "Tokens de Entrada:",
"Input:": "Entrada:",
@@ -449,9 +487,12 @@
"Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router",
"Invalid": "Inválido",
"Issuer URL": "URL do Emissor",
+ "JSON (Base64)": "JSON (Base64)",
"JSON Response": "Resposta JSON",
"Join developers who are streamlining their AI integrations with 9Router.": "Junte-se aos desenvolvedores que estão otimizando suas integrações de IA com o 9Router.",
+ "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "Junte-se aos desenvolvedores que estão otimizando suas integrações de IA com o 9Router. Código aberto e gratuito para começar.",
"Judge": "Julgador",
+ "Just now": "Agora mesmo",
"KeepAlive": "KeepAlive",
"Key Name": "Nome da Chave",
"Kill this process to start MITM Server?": "Encerrar este processo para iniciar o Servidor MITM?",
@@ -462,6 +503,7 @@
"Label": "Rótulo",
"Language": "Idioma",
"Last Page": "Última Página",
+ "Last Used": "Último uso",
"Latency": "Latência",
"Latency:": "Latência:",
"Lazy senior dev": "Dev sênior preguiçoso",
@@ -469,6 +511,7 @@
"Leave blank to keep existing secret": "Deixe em branco para manter o segredo existente",
"Leave empty for public PKCE app": "Deixe vazio para app PKCE público",
"Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).",
+ "Legacy manual proxy fields are still accepted by API for backward compatibility.": "Campos de proxy manual legados ainda são aceitos pela API para compatibilidade inversa.",
"Legal": "Legal",
"Light": "Claro",
"Live server console output": "Saída do console do servidor ao vivo",
@@ -497,11 +540,23 @@
"MITM": "MITM",
"MITM Server": "Servidor MITM",
"MITM Tools": "Ferramentas MITM",
+ "MP3 (Binary)": "MP3 (Binário)",
"Machine ID will be auto-filled...": "O ID da máquina será preenchido automaticamente...",
"Main Model": "Modelo Principal",
"Manage": "Gerenciar",
"Manage your AI provider connections": "Gerencie suas conexões de provedor de IA",
+ "Manage your Embedding providers": "Gerencie seus provedores de Embedding",
+ "Manage your Image to Text providers": "Gerencie seus provedores de Imagem para Texto",
+ "Manage your Music providers": "Gerencie seus provedores de Música",
+ "Manage your Speech To Text providers": "Gerencie seus provedores de Fala para Texto",
+ "Manage your Text To Speech providers": "Gerencie seus provedores de Texto para Fala",
+ "Manage your Text to Image providers": "Gerencie seus provedores de Texto para Imagem",
+ "Manage your Video providers": "Gerencie seus provedores de Vídeo",
+ "Manage your Web Fetch providers": "Gerencie seus provedores de Fetch Web",
+ "Manage your Web Search providers": "Gerencie seus provedores de Busca Web",
"Manage your preferences": "Gerenciar suas preferências",
+ "Manage your proxy pool configurations": "Gerencie suas configurações de pools de proxy",
+ "Manage your web providers": "Gerencie seus provedores web",
"Manual / current endpoint": "Endpoint manual / atual",
"Manual Callback Required": "Callback Manual Necessário",
"Manual Config": "Configuração Manual",
@@ -533,25 +588,32 @@
"More on GitHub": "Mais no GitHub",
"Move down": "Mover para baixo",
"Move up": "Mover para cima",
+ "Music": "Música",
"My Profile": "Meu Perfil",
+ "N/A": "N/D",
"NPM": "NPM",
"Name": "Nome",
"Navigate to home": "Navegar para início",
"Network": "Rede",
+ "Never": "Nunca",
+ "Never tested": "Nunca testado",
"New": "Novo",
"New Password": "Nova senha",
"New password": "Nova senha",
"Next": "Próximo",
"Next accounts page": "Próxima página de contas",
"No": "Não",
+ "No API key usage recorded yet.": "Nenhum uso de chave de API registrado ainda.",
"No API keys yet": "Nenhuma chave de API ainda",
"No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves",
"No MCPs added": "Nenhum MCP adicionado",
"No PXPIPE activity yet": "Nenhuma atividade PXPIPE ainda",
"No Providers Connected": "Nenhum Provedor Conectado",
"No Proxy": "Sem proxy",
+ "No account-specific usage recorded yet.": "Nenhum uso específico de conta registrado ainda.",
"No active connections found for this group.": "Nenhuma conexão ativa encontrada para este grupo.",
"No active proxy pools available.": "Nenhum pool de proxy ativo disponível.",
+ "No active proxy pools available. Create one in Proxy Pools page first.": "Nenhum pool de proxy ativo disponível. Crie um na página Pools de Proxy primeiro.",
"No authentication required": "Nenhuma autenticação necessária",
"No combos yet": "Nenhum combo ainda",
"No combos yet.": "Nenhum combo ainda.",
@@ -560,6 +622,7 @@
"No console logs yet.": "Nenhum log de console ainda.",
"No conversations yet.": "Nenhuma conversa ainda.",
"No data for this period": "Nenhum dado para este período",
+ "No endpoint usage recorded yet.": "Nenhum uso de endpoint registrado ainda.",
"No install log yet.": "Nenhum log de instalação ainda.",
"No key configured": "Nenhuma chave configurada",
"No language selected": "Nenhum idioma selecionado",
@@ -570,9 +633,11 @@
"No models found": "Nenhum modelo encontrado",
"No models selected": "Nenhum modelo selecionado",
"No per-request CPU time limits (unlike Vercel/Cloudflare)": "Sem limites de tempo de CPU por requisição (diferente de Vercel/Cloudflare)",
+ "No port forwarding needed": "Sem necessidade de redirecionamento de porta",
"No pricing data available": "Nenhum dado de preço disponível",
"No providers connected": "Nenhum provedor conectado",
"No providers match your search": "Nenhum provedor corresponde à sua pesquisa",
+ "No providers support": "Nenhum provedor suporta",
"No providers yet.": "Nenhum provedor ainda.",
"No providers.": "Nenhum provedor.",
"No proxy pool entries yet": "Nenhuma entrada no pool de proxy ainda",
@@ -582,6 +647,8 @@
"No reset credit details returned for this account.": "Nenhum detalhe de crédito de redefinição retornado para esta conta.",
"No servers match filter": "Nenhum servidor corresponde ao filtro",
"No tools advertised by server.": "Nenhuma ferramenta anunciada pelo servidor.",
+ "No usage recorded yet": "Nenhum uso registrado ainda",
+ "No usage recorded yet.": "Nenhum uso registrado ainda.",
"No usage yet.": "Nenhum uso ainda.",
"None": "Nenhum",
"None (unbind all)": "Nenhum (desvincular todos)",
@@ -595,17 +662,21 @@
"OAuth Providers": "Provedores OAuth",
"OIDC": "OIDC",
"OIDC Dashboard Login": "Login no Painel via OIDC",
+ "OIDC login is currently active. Password login is disabled until you switch back.": "O login OIDC está ativo no momento. O login por senha está desabilitado até você voltar.",
"OK": "OK",
"Observability": "Observabilidade",
"Office Proxy": "Proxy de Escritório",
"Offline": "Offline",
"Ollama Host URL": "URL do Host Ollama",
+ "One PAT per line. Format:": "Um PAT por linha. Formato:",
"One key per line. Format:": "Uma chave por linha. Formato:",
"One-to-one (rotate)": "Um-para-um (rotacionar)",
"Online": "Online",
"Only from connected providers": "Apenas de provedores conectados",
"Only letters, numbers, -, _ and .": "Apenas letras, números, -, _ e .",
+ "Only letters, numbers, -, _ and . allowed": "Apenas letras, números, -, _ e . permitidos",
"Open": "Abrir",
+ "Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.": "Abra Claude Desktop → Ajuda → Solução de Problemas → Ative o Modo Desenvolvedor → Configure inferência de terceiros e depois volte aqui.",
"Open Claw - Manual Configuration": "Open Claw - Configuração Manual",
"Open Claw CLI not detected locally": "Open Claw CLI não detectado localmente",
"Open Dashboard": "Abrir Painel",
@@ -613,6 +684,8 @@
"Open Headroom Dashboard": "Abrir Painel Headroom",
"Open Logs": "Abrir Logs",
"Open menu": "Abrir menu",
+ "Open platform.iflow.cn in your browser": "Abra platform.iflow.cn no seu navegador",
+ "Open settings": "Abrir configurações",
"Open source": "Código aberto",
"Open source and free to start.": "Código aberto e gratuito para começar.",
"OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "Vozes OpenAI / ElevenLabs / Edge / Google / Deepgram.",
@@ -634,10 +707,12 @@
"Out": "Saída",
"Outbound Proxy": "Proxy de saída",
"Output": "Saída",
+ "Output Cost": "Custo de saída (output)",
"Output Format": "Formato de Saída",
"Output Tokens": "Tokens de Saída",
"Output Tokens:": "Tokens de Saída:",
"Output:": "Saída:",
+ "Overview": "Visão geral",
"PATH": "PATH",
"PXPIPE": "PXPIPE",
"PXPIPE Dashboard": "Painel PXPIPE",
@@ -650,16 +725,20 @@
"Paid": "Pago",
"Partial preview": "Visualização parcial",
"Password": "Senha",
+ "Password and OIDC login are both active.": "O login por senha e OIDC estão ambos ativos.",
+ "Password and OIDC login are both enabled.": "O login por senha e OIDC estão ambos habilitados.",
"Password updated successfully": "Senha atualizada com sucesso",
"Passwords do not match": "As senhas não correspondem",
"Paste": "Colar",
"Paste Proxy List (One per line)": "Cole a Lista de Proxy (um por linha)",
+ "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "Cole o JSON de autenticação external_idp do login CLIProxyAPI/Kiro Microsoft.",
"Paste it below": "Cole abaixo",
"Paste refresh token from Kiro IDE.": "Cole o token de atualização do Kiro IDE.",
"Paste the command into your terminal and press Enter.": "Cole o comando no terminal e pressione Enter.",
"Paste this to your AI:": "Cole isto em sua IA:",
"Paste your Kiro API key...": "Cole sua chave de API Kiro...",
"Paused": "Pausado",
+ "Period": "Período",
"Permissions": "Permissões",
"Personal Access Token": "Token de Acesso Pessoal",
"Pick the model that fuses panel answers": "Escolha o modelo que funde as respostas do painel",
@@ -667,11 +746,13 @@
"Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar",
"Please wait while we complete the authorization.": "Aguarde enquanto concluímos a autorização.",
"Point your CLI tools to http://localhost:20128": "Aponte suas ferramentas CLI para http://localhost:20128",
+ "Popup was blocked. After authorizing in the browser, paste the full callback URL here:": "O pop-up foi bloqueado. Depois de autorizar no navegador, cole a URL completa de callback aqui:",
"Port 443 Already In Use": "Porta 443 já está em uso",
"Port 443 is currently used by another process:": "A porta 443 está sendo usada por outro processo:",
"Powerful Features": "Recursos Poderosos",
"Prefix": "Prefixo",
"Preset": "Predefinição",
+ "Prev": "Ant",
"Previous": "Anterior",
"Previous accounts page": "Página anterior de contas",
"Price": "Preço",
@@ -699,14 +780,20 @@
"Proxy URL": "URL do Proxy",
"Proxy disabled": "Proxy desativado",
"Proxy enabled": "Proxy ativado",
+ "Proxy pool created": "Pool de proxy criado",
+ "Proxy pool deleted": "Pool de proxy excluído",
+ "Proxy pool updated": "Pool de proxy atualizado",
"Proxy settings applied": "Configurações de proxy aplicadas",
"Proxy test OK": "Teste de proxy OK",
"Proxy test failed": "Falha no teste de proxy",
+ "Proxy test passed": "Teste de proxy aprovado",
"Purpose:": "Propósito:",
"Python >= 3.10 required for local managed mode.": "Python >= 3.10 necessário para modo gerenciado local.",
"Quota Tracker": "Rastreador de cota",
"Read Documentation": "Ler Documentação",
"Read this skill and use it:": "Leia esta skill e use-a:",
+ "Reading from AWS SSO cache": "Lendo do cache AWS SSO",
+ "Reading from Cursor IDE database": "Lendo do banco de dados do IDE Cursor",
"Ready": "Pronto",
"Ready to Simplify Your AI Infrastructure?": "Pronto para Simplificar Sua Infraestrutura de IA?",
"Reasoning": "Raciocínio",
@@ -714,6 +801,7 @@
"Recent Requests": "Requisições Recentes",
"Recent chats": "Chats recentes",
"Recheck": "Verificar novamente",
+ "Recommended for most users. Free AWS account required.": "Recomendado para a maioria dos usuários. Conta AWS gratuita necessária.",
"Record request details for inspection in the logs view": "Gravar detalhes da requisição para inspeção na visualização de logs",
"Redirect URI": "URI de Redirecionamento",
"Redo": "Refazer",
@@ -749,6 +837,7 @@
"Required for SSL certificate and server startup": "Necessário para certificado SSL e inicialização do servidor",
"Required to modify /etc/hosts and flush DNS cache": "Necessário para modificar /etc/hosts e limpar cache DNS",
"Requires Cloudflare Account ID and a Workers API Token": "Requer ID da Conta Cloudflare e um Token de API Workers",
+ "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "Requer ID da Conta Cloudflare e Token de API Workers (permissão Editar Workers)",
"Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "Requer porta de saída 7844 (TCP/UDP). Conexão pode levar 10-30s.",
"Reset": "Redefinir",
"Reset Codex limit?": "Redefinir limite do Codex?",
@@ -764,8 +853,11 @@
"Restore model": "Restaurar modelo",
"Retry": "Tentar novamente",
"Risk Notice": "Aviso de Risco",
+ "Rotate providers across requests instead of strict fallback order.": "Rotacione provedores nas requisições em vez de estrita ordem de fallback.",
"Rotation Strategy": "Estratégia de Rotação",
+ "Round": "Rodada",
"Round Robin": "Round Robin",
+ "Round Robin — rotate": "Round Robin — rotacionar",
"Route Requests": "Rotear Requisições",
"Routing Strategy": "Estratégia de roteamento",
"Rows:": "Linhas:",
@@ -782,7 +874,9 @@
"Save current Base URL and API key as a browser-local preset": "Salvar URL Base e chave de API atuais como predefinição local do navegador",
"Save this key now!": "Salve esta chave agora!",
"Saved": "Salvo",
+ "Scan QR to connect instantly": "Escaneie o QR para conectar instantaneamente",
"Scopes": "Escopos",
+ "Screen sharing": "Compartilhamento de tela",
"Scroll down to": "Role para baixo até",
"Search": "Pesquisar",
"Search by name or description...": "Pesquisar por nome ou descrição...",
@@ -791,6 +885,9 @@
"Search providers...": "Pesquisar provedores...",
"Search...": "Pesquisar...",
"Security": "Segurança",
+ "Security required: Change the default dashboard password before activating the tunnel.": "Segurança necessária: altere a senha padrão do painel antes de ativar o túnel.",
+ "Security required: Enable \"Require API key\" before activating the tunnel.": "Segurança necessária: ative a opção \"Exigir chave de API\" antes de ativar o túnel.",
+ "Security required: Enable \"Require login\" and set a custom password before activating the tunnel.": "Segurança necessária: ative a opção \"Exigir login\" e defina uma senha personalizada antes de ativar o túnel.",
"Security risk: no password set.": "Risco de segurança: nenhuma senha definida.",
"Security risk: no password set. You will be asked to set one when logging in remotely.": "Risco de segurança: nenhuma senha definida. Será solicitado que você defina uma ao fazer login remotamente.",
"Select": "Selecionar",
@@ -832,10 +929,13 @@
"Show this quota row": "Mostrar esta linha de cota",
"Shutdown": "Desligar",
"Sign in with OIDC": "Entrar com OIDC",
+ "Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!": "Interface de chat simples para interagir com qualquer modelo de IA dos provedores conectados. Selecione um modelo e comece a conversar!",
"Single": "Único",
+ "Skills": "Skills",
"Sort": "Classificar",
"Sort Codex quotas by remaining": "Ordenar cotas do Codex por saldo restante",
"Sort accounts by earliest quota reset time": "Ordenar contas pelo horário de redefinição de cota",
+ "Speech To Text": "Fala para Texto",
"Speech-to-Text": "Fala-para-Texto",
"StandardErrorPath": "Caminho do Erro Padrão",
"StandardOutPath": "Caminho da Saída Padrão",
@@ -879,10 +979,12 @@
"Tailscale": "Tailscale",
"Tailscale Funnel": "Tailscale Funnel",
"Tailscale Funnel will be stopped.": "O Tailscale Funnel será parado.",
+ "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "O Tailscale Funnel será parado. O acesso remoto via URL Tailscale parará de funcionar.",
"Tailscale installed": "Tailscale instalado",
"Tailscale is not installed. Install it to enable Funnel.": "Tailscale não está instalado. Instale para ativar o Funnel.",
"Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.",
"Temperature": "Temperatura",
+ "Terminal": "Terminal",
"Terms of Service": "Termos de Serviço",
"Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "Prompt de sistema conciso → ~65% menos tokens de saída (até 87%)",
"Test Example": "Exemplo de Teste",
@@ -894,29 +996,46 @@
"Test connection": "Testar conexão",
"Test proxy": "Testar proxy",
"Test proxy URL": "Testar URL do proxy",
+ "Text To Speech": "Texto para Fala",
+ "Text to Image": "Texto para Imagem",
"Text-to-Speech": "Texto-para-Fala",
"Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Texto-para-imagem via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…",
"The Cloudflare tunnel will be disconnected.": "O túnel Cloudflare será desconectado.",
+ "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "O túnel Cloudflare será desconectado. O acesso remoto via URL do túnel deixará de funcionar.",
"The proxy server has been stopped.": "O servidor proxy foi parado.",
+ "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "A requisição é atendida por OpenAI, Anthropic, Gemini ou outros instantaneamente.",
"The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "O endpoint unificado para geração de IA. Conecte, roteie e gerencie seus provedores de IA com facilidade.",
"The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "A interface unificada para infraestrutura de IA moderna. Segura, observável e escalável.",
"Theme": "Tema",
"Thinking Process": "Processo de Raciocínio",
"This is the only time you will see this key. Store it securely.": "Esta é a única vez que você verá esta chave. Armazene-a com segurança.",
+ "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "Este provedor está pronto para uso. Opcionalmente, envie requisições por um pool de proxy para burlar limites por IP.",
+ "This value is write-only after saving.": "Este valor é somente gravação após salvar.",
"Time": "Hora",
"Timestamp": "Timestamp",
"Timestamp:": "Timestamp:",
+ "Today": "Hoje",
"Toggle auto-ping": "Alternar ping automático",
"Token Saver": "Economizador de Tokens",
"Token Saver settings": "Configurações do Economizador de Tokens",
"Token Types:": "Tipos de Token:",
+ "Token auto-detected from Kiro IDE successfully!": "Token auto-detectado do Kiro IDE com sucesso!",
+ "Token is used once for deployment and not stored.": "O token é usado uma vez para implantação e não é armazenado.",
+ "Token is used once for deployment, not stored. Found in Organization Settings.": "O token é usado uma vez para implantação, não armazenado. Encontrado nas Configurações da Organização.",
+ "Token savings (estimated)": "Economia de tokens (estimada)",
+ "Token will be auto-filled...": "O token será preenchido automaticamente...",
"Tokens": "Tokens",
+ "Tokens auto-detected from Cursor IDE successfully!": "Tokens auto-detectados do IDE Cursor com sucesso!",
+ "Tool not found or disabled.": "Ferramenta não encontrada ou desabilitada.",
"Tools": "Ferramentas",
"Total": "Total",
+ "Total Cost": "Custo total",
"Total Input Tokens": "Total de Tokens de Entrada",
"Total Models": "Total de Modelos",
"Total Requests": "Total de Requisições",
+ "Total Tokens": "Total de tokens",
"Total:": "Total:",
+ "Track and manage your API quota limits": "Acompanhe e gerencie seus limites de cota da API",
"Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "Transcreva áudio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…",
"Translator Debug": "Depuração do Tradutor",
"Tried in order (top-down) or rotated when round-robin is on.": "Tentado em ordem (de cima para baixo) ou rotacionado quando round-robin está ativo.",
@@ -934,6 +1053,11 @@
"Undo": "Desfazer",
"Uninstall": "Desinstalar",
"Uninstalling…": "Desinstalando…",
+ "Unknown": "Desconhecido",
+ "Unknown Account": "Conta desconhecida",
+ "Unknown Endpoint": "Endpoint desconhecido",
+ "Unknown Key": "Chave desconhecida",
+ "Unknown Model": "Modelo desconhecido",
"Update": "Atualizar",
"Update 9Router": "Atualizar 9Router",
"Update Password": "Atualizar senha",
@@ -943,19 +1067,29 @@
"Uptime": "Tempo de atividade",
"Usage": "Uso",
"Usage Logs": "Logs de Uso",
+ "Usage by API Key": "Uso por chave de API",
+ "Usage by Account": "Uso por conta",
+ "Usage by Endpoint": "Uso por endpoint",
+ "Usage by Model": "Uso por modelo",
"Usage:": "Uso:",
"Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router",
"Use a GitLab OAuth application": "Usar um aplicativo OAuth GitLab",
"Use a GitLab PAT with api scope": "Usar um PAT GitLab com escopo de API",
+ "Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.": "Use uma chave de API xAI direta de console.x.ai. Isso é separado do OAuth do Grok Build.",
+ "Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.": "Use um proxy local para Iniciar/Parar, ou um sidecar Docker externo como http://headroom:8787.",
+ "Use a long-lived Kiro/CodeWhisperer API key (headless auth).": "Use uma chave de API Kiro/CodeWhisperer duradoura (autenticação headless).",
"Valid": "Válido",
"Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vetores para RAG / busca semântica via OpenAI, Gemini, Mistral…",
"Vercel API Token": "Token de API Vercel",
"Vercel Relay": "Vercel Relay",
"Version": "Versão",
+ "Video": "Vídeo",
"View": "Visualizar",
"View Codex reset credit expiry": "Ver expiração de crédito do Codex",
"View Full Details": "Ver Detalhes Completos",
"View on GitHub": "Ver no GitHub",
+ "Vision": "Visão",
+ "Vision Adapter": "Adaptador de Visão",
"Visit the login URL below and authorize:": "Visite a URL de login abaixo e autorize:",
"Voice": "Voz",
"Voice ID": "ID de Voz",
@@ -963,6 +1097,7 @@
"Waiting for authorization...": "Aguardando autorização...",
"Warning": "Aviso",
"Web Fetch": "Fetch Web",
+ "Web Fetch & Search": "Web Fetch e Busca",
"Web Search": "Busca Web",
"What is Cloudflare Relay?": "O que é Cloudflare Relay?",
"What is Deno Relay?": "O que é Deno Relay?",
@@ -971,13 +1106,16 @@
"When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.",
"Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows: Execute o terminal (9Router) como Administrador para ativar MITM",
"Worker Name": "Nome do Worker",
+ "Works on any device": "Funciona em qualquer dispositivo",
"Writes to": "Grava em",
"Yes": "Sim",
"You will be asked to set one when logging in remotely.": "Será solicitado que você defina uma ao fazer login remotamente.",
"Your Account Name": "Nome da Sua Conta",
"Your Code": "Seu Código",
"Your OAuth application client ID": "ID do cliente do seu aplicativo OAuth",
+ "Your model can't read image/audio? Auto-switches to a model in the pool below.": "Seu modelo não lê imagem/áudio? Alterna automaticamente para um modelo do pool abaixo.",
"Your requests start from your favorite tools or our unified SDK.": "Suas requisições começam de suas ferramentas favoritas ou do nosso SDK unificado.",
+ "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "Suas requisições começam das suas ferramentas favoritas ou do nosso SDK unificado. Apenas mude a URL base.",
"[ml] downloads ~1 GB (torch + huggingface-hub). Continue?": "[ml] baixa ~1 GB (torch + huggingface-hub). Continuar?",
"e.g. a warm, gentle voice, speaking slowly with a British accent": "ex.: voz quente e suave, falando devagar com sotaque britânico",
"extras status failed": "falha no status dos extras",
@@ -985,6 +1123,12 @@
"not installed": "não instalado",
"sk_9router (default)": "sk_9router (padrão)",
"tree-sitter AST compression for code responses": "Compressão AST tree-sitter para respostas de código",
+ "web": "web",
+ "— audio input": "— entrada de áudio",
+ "— images (png, jpg, webp, …)": "— imagens (png, jpg, webp, …)",
+ "— queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "— consulta todos os modelos em paralelo e um juiz sintetiza uma resposta. Melhor qualidade, porém o maior custo: cada requisição cobra todos os modelos do painel + o juiz (N+1 chamadas)",
+ "— rotates models across requests to spread load": "— rotaciona os modelos entre as requisições para distribuir a carga",
+ "— tries models in order (next on failure)": "— tenta os modelos em ordem (próximo em caso de falha)",
"⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercepta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar solicitações aos seus provedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco.",
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco."
}
diff --git a/public/providers/xquik.png b/public/providers/xquik.png
new file mode 100644
index 00000000..8f359751
Binary files /dev/null and b/public/providers/xquik.png differ
diff --git a/skills/9router-web-search/SKILL.md b/skills/9router-web-search/SKILL.md
index ebd3003d..afb0adea 100644
--- a/skills/9router-web-search/SKILL.md
+++ b/skills/9router-web-search/SKILL.md
@@ -1,6 +1,6 @@
---
name: 9router-web-search
-description: Web search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity. Use when the user wants to search the web, look up information, find articles, or query a search engine.
+description: Web and X search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity / Xquik. Use when the user wants to search the web, find articles, or search public X posts.
---
# 9Router — Web Search
@@ -26,7 +26,7 @@ IDs end in `/search` (e.g. `tavily/search`). Combos (`owned_by:"combo"`) chain p
| `model` (or `provider`) | yes | from `/v1/models/web` (e.g. `tavily` or `brave`) |
| `query` | yes | search query |
| `max_results` | no | default 5 |
-| `search_type` | no | `web` (default) / `news` |
+| `search_type` | no | `web` (default) / `news` / `x` for Xquik |
| `country`, `language`, `time_range`, `domain_filter` | no | provider-dependent |
## Examples
@@ -49,6 +49,26 @@ const r = await fetch(`${process.env.NINEROUTER_URL}/v1/search`, {
console.log(await r.json());
```
+X search with Xquik:
+
+```bash
+curl -X POST $NINEROUTER_URL/v1/search \
+ -H "Authorization: Bearer $NINEROUTER_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"model":"xquik","query":"from:github release","max_results":10,"provider_options":{"queryType":"Latest"}}'
+```
+
+Add the Xquik API key in 9Router's provider settings. Xquik charges 1 credit per returned post. Continue a search by passing `pagination.next_cursor` as `provider_options.cursor`.
+
+Xquik responses include provider pagination and credit usage:
+
+```json
+{
+ "pagination": { "has_more": true, "next_cursor": "cursor-2" },
+ "usage": { "queries_used": 1, "search_cost_usd": null, "provider_credits_used": 10 }
+}
+```
+
## Response shape
```json
@@ -87,5 +107,6 @@ All accept `query` + `max_results`. Optional fields vary:
| `searchapi` | country, language, pagination | — |
| `youcom` | country, language, time_range, domain_filter, full_page | — |
| `searxng` | language, time_range | Self-hosted, **noAuth** |
+| `xquik` | X/Twitter search operators, language, cursor pagination | `queryType: Latest/Top`, `cursor` (options) |
Provider IS the model — `"provider":"tavily" ≡ "model":"tavily"`.
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js
index 58482a9b..52ca8b09 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js
@@ -2,8 +2,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { UPDATER_CONFIG } from "@/shared/constants/config";
+import { readPresets, upsertPreset, deletePreset, subscribePresets, stripSlash } from "./cliEndpointPresets";
-const STORAGE_KEY = "9router.cliToolEndpointPresets";
const CUSTOM_VALUE = "__custom__";
const SAVE_VALUE = "__save__";
@@ -13,22 +13,6 @@ const ensureV1 = (url) => {
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
};
-const readSavedPresets = () => {
- if (typeof window === "undefined") return [];
- try {
- const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]");
- if (!Array.isArray(raw)) return [];
- return raw.filter((p) => p?.name && p?.baseUrl);
- } catch {
- return [];
- }
-};
-
-const writeSavedPresets = (presets) => {
- if (typeof window === "undefined") return;
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
-};
-
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
const opts = [];
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
@@ -66,14 +50,34 @@ export default function BaseUrlSelect({
cloudEnabled = false,
cloudUrl = "",
withV1 = true,
+ currentUrl = "",
}) {
const [savedPresets, setSavedPresets] = useState([]);
+ const [presetsLoaded, setPresetsLoaded] = useState(false);
const [mode, setMode] = useState("");
const [customInput, setCustomInput] = useState("");
const initializedRef = useRef(false);
+ const customInputRef = useRef("");
useEffect(() => {
- setSavedPresets(readSavedPresets());
+ const sync = () => {
+ const presets = readPresets();
+ setSavedPresets(presets);
+ // A preset saved elsewhere (e.g. on Apply) takes over the custom slot
+ setMode((prev) => {
+ if (prev !== CUSTOM_VALUE) return prev;
+ const typed = stripSlash(customInputRef.current);
+ if (!typed) return prev;
+ const match = presets.find((p) => {
+ const saved = stripSlash(p.baseUrl);
+ return saved === typed || saved === ensureV1(typed);
+ });
+ return match ? `saved:${match.name}` : prev;
+ });
+ };
+ sync();
+ setPresetsLoaded(true);
+ return subscribePresets(sync);
}, []);
const options = useMemo(
@@ -81,19 +85,23 @@ export default function BaseUrlSelect({
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
);
- // Always default to first option (127.0.0.1) on mount, ignore persisted value
+ // Prefer a saved preset matching the currently configured URL, else first option
useEffect(() => {
if (initializedRef.current) return;
- if (options.length === 0) return;
+ if (!presetsLoaded || options.length === 0) return;
initializedRef.current = true;
- const first = options.find((o) => o.value !== CUSTOM_VALUE);
- if (first) {
- setMode(first.value);
- onChange(first.url);
+ const current = stripSlash(currentUrl);
+ const matched = current
+ ? options.find((o) => o.saved && stripSlash(o.url) === current)
+ : null;
+ const target = matched || options.find((o) => o.value !== CUSTOM_VALUE);
+ if (target) {
+ setMode(target.value);
+ onChange(target.url);
} else {
setMode(CUSTOM_VALUE);
}
- }, [options, onChange]);
+ }, [presetsLoaded, options, onChange, currentUrl]);
const handleSelect = (e) => {
const next = e.target.value;
@@ -103,11 +111,8 @@ export default function BaseUrlSelect({
let defaultName = trimmed;
try { defaultName = new URL(trimmed).host; } catch {}
const name = window.prompt("Save endpoint as:", defaultName);
- if (!name?.trim()) return;
- const updated = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }]
- .sort((a, b) => a.name.localeCompare(b.name));
- setSavedPresets(updated);
- writeSavedPresets(updated);
+ const saved = name?.trim() ? upsertPreset(trimmed, name.trim()) : null;
+ if (saved) setMode(`saved:${saved}`);
return;
}
setMode(next);
@@ -122,19 +127,23 @@ export default function BaseUrlSelect({
const handleCustomInput = (e) => {
const v = e.target.value;
+ customInputRef.current = v;
setCustomInput(v);
onChange(v);
};
const handleDeleteSaved = () => {
if (!mode.startsWith("saved:")) return;
- const name = mode.slice(6);
- const updated = savedPresets.filter((p) => p.name !== name);
- setSavedPresets(updated);
- writeSavedPresets(updated);
- setMode(CUSTOM_VALUE);
+ deletePreset(mode.slice(6));
setCustomInput("");
- onChange("");
+ const fallback = options.find((o) => o.value !== CUSTOM_VALUE && o.value !== mode);
+ if (fallback) {
+ setMode(fallback.value);
+ onChange(fallback.url);
+ } else {
+ setMode(CUSTOM_VALUE);
+ onChange("");
+ }
};
const isSaved = mode.startsWith("saved:");
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
index 7ee5ad2e..912114d6 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -53,6 +54,8 @@ export default function ClaudeToolCard({
const [maxContextTokens, setMaxContextTokens] = useState("");
const hasInitializedModels = useRef(false);
+ const currentBaseUrl = claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL || "";
+
const getConfigStatus = () => {
if (!claudeStatus?.installed) return null;
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
@@ -189,6 +192,8 @@ export default function ClaudeToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled }));
} else {
@@ -334,6 +339,7 @@ export default function ClaudeToolCard({
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js
index a88f79e6..b1deb02f 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -50,6 +51,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
}
};
+ const currentBaseUrl = status?.settings?.openAiBaseUrl || "";
+
const getConfigStatus = () => {
if (!status?.installed) return null;
if (!status.has9Router) return "not_configured";
@@ -94,6 +97,8 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
@@ -226,6 +231,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
index 3e706996..6fed52de 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
@@ -6,6 +6,7 @@ import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
+import { rememberEndpoint } from "./cliEndpointPresets";
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
@@ -57,17 +58,22 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
if (modelMatch) setSelectedModel(modelMatch[1]);
// Parse subagent settings
- const subagentModelMatch = codexStatus.config.match(/\[agents\.subagent\]\s*\n\s*model\s*=\s*"([^"]+)"/m);
+ const subagentModelMatch = codexStatus.config.match(/^default_subagent_model\s*=\s*"([^"]+)"/m);
if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]);
}
}, [codexStatus]);
+ const getCurrentBaseUrl = () => {
+ const parsed = codexStatus?.config?.match(/base_url\s*=\s*"([^"]+)"/);
+ return parsed ? parsed[1] : "";
+ };
+
+ const currentBaseUrl = getCurrentBaseUrl();
+
const getConfigStatus = () => {
if (!codexStatus?.installed) return null;
if (!codexStatus.config) return "not_configured";
- const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
- const currentUrl = parsed ? parsed[1] : "";
- return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
+ return matchKnownEndpoint(currentBaseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
@@ -114,6 +120,8 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkCodexStatus();
} else {
@@ -172,24 +180,18 @@ name = "9Router"
base_url = "${getEffectiveBaseUrl()}"
wire_api = "responses"
-[agents.subagent]
-model = "${effectiveSubagentModel}"
-`;
+[model_providers.9router.http_headers]
+Authorization = "Bearer ${keyToUse}"
- const authContent = JSON.stringify({
- auth_mode: "apikey",
- OPENAI_API_KEY: keyToUse
- }, null, 2);
+[agents]
+default_subagent_model = "${effectiveSubagentModel}"
+`;
return [
{
filename: "~/.codex/config.toml",
content: configContent,
},
- {
- filename: "~/.codex/auth.json",
- content: authContent,
- },
];
};
@@ -254,7 +256,7 @@ model = "${effectiveSubagentModel}"
After installation, run codex to verify.
- Codex uses ~/.codex/auth.json with OPENAI_API_KEY.
+ Codex reads custom providers from ~/.codex/config.toml.
Click "Apply" to auto-configure.
@@ -279,13 +281,12 @@ model = "${effectiveSubagentModel}"
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
{/* Current configured */}
{codexStatus?.config && (() => {
- const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
- const currentBaseUrl = parsed ? parsed[1] : null;
return currentBaseUrl ? (
Current
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
index 90f96f94..43242938 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -77,6 +78,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
}
};
+ const currentBaseUrl = status?.currentUrl || "";
+
const getConfigStatus = () => {
if (!status) return null;
if (!status.has9Router) return "not_configured";
@@ -123,6 +126,8 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." });
checkStatus();
} else {
@@ -230,6 +235,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
index 2d1fb820..367ef5db 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
const ENDPOINT = "/api/cli-tools/cowork-settings";
@@ -110,6 +111,8 @@ export default function CoworkToolCard({
const getEffectiveBaseUrl = () => ensureV1(customBaseUrl);
+ const currentBaseUrl = status?.cowork?.baseUrl || "";
+
const getConfigStatus = () => {
if (!status?.installed) return null;
const url = status?.cowork?.baseUrl;
@@ -148,6 +151,8 @@ export default function CoworkToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." });
checkStatus();
} else {
@@ -306,6 +311,7 @@ export default function CoworkToolCard({
tailscaleUrl={tailscaleUrl}
cloudEnabled={cloudEnabled}
cloudUrl={cloudUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js
index ad84a4b7..db051f6b 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function DeepSeekTuiToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = deepseekStatus?.settings?.["providers.openai"]?.base_url || "";
+
const getConfigStatus = () => {
if (!deepseekStatus?.installed) return null;
const openaiSection = deepseekStatus.settings?.["providers.openai"];
@@ -128,6 +131,8 @@ export default function DeepSeekTuiToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
@@ -263,6 +268,7 @@ model = "${selectedModel || "provider/model-id"}"
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
index adc2a7ae..d6068f11 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -39,6 +40,8 @@ export default function DroidToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = droidStatus?.settings?.customModels?.find((m) => m.id?.startsWith("custom:9Router"))?.baseUrl || "";
+
const getConfigStatus = () => {
if (!droidStatus?.installed) return null;
// Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...)
@@ -154,6 +157,8 @@ export default function DroidToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkDroidStatus();
} else {
@@ -299,6 +304,7 @@ export default function DroidToolCard({
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
index f1ea3c22..fde57a38 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
@@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -96,6 +97,7 @@ export default function GrokBuildToolCard({
const hasFetchedStatus = useRef(Boolean(initialStatus));
const configuredModel = grokStatus?.settings?.model;
+ const currentBaseUrl = configuredModel?.base_url || "";
const configStatus = !grokStatus?.installed
? null
: !configuredModel?.base_url
@@ -184,6 +186,8 @@ export default function GrokBuildToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Main and subagent models applied successfully!" });
checkStatus();
} else {
@@ -310,7 +314,7 @@ export default function GrokBuildToolCard({
Select Endpoint
arrow_forward
-
+
{configuredModel?.base_url && (
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
index 9ef6cddf..bf5c25d1 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function HermesToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = hermesStatus?.settings?.model?.base_url || "";
+
const getConfigStatus = () => {
if (!hermesStatus?.installed) return null;
const cfg = hermesStatus.settings?.model;
@@ -128,6 +131,8 @@ export default function HermesToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
@@ -242,6 +247,7 @@ export default function HermesToolCard({
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js
index c4544fa3..c616980c 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -35,6 +36,8 @@ export default function JcodeToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = jcodeStatus?.config?.providers?.["9router"]?.base_url || "";
+
const getConfigStatus = () => {
if (!jcodeStatus?.installed) return null;
if (!jcodeStatus?.has9Router) return "not_configured";
@@ -140,6 +143,8 @@ export default function JcodeToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkJcodeStatus();
} else {
@@ -295,6 +300,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js
index be348595..87f14f54 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -88,6 +89,8 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
index b646a6eb..8e5cb8c2 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -37,6 +38,8 @@ export default function OpenClawToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
+ const currentBaseUrl = openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl || "";
+
const getConfigStatus = () => {
if (!openclawStatus?.installed) return null;
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
@@ -146,6 +149,8 @@ export default function OpenClawToolCard({
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkOpenclawStatus();
} else {
@@ -291,6 +296,7 @@ export default function OpenClawToolCard({
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
index c99edc98..5c800bc2 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
+import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
@@ -94,6 +95,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
}
};
+ const currentBaseUrl = status?.config?.provider?.["9router"]?.options?.baseURL || "";
+
const getConfigStatus = () => {
if (!status?.installed) return null;
if (!status.config) return "not_configured";
@@ -145,6 +148,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
});
const data = await res.json();
if (res.ok) {
+ // Remember the endpoint so it stays selectable next time
+ rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
@@ -297,6 +302,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
+ currentUrl={currentBaseUrl}
/>
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js
new file mode 100644
index 00000000..53cd0d43
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointPresets.js
@@ -0,0 +1,71 @@
+import { UPDATER_CONFIG } from "@/shared/constants/config";
+
+// Browser-local endpoint presets shared by every CLI tool card
+const STORAGE_KEY = "9router.cliToolEndpointPresets";
+const CHANGE_EVENT = "9router:endpoint-presets-changed";
+
+const stripSlash = (url) => (url || "").replace(/\/+$/, "");
+
+export function readPresets() {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]");
+ if (!Array.isArray(raw)) return [];
+ return raw.filter((p) => p?.name && p?.baseUrl);
+ } catch {
+ return [];
+ }
+}
+
+function writePresets(presets) {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
+ window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
+}
+
+export function subscribePresets(handler) {
+ if (typeof window === "undefined") return () => {};
+ window.addEventListener(CHANGE_EVENT, handler);
+ return () => window.removeEventListener(CHANGE_EVENT, handler);
+}
+
+function defaultNameFor(url) {
+ try { return new URL(url).host; } catch { return url; }
+}
+
+// Adds or replaces a preset; returns the stored name, or null when skipped
+export function upsertPreset(baseUrl, name) {
+ const url = stripSlash(baseUrl);
+ if (!url) return null;
+
+ const presets = readPresets();
+ const existing = presets.find((p) => stripSlash(p.baseUrl) === url);
+ if (existing && !name) return existing.name;
+
+ const finalName = (name || defaultNameFor(url)).trim();
+ if (!finalName) return null;
+
+ const next = [...presets.filter((p) => p.name !== finalName && stripSlash(p.baseUrl) !== url), { name: finalName, baseUrl: url }]
+ .sort((a, b) => a.name.localeCompare(b.name));
+ writePresets(next);
+ return finalName;
+}
+
+// Save an applied endpoint unless it exactly matches a built-in dropdown option
+export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) {
+ const url = stripSlash(baseUrl);
+ if (!url) return null;
+
+ const builtIns = [`http://127.0.0.1:${UPDATER_CONFIG.appPort}`, tunnelPublicUrl, tailscaleUrl, cloudUrl]
+ .filter(Boolean)
+ .flatMap((u) => [stripSlash(u), `${stripSlash(u)}/v1`]);
+ if (builtIns.includes(url)) return null;
+
+ return upsertPreset(url);
+}
+
+export function deletePreset(name) {
+ writePresets(readPresets().filter((p) => p.name !== name));
+}
+
+export { stripSlash };
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js
index 76c571b8..ff30dcb2 100644
--- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js
@@ -275,7 +275,7 @@ export function GenericExampleCard({ providerId, kind }) {
{/* API Key */}
- {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured}
+ {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, Math.max(0, apiKey.length - 8)))}` : No key configured}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js
index 18f2a4f6..3cffdc42 100644
--- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js
@@ -157,7 +157,7 @@ export function SttExampleCard({ providerId }) {
{/* API Key */}
- {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured}
+ {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, Math.max(0, apiKey.length - 8)))}` : No key configured}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js
index a3fb1d32..e67a2594 100644
--- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js
@@ -274,7 +274,7 @@ export function TtsExampleCard({ providerId }) {
{apiKey
- ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}`
+ ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, Math.max(0, apiKey.length - 8)))}`
: connectionCount > 0
? Using stored key(s) · {connectionCount} connection{connectionCount > 1 ? "s" : ""}
: No key configured}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/BulkImportGrokCliModal.js b/src/app/(dashboard)/dashboard/providers/[id]/BulkImportGrokCliModal.js
new file mode 100644
index 00000000..10628eb1
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/BulkImportGrokCliModal.js
@@ -0,0 +1,284 @@
+"use client";
+
+import { useState, useRef } from "react";
+import { Modal, Button } from "@/shared/components";
+import { translate } from "@/i18n/runtime";
+
+const PLACEHOLDER = `[
+ {
+ "access_token": "eyJ0eXAiOiJhdCtqd3Qi...",
+ "refresh_token": "LZhriF9bf88pPykpXCuZ9...",
+ "id_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
+ "email": "account1@example.com"
+ },
+ {
+ "access_token": "eyJ0eXAiOiJhdCtqd3Qi...",
+ "refresh_token": "LZhriF9bf88pPykpXCuZ9...",
+ "id_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
+ "email": "account2@example.com"
+ }
+]`;
+
+function parseAccountsInput(rawText) {
+ const trimmed = rawText.trim();
+ if (!trimmed) return [];
+
+ let parsed;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (initialErr) {
+ // If direct parse failed, try handling concatenated or comma-separated JSON objects
+ try {
+ let fixed = trimmed;
+ if (!fixed.startsWith("[")) {
+ fixed = fixed.replace(/\}\s*,\s*\{/g, "},{").replace(/\}\s*\{/g, "},{");
+ if (fixed.endsWith(",")) fixed = fixed.slice(0, -1);
+ fixed = `[${fixed}]`;
+ }
+ parsed = JSON.parse(fixed);
+ } catch {
+ throw initialErr;
+ }
+ }
+
+ if (Array.isArray(parsed)) {
+ return parsed;
+ }
+ if (parsed && typeof parsed === "object") {
+ if (Array.isArray(parsed.accounts)) return parsed.accounts;
+ return [parsed];
+ }
+
+ throw new Error("Input must be a JSON object or array of objects");
+}
+
+export default function BulkImportGrokCliModal({ isOpen, onClose, onSuccess }) {
+ const [jsonText, setJsonText] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [parseError, setParseError] = useState("");
+ const [result, setResult] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [fileCountInfo, setFileCountInfo] = useState(null);
+ const fileInputRef = useRef(null);
+
+ const handleClose = () => {
+ if (submitting) return;
+ setJsonText("");
+ setParseError("");
+ setResult(null);
+ setFileCountInfo(null);
+ setIsDragging(false);
+ onClose();
+ };
+
+ const processFiles = async (files) => {
+ if (!files || files.length === 0) return;
+ setParseError("");
+ const jsonFiles = Array.from(files).filter(
+ (file) => file.name.endsWith(".json") || file.type === "application/json" || file.type === ""
+ );
+
+ if (jsonFiles.length === 0) {
+ setParseError(translate("Please select valid .json files"));
+ return;
+ }
+
+ try {
+ const allAccounts = [];
+ for (const file of jsonFiles) {
+ const text = await file.text();
+ const accountsFromFile = parseAccountsInput(text);
+ if (Array.isArray(accountsFromFile)) {
+ allAccounts.push(...accountsFromFile);
+ } else if (accountsFromFile) {
+ allAccounts.push(accountsFromFile);
+ }
+ }
+
+ if (allAccounts.length === 0) {
+ setParseError(translate("No accounts found in selected files"));
+ return;
+ }
+
+ setJsonText(JSON.stringify(allAccounts, null, 2));
+ setFileCountInfo({
+ filesCount: jsonFiles.length,
+ accountsCount: allAccounts.length,
+ });
+ } catch (err) {
+ setParseError(`${translate("Error reading files")}: ${err.message}`);
+ }
+ };
+
+ const handleFileInputChange = (e) => {
+ processFiles(e.target.files);
+ if (e.target) e.target.value = "";
+ };
+
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ setIsDragging(true);
+ };
+
+ const handleDragLeave = (e) => {
+ e.preventDefault();
+ setIsDragging(false);
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ setIsDragging(false);
+ if (e.dataTransfer?.files?.length > 0) {
+ processFiles(e.dataTransfer.files);
+ }
+ };
+
+ const handleSubmit = async () => {
+ setParseError("");
+ setResult(null);
+
+ let accounts;
+ try {
+ accounts = parseAccountsInput(jsonText);
+ } catch (err) {
+ setParseError(`${translate("Invalid JSON")}: ${err.message}`);
+ return;
+ }
+
+ if (!accounts || accounts.length === 0) {
+ setParseError(translate("No accounts found in input"));
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const res = await fetch("/api/oauth/grok-cli/bulk-import", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ accounts }),
+ });
+ const data = await res.json();
+
+ if (!res.ok) {
+ setParseError(data?.error || `Request failed: ${res.status}`);
+ return;
+ }
+
+ setResult(data);
+ if (data.success > 0 && typeof onSuccess === "function") {
+ onSuccess();
+ }
+ } catch (err) {
+ setParseError(err.message || translate("Request failed"));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const failedItems = result?.results?.filter((r) => !r.ok) || [];
+
+ return (
+
+
+
+
+ {translate("Upload multiple .json files or paste JSON array / object.")}
+
+
+
+
+
+
+
+ {fileCountInfo && (
+
+ check_circle
+
+ {translate("Loaded")} {fileCountInfo.accountsCount} {translate("account(s) from")}{" "}
+ {fileCountInfo.filesCount} {translate("file(s)")}
+
+
+ )}
+
+ {parseError && (
+
{parseError}
+ )}
+
+ {result && result.failed > 0 && (
+
+
+ ✗ {result.failed} {translate("failed")}
+
+ {failedItems.length > 0 && (
+
+ {failedItems.map((item) => (
+ -
+ [{item.index}] {item.error}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index 9ee75185..14ac7b3e 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -22,6 +22,7 @@ import AddApiKeyModal from "./AddApiKeyModal";
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
import AddCustomModelModal from "./AddCustomModelModal";
import BulkImportCodexModal from "./BulkImportCodexModal";
+import BulkImportGrokCliModal from "./BulkImportGrokCliModal";
const ONE_BY_ONE_DELAY_MS = 1000;
@@ -48,6 +49,7 @@ export default function ProviderDetailPage() {
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [addConnectionError, setAddConnectionError] = useState("");
const [showBulkImportCodex, setShowBulkImportCodex] = useState(false);
+ const [showBulkImportGrokCli, setShowBulkImportGrokCli] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
@@ -1624,6 +1626,11 @@ export default function ProviderDetailPage() {
{translate("Bulk Add")}
)}
+ {providerId === "grok-cli" && (
+
+ )}
+
+
Timeout (ms)
+
setHeadroomTimeoutMs(e.target.value)}
+ onBlur={handleHeadroomTimeoutBlur}
+ placeholder="3000"
+ className="font-mono text-sm"
+ />
+
+ Request timeout in milliseconds. Defaults to 3000 ms.
+
+
{headroomManaged ? (
{currentPageRows.map((quota) => {
+ const isUnlimited = quota.unlimited === true;
const colors = getColorClasses(quota.remaining);
const countdown = formatResetTime(quota.resetAt);
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
@@ -174,6 +175,7 @@ export default function QuotaTable({
{/* Progress + used/total */}
+ {!isUnlimited && (
@@ -182,16 +184,23 @@ export default function QuotaTable({
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
/>
+ )}
0 ? quota.total.toLocaleString() : "∞"}`}
+ title={
+ isUnlimited
+ ? `${quota.used.toLocaleString()} used · Unlimited`
+ : `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`
+ }
>
- {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
+ {isUnlimited
+ ? `${quota.used.toLocaleString()} used · Unlimited`
+ : `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`}
-
- {quota.remaining}%
+
+ {isUnlimited ? "Unlimited" : `${quota.remaining}%`}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
index c55633c2..683ea53d 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
@@ -1265,6 +1265,11 @@ export default function ProviderLimits() {
onHideQuota={(quotaRow) => handleHideQuota(conn.provider, quotaRow)}
/>
)}
+ {quota?.message && !error && !isLoading && (
+
+ {quota.message}
+
+ )}
{hiddenQuotaRows.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
index 6d542ddf..9fd7d31f 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
@@ -379,8 +379,17 @@ export function parseQuotaData(provider, data) {
case "codex":
if (data.quotas) {
Object.entries(data.quotas).forEach(([quotaType, quota]) => {
+ let displayName = quotaType;
+ if (quotaType === "spark_session") displayName = "Spark (5h)";
+ else if (quotaType === "spark_weekly") displayName = "Spark (Weekly)";
+ else if (quotaType === "session") displayName = "5h";
+ else if (quotaType === "weekly") displayName = "Weekly";
+ else if (quotaType === "review_session") displayName = "Review (5h)";
+ else if (quotaType === "review_weekly") displayName = "Review (Weekly)";
+
normalizedQuotas.push({
- name: quotaType,
+ name: displayName,
+ quotaType,
used: quota.used || 0,
total: quota.total || 0,
remaining: quota.remaining,
@@ -566,6 +575,22 @@ export function parseQuotaData(provider, data) {
}
break;
+ case "zed":
+ // Edit predictions + optional hosted model_requests; unlimited uses remainingPercentage.
+ if (data.quotas) {
+ Object.entries(data.quotas).forEach(([name, quota]) => {
+ normalizedQuotas.push({
+ name,
+ used: quota.used || 0,
+ total: quota.total || 0,
+ resetAt: quota.resetAt || null,
+ remainingPercentage: quota.remainingPercentage,
+ unlimited: quota.unlimited,
+ });
+ });
+ }
+ break;
+
default:
// Generic fallback for unknown providers
if (data.quotas) {
diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js
index ff20c575..1a6d016f 100644
--- a/src/app/api/cli-tools/codex-settings/route.js
+++ b/src/app/api/cli-tools/codex-settings/route.js
@@ -135,35 +135,22 @@ export async function POST(request) {
// Update or create 9router provider section (no api_key - Codex reads from auth.json)
// Ensure /v1 suffix is added only once
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
+ // Custom providers ignore auth.json - the key must travel as a static header
setNestedSection(parsed, "model_providers.9router", {
name: "9Router",
base_url: normalizedBaseUrl,
wire_api: "responses",
+ http_headers: { Authorization: `Bearer ${apiKey}` },
});
- // Add subagent configuration
- const effectiveSubagentModel = subagentModel || model;
- setNestedSection(parsed, "agents.subagent", {
- model: effectiveSubagentModel,
- });
+ // Subagent model is a scalar under [agents]; agents. now means a custom role
+ deleteNestedSection(parsed, "agents.subagent");
+ setNestedSection(parsed, "agents.default_subagent_model", subagentModel || model);
// Write merged config
const configContent = stringifyTOML(parsed);
await fs.writeFile(configPath, configContent);
- // Update auth.json with OPENAI_API_KEY (Codex reads this first)
- const authPath = getCodexAuthPath();
- let authData = {};
- try {
- const existingAuth = await fs.readFile(authPath, "utf-8");
- authData = JSON.parse(existingAuth);
- } catch { /* No existing auth */ }
-
- // Force apikey mode (keep existing tokens untouched for ChatGPT login reuse)
- authData.OPENAI_API_KEY = apiKey;
- authData.auth_mode = "apikey";
- await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
-
return NextResponse.json({
success: true,
message: "Codex settings applied successfully!",
@@ -204,7 +191,8 @@ export async function DELETE() {
// Remove 9router provider section
deleteNestedSection(parsed, "model_providers.9router");
- // Remove subagent configuration
+ // Remove subagent configuration (both the current key and the legacy role form)
+ deleteNestedSection(parsed, "agents.default_subagent_model");
deleteNestedSection(parsed, "agents.subagent");
// Write updated config
diff --git a/src/app/api/models/catalog-sync/route.js b/src/app/api/models/catalog-sync/route.js
new file mode 100644
index 00000000..bc5482b5
--- /dev/null
+++ b/src/app/api/models/catalog-sync/route.js
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import fs from "node:fs";
+import { getSyncState, syncModelCatalog } from "@/lib/modelCatalog/sync.js";
+import { CATALOG_FILE } from "open-sse/providers/catalogOverride.js";
+
+// GET /api/models/catalog-sync - Sync status and what the catalog currently holds
+export async function GET() {
+ const state = getSyncState();
+ let catalog = null;
+ try {
+ const parsed = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8"));
+ catalog = {
+ syncedAt: parsed.syncedAt,
+ models: Object.keys(parsed.models || {}).length,
+ providers: Object.keys(parsed.providers || {}).length,
+ bytes: fs.statSync(CATALOG_FILE).size,
+ };
+ } catch {
+ catalog = null;
+ }
+ return NextResponse.json({ ...state, catalog });
+}
+
+// POST /api/models/catalog-sync - Run a sync now instead of waiting for the timer
+export async function POST() {
+ const result = await syncModelCatalog();
+ if (!result) {
+ return NextResponse.json({ error: getSyncState().lastError || "sync in progress" }, { status: 503 });
+ }
+ return NextResponse.json({ success: true, result });
+}
diff --git a/src/app/api/oauth/grok-cli/bulk-import/route.js b/src/app/api/oauth/grok-cli/bulk-import/route.js
new file mode 100644
index 00000000..e714c523
--- /dev/null
+++ b/src/app/api/oauth/grok-cli/bulk-import/route.js
@@ -0,0 +1,116 @@
+import { NextResponse } from "next/server";
+import { createProviderConnection } from "@/models";
+import { decodeXaiIdTokenEmail, extractEmailFromAccessToken } from "@/lib/oauth/providerHelpers";
+
+/**
+ * POST /api/oauth/grok-cli/bulk-import
+ * Bulk import multiple Grok CLI (OAuth/Device) account JSON objects in one call.
+ *
+ * Body accepts any of:
+ * - Array: [{...}, {...}]
+ * - Single: {...}
+ * - Wrapped: { accounts: [{...}, ...] }
+ *
+ * Each item accepts snake_case or camelCase:
+ * access_token / accessToken
+ * refresh_token / refreshToken
+ * id_token / idToken
+ * email
+ * expires_in / expiresIn / expires_at / expiresAt
+ */
+export async function POST(request) {
+ let body;
+ try {
+ body = await request.json();
+ } catch (err) {
+ return NextResponse.json(
+ { error: `Invalid JSON body: ${err.message}` },
+ { status: 400 }
+ );
+ }
+
+ let accounts;
+ if (Array.isArray(body)) {
+ accounts = body;
+ } else if (body && typeof body === "object" && Array.isArray(body.accounts)) {
+ accounts = body.accounts;
+ } else if (body && typeof body === "object") {
+ accounts = [body];
+ } else {
+ accounts = null;
+ }
+
+ if (!Array.isArray(accounts) || accounts.length === 0) {
+ return NextResponse.json(
+ { error: "No accounts provided" },
+ { status: 400 }
+ );
+ }
+
+ const results = [];
+ let success = 0;
+ let failed = 0;
+
+ for (let i = 0; i < accounts.length; i++) {
+ const raw = accounts[i];
+ try {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ throw new Error("Item is not an object");
+ }
+
+ const accessToken = raw.access_token || raw.accessToken;
+ const refreshToken = raw.refresh_token || raw.refreshToken || null;
+ const idToken = raw.id_token || raw.idToken || null;
+ let email = raw.email || null;
+
+ if (!accessToken || typeof accessToken !== "string") {
+ throw new Error("Missing access_token / accessToken");
+ }
+
+ if (!email) {
+ email =
+ decodeXaiIdTokenEmail(idToken) ||
+ extractEmailFromAccessToken(accessToken) ||
+ null;
+ }
+
+ let expiresAt = raw.expires_at || raw.expiresAt || null;
+ const expiresIn = raw.expires_in || raw.expiresIn;
+ if (!expiresAt && typeof expiresIn === "number" && expiresIn > 0) {
+ expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
+ }
+
+ const psd = {
+ authMethod: "device_code",
+ ...(idToken ? { idToken } : {}),
+ ...(email ? { email } : {}),
+ ...(raw.providerSpecificData || {}),
+ };
+
+ const created = await createProviderConnection({
+ provider: "grok-cli",
+ authType: "oauth",
+ accessToken,
+ refreshToken,
+ expiresAt,
+ email,
+ displayName: raw.displayName || raw.name || undefined,
+ providerSpecificData: psd,
+ testStatus: "active",
+ });
+
+ success++;
+ results.push({ index: i, ok: true, id: created.id, email: created.email });
+ } catch (err) {
+ failed++;
+ results.push({ index: i, ok: false, error: err.message });
+ }
+ }
+
+ return NextResponse.json({
+ total: accounts.length,
+ success,
+ failed,
+ results,
+ });
+}
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
index 1d237f5b..3c337ffb 100644
--- a/src/app/api/providers/[id]/test/testUtils.js
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -460,6 +460,12 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
}
async function fetchWithConnectionProxy(url, options = {}, effectiveProxy = null) {
+ // Add a 15-second timeout to prevent connection testing from hanging indefinitely
+ // and exhausting the browser/Node.js connection pools.
+ if (!options.signal) {
+ options.signal = AbortSignal.timeout(15000);
+ }
+
// Vercel relay: forward via relay URL
if (effectiveProxy?.vercelRelayUrl) {
const { proxyAwareFetch } = await import("open-sse/utils/proxyFetch.js");
diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js
index e9f8f400..7cdabc5e 100644
--- a/src/app/api/providers/validate/route.js
+++ b/src/app/api/providers/validate/route.js
@@ -20,7 +20,7 @@ async function probeWebProvider(provider, apiKey) {
if (!cfg) return null;
if (cfg.authType === "none") return true; // no-auth (e.g. searxng)
- let url = cfg.baseUrl;
+ let url = cfg.validateUrl || cfg.baseUrl;
const headers = { "Content-Type": "application/json" };
let body;
diff --git a/src/app/globals.css b/src/app/globals.css
index 2a1434fe..95bb7686 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -4,8 +4,8 @@
@custom-variant dark (&:where(.dark, .dark *));
/* Hide icon ligature text until font is ready */
-.material-symbols-outlined { visibility: hidden; }
-.fonts-loaded .material-symbols-outlined { visibility: visible; }
+.material-symbols-outlined { opacity: 0; }
+.fonts-loaded .material-symbols-outlined { opacity: 1; transition: opacity .12s ease-out; }
/* ============================================================
9Router palette — adopted from 9remote_private/web
diff --git a/src/app/layout.js b/src/app/layout.js
index 5ea6c11d..8e9b7609 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -34,7 +34,7 @@ export default function RootLayout({ children }) {
diff --git a/src/instrumentation.js b/src/instrumentation.js
index f511eae2..57ea03f1 100644
--- a/src/instrumentation.js
+++ b/src/instrumentation.js
@@ -2,5 +2,13 @@ export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { initConsoleLogCapture } = await import("@/lib/consoleLogBuffer");
initConsoleLogCapture();
+
+ // Server-only: lets capabilities.js read the synced catalog without pulling
+ // node:fs into the dashboard's browser bundle.
+ const { installCatalogSource } = await import("open-sse/providers/catalogOverride.js");
+ await installCatalogSource();
+
+ const { startModelCatalogSync } = await import("@/lib/modelCatalog/sync.js");
+ startModelCatalogSync();
}
}
diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js
index 72a64848..b94f770a 100644
--- a/src/lib/db/repos/settingsRepo.js
+++ b/src/lib/db/repos/settingsRepo.js
@@ -53,6 +53,7 @@ const DEFAULT_SETTINGS = {
headroomEnabled: false,
headroomUrl: DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: false,
+ headroomTimeoutMs: 3000,
cavemanEnabled: false,
cavemanLevel: "full",
ponytailEnabled: false,
diff --git a/src/lib/modelCatalog/sync.js b/src/lib/modelCatalog/sync.js
new file mode 100644
index 00000000..0d49c48e
--- /dev/null
+++ b/src/lib/modelCatalog/sync.js
@@ -0,0 +1,247 @@
+// Daily refresh of model capabilities from models.dev.
+//
+// Downloads the catalog, keeps only what differs from the hand-written tables,
+// and writes it next to the database. Failures are swallowed on purpose: a
+// stale or missing file just means those tables keep deciding on their own.
+
+import fs from "node:fs";
+import path from "node:path";
+import { CATALOG_FILE, CATALOG_RAW_FILE, invalidateCatalog, installCatalogSource } from "open-sse/providers/catalogOverride.js";
+
+const CATALOG_URL = "https://models.dev/api.json";
+const FETCH_TIMEOUT_MS = 60000;
+
+export const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
+const STARTUP_DELAY_MS = 60 * 1000; // let the server boot and serve first requests
+const RETRY_DELAY_MS = 30 * 60 * 1000;
+
+const MODALITY_BY_INPUT = { image: "vision", pdf: "pdf", audio: "audioInput", video: "videoInput" };
+// Gateways disagree about the same model, so a modality needs a majority of
+// them to declare it — one reseller mislabelling a text model must not win.
+const MIN_SHARE = 0.5;
+// Ignore limit differences below this: gateways round 200000 vs 202752.
+const LIMIT_TOLERANCE = 0.1;
+
+// 9router provider id -> models.dev provider id, for context/maxOutput only.
+// Providers absent here keep whatever the local pattern table resolves; names
+// that already match are resolved automatically.
+const PROVIDER_ALIASES = {
+ "glm": "zai",
+ "glm-cn": "zhipuai",
+ "claude": "anthropic",
+ "gemini": "google",
+ "kimi": "moonshotai",
+ "kimi-cn": "moonshotai-cn",
+ "qwen": "alibaba",
+ "qwen-cn": "alibaba-cn",
+ "zhipu": "zhipuai",
+ "hunyuan": "tencent",
+ "doubao": "volcengine",
+ "cloudflare-ai": "cloudflare-workers-ai",
+};
+
+let state = { running: false, lastSync: null, lastError: null, lastResult: null, etag: null };
+let timer = null;
+
+export function getSyncState() {
+ return { ...state, file: CATALOG_FILE, url: CATALOG_URL, intervalMs: SYNC_INTERVAL_MS };
+}
+
+// "zai-org/GLM-4.6V:free" -> "glm-4.6v"
+function baseId(modelId) {
+ const withoutVendor = modelId.includes("/") ? modelId.split("/").pop() : modelId;
+ return withoutVendor.toLowerCase().split(":")[0];
+}
+
+function writeAtomic(file, contents) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(`${file}.tmp`, contents, "utf8");
+ fs.renameSync(`${file}.tmp`, file);
+}
+
+// Trimmed copy of the upstream catalog, kept for the add-models skill: same
+// models, ~470KB instead of 4.3MB.
+function slim(catalog) {
+ const out = {};
+ for (const [providerId, provider] of Object.entries(catalog)) {
+ const models = {};
+ for (const [modelId, model] of Object.entries(provider?.models || {})) {
+ models[modelId] = {
+ i: (model?.modalities?.input || []).filter((x) => x !== "text"),
+ c: model?.limit?.context,
+ o: model?.limit?.output,
+ r: model?.reasoning || undefined,
+ };
+ }
+ out[providerId] = models;
+ }
+ return out;
+}
+
+function build(catalog, entries) {
+ // Index once: per provider for limits, and tallied across all of them for
+ // modalities.
+ const byProvider = {};
+ const tally = {};
+ for (const [providerId, provider] of Object.entries(catalog)) {
+ const models = {};
+ const counted = new Set();
+ for (const [modelId, model] of Object.entries(provider?.models || {})) {
+ const id = baseId(modelId);
+ models[id] = model;
+ // One vote per provider: several ids can normalize to the same model
+ // (claude-opus-4-thinking:1024, :8192, :32768 …) and must not stack.
+ if (counted.has(id)) continue;
+ counted.add(id);
+ const counts = tally[id] || (tally[id] = { total: 0 });
+ counts.total++;
+ for (const input of model?.modalities?.input || []) {
+ const key = MODALITY_BY_INPUT[input];
+ if (key) counts[key] = (counts[key] || 0) + 1;
+ }
+ }
+ byProvider[providerId] = models;
+ }
+
+ // Modalities belong to the model — every gateway serving it has the same
+ // weights — so they are keyed by model id and shared across providers.
+ const models = {};
+ for (const [id, counts] of Object.entries(tally)) {
+ const declared = {};
+ for (const key of Object.values(MODALITY_BY_INPUT)) {
+ if ((counts[key] || 0) / counts.total >= MIN_SHARE) declared[key] = true;
+ }
+ if (Object.keys(declared).length) models[id] = declared;
+ }
+
+ // Limits belong to the gateway — each truncates differently — so only the
+ // matching provider's own numbers are used, keyed by provider + model.
+ const providers = {};
+ for (const { provider, model, contextLength, current } of entries) {
+ const alias = PROVIDER_ALIASES[provider];
+ const upstream = catalog[provider] ? provider : (alias && catalog[alias] ? alias : null);
+ const entry = upstream && byProvider[upstream]?.[baseId(model)];
+ if (!entry) continue;
+
+ const delta = {};
+ const { context, output } = entry.limit || {};
+ if (context > 0 && !contextLength
+ && Math.abs(context - current.contextWindow) / current.contextWindow > LIMIT_TOLERANCE) {
+ delta.contextWindow = context;
+ }
+ if (output > 0
+ && Math.abs(output - current.maxOutput) / current.maxOutput > LIMIT_TOLERANCE) {
+ delta.maxOutput = output;
+ }
+ if (Object.keys(delta).length) (providers[provider] || (providers[provider] = {}))[model] = delta;
+ }
+
+ return { models, providers };
+}
+
+// Snapshot every registered model with the capabilities the hand-written tables
+// resolve on their own, so build() can tell which upstream values are a change.
+//
+// The previous catalog MUST be detached first. Leaving it installed makes each
+// delta relative to the last one, so a value that still agrees with upstream
+// looks like "no change" and is dropped — the file erases itself over two runs.
+async function collectEntries() {
+ const [{ default: registry }, { getCapabilitiesForModel, setCatalogSource }] = await Promise.all([
+ import("open-sse/providers/registry/index.js"),
+ import("open-sse/providers/capabilities.js"),
+ ]);
+ setCatalogSource(null);
+
+ const entries = [];
+ for (const provider of registry) {
+ for (const model of provider.models || []) {
+ entries.push({
+ provider: provider.id,
+ model: model.id,
+ contextLength: model.contextLength,
+ current: getCapabilitiesForModel(provider.id, model.id),
+ });
+ }
+ }
+ return entries;
+}
+
+// Run one sync. Returns a summary, or null when it could not complete.
+export async function syncModelCatalog() {
+ if (state.running) return null;
+ state.running = true;
+ try {
+ const headers = { accept: "application/json" };
+ if (state.etag) headers["if-none-match"] = state.etag;
+ const response = await fetch(CATALOG_URL, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
+
+ let result;
+ if (response.status === 304) {
+ result = { status: "unchanged" };
+ } else if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ } else {
+ // ~23ms to parse, once a day, on a server that is otherwise idle at this
+ // point — not worth a worker thread.
+ const catalog = await response.json();
+ const etag = response.headers.get("etag") || null;
+ const entries = await collectEntries();
+ const { models, providers } = build(catalog, entries);
+ const serialized = JSON.stringify({ v: 1, etag, syncedAt: Date.now(), models, providers });
+
+ writeAtomic(CATALOG_FILE, serialized);
+ writeAtomic(CATALOG_RAW_FILE, JSON.stringify(slim(catalog)));
+
+ state.etag = etag;
+ invalidateCatalog();
+ result = {
+ status: "updated",
+ etag,
+ bytes: Buffer.byteLength(serialized),
+ models: Object.keys(models).length,
+ providers: Object.keys(providers).length,
+ };
+ console.log(`[modelCatalog] ${result.models} models, ${result.providers} providers, ${(result.bytes / 1024).toFixed(1)}KB`);
+ }
+
+ state.lastSync = Date.now();
+ state.lastError = null;
+ state.lastResult = result;
+ return result;
+ } catch (error) {
+ state.lastError = error?.message || String(error);
+ console.log(`[modelCatalog] sync failed: ${state.lastError}`);
+ return null;
+ } finally {
+ // collectEntries() detaches the reader; put it back whatever happened.
+ await installCatalogSource().catch(() => {});
+ state.running = false;
+ }
+}
+
+// The etag lives in the file we wrote, so a restart can resume from it instead
+// of re-downloading 4.3MB to be told nothing changed.
+function restoreEtag() {
+ try {
+ state.etag = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8")).etag || null;
+ state.lastSync = fs.statSync(CATALOG_FILE).mtimeMs;
+ } catch {
+ state.etag = null;
+ }
+}
+
+// Schedule the recurring sync. Disable entirely with MODEL_CATALOG_SYNC=off.
+export function startModelCatalogSync() {
+ if (timer) return;
+ if (String(process.env.MODEL_CATALOG_SYNC || "").toLowerCase() === "off") return;
+ restoreEtag();
+
+ const schedule = (delay) => {
+ timer = setTimeout(async () => {
+ const result = await syncModelCatalog();
+ schedule(result ? SYNC_INTERVAL_MS : RETRY_DELAY_MS);
+ }, delay);
+ timer.unref?.();
+ };
+ schedule(STARTUP_DELAY_MS);
+}
diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js
index 1e58918a..35e1bf03 100644
--- a/src/shared/constants/cliTools.js
+++ b/src/shared/constants/cliTools.js
@@ -10,6 +10,9 @@ export const MITM_TOOLS = {
mitmDomain: "daily-cloudcode-pa.googleapis.com",
modelAliases: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
defaultModels: [
+ { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", alias: "gemini-3.7-flash-high" },
+ { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", alias: "gemini-3.7-flash-medium" },
+ { id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", alias: "gemini-3.7-flash-low" },
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" },
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", alias: "gemini-3.6-flash-low" },
diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js
index dd116d16..618e3b2f 100644
--- a/src/shared/constants/providers.js
+++ b/src/shared/constants/providers.js
@@ -5,7 +5,7 @@ import { RISK_NOTICE } from "@/shared/constants/providersDisplay";
const MEDIA_ENTRY_KEYS = [
"serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
"imageConfig", "imageToTextConfig", "videoConfig", "musicConfig",
- "searchViaChat", "searchConfig", "fetchConfig",
+ "searchViaChat", "searchConfig", "fetchConfig", "credentialFallback",
"modelsFetcher", "mediaPriority", "hiddenKinds",
];
diff --git a/src/shared/constants/skills.js b/src/shared/constants/skills.js
index 1f758e32..bd305dcc 100644
--- a/src/shared/constants/skills.js
+++ b/src/shared/constants/skills.js
@@ -56,7 +56,7 @@ export const SKILLS = [
{
id: "9router-web-search",
name: "Web Search",
- description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.",
+ description: "Web and X search via Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com / Xquik.",
endpoint: "/v1/search",
icon: "search",
},
diff --git a/src/shared/utils/providerIcon.js b/src/shared/utils/providerIcon.js
index 32167d14..7e058520 100644
--- a/src/shared/utils/providerIcon.js
+++ b/src/shared/utils/providerIcon.js
@@ -5,6 +5,7 @@ const ICON_ALIASES = {
"perplexity-agent": "perplexity",
"gitlab-duo": "gitlab",
"vercel-ai-gateway": "vercel",
+ "ollama-search": "ollama",
};
// Runtime only — first 404 remembers id for the whole session
diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js
index e8231160..c7f23c3f 100644
--- a/src/sse/handlers/chat.js
+++ b/src/sse/handlers/chat.js
@@ -7,6 +7,7 @@ import {
extractApiKey,
isValidApiKey,
} from "../services/auth.js";
+import { handleAntigravityQuotaError } from "../services/antigravityQuota.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo, getComboModels } from "../services/model.js";
import { handleChatCore } from "open-sse/handlers/chatCore.js";
@@ -274,6 +275,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
headroomEnabled: !!chatSettings.headroomEnabled,
headroomUrl: chatSettings.headroomUrl || DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: !!chatSettings.headroomCompressUserMessages,
+ headroomTimeoutMs: chatSettings.headroomTimeoutMs,
cavemanEnabled: !!chatSettings.cavemanEnabled,
cavemanLevel: chatSettings.cavemanLevel || "full",
ponytailEnabled: !!chatSettings.ponytailEnabled,
@@ -318,8 +320,22 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
if (result.success) return result.response;
- // Mark account unavailable (auto-calculates cooldown with exponential backoff, or precise resetsAtMs)
- const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs);
+ // Antigravity 409/429: refresh live quota to get exact resetAt before locking
+ let quotaResetMs = null;
+ let resetsAtMs = result.resetsAtMs;
+ if (provider === "antigravity" && (result.status === 409 || result.status === 429)) {
+ quotaResetMs = await handleAntigravityQuotaError(
+ credentials.connectionId, result.status, model,
+ refreshedCredentials.accessToken, credentials.providerSpecificData
+ );
+ if (quotaResetMs) resetsAtMs = quotaResetMs;
+ }
+
+ // Exhausted Antigravity model is blocked only in RAM cache until upstream resetAt.
+ // Do not persist a modelLock_* for this path.
+ const shouldFallback = provider === "antigravity" && quotaResetMs
+ ? true
+ : (await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, resetsAtMs)).shouldFallback;
if (shouldFallback) {
log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`);
diff --git a/src/sse/handlers/search.js b/src/sse/handlers/search.js
index d8ee6b74..131095f6 100644
--- a/src/sse/handlers/search.js
+++ b/src/sse/handlers/search.js
@@ -148,8 +148,33 @@ async function handleSingleProviderSearch(body, providerInput, request, apiKey,
let lastError = null;
let lastStatus = null;
+ // Credential fallback: some search providers reuse the API key of a related
+ // chat provider (e.g. ollama-search reuses the `ollama` chat key, zai-search
+ // reuses the `glm` chat key). When the search provider has no own connection,
+ // fall back to the linked provider's credentials.
+ const fallbackProviderId = resolvedProvider.credentialFallback;
+
+ // Lock scope for this handler. Without it markAccountUnavailable would write
+ // an account-wide `__all` lock, which on the credentialFallback path takes
+ // the shared chat key (e.g. glm) offline for chat as well. Must be passed to
+ // getProviderCredentials too, so the lock is read back under the same key.
+ const searchLockKey = `websearch:${providerId}`;
+
while (true) {
- const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
+ // Provider that actually owns the connection in use — differs from
+ // providerId once we fall back, and error locks must be attributed to it.
+ let credentialProviderId = providerId;
+ let credentials = await getProviderCredentials(providerId, excludeConnectionIds, searchLockKey);
+
+ // Fall back to the related chat provider's credentials when this search
+ // provider has none of its own (one key, chat + search).
+ if (!credentials && fallbackProviderId) {
+ credentials = await getProviderCredentials(fallbackProviderId, excludeConnectionIds, searchLockKey);
+ if (credentials) {
+ credentialProviderId = fallbackProviderId;
+ log.info("AUTH", `\x1b[32m${providerId} reusing ${fallbackProviderId} credentials\x1b[0m`);
+ }
+ }
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
@@ -191,7 +216,7 @@ async function handleSingleProviderSearch(body, providerInput, request, apiKey,
if (result.success) return result.response;
- const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId);
+ const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, credentialProviderId, searchLockKey);
if (shouldFallback) {
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
diff --git a/src/sse/services/antigravityQuota.js b/src/sse/services/antigravityQuota.js
new file mode 100644
index 00000000..e991614f
--- /dev/null
+++ b/src/sse/services/antigravityQuota.js
@@ -0,0 +1,99 @@
+/**
+ * Antigravity live quota cache — in-memory, refreshed on demand.
+ * Used by auth.js pre-filter to skip accounts with exhausted model quota.
+ * Also triggered by 409/429 error handler to sync exact resetAt from upstream.
+ */
+
+import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
+import { getAntigravityUsage } from "open-sse/services/usage/google.js";
+import * as log from "../utils/logger.js";
+
+// In-memory cache: connectionId → { [modelId]: { remainingPercentage, resetAt } }
+const quotaCache = new Map();
+// Track last refresh per connection to avoid hammering
+const lastRefreshAt = new Map();
+// In-flight refresh promises — dedup concurrent 409/429 bursts
+const inflightRefresh = new Map();
+
+const MIN_REFRESH_INTERVAL_MS = 30_000; // 30s between refreshes per connection
+
+/**
+ * Get the quota cache (read-only reference for auth.js pre-filter).
+ */
+export function getAntigravityQuotaCache() {
+ return quotaCache;
+}
+
+/**
+ * Refresh quota for a single antigravity connection from upstream API.
+ * Updates in-memory cache only. Cache expiry is the upstream model resetAt.
+ * @returns {object|null} quotas map or null on failure
+ */
+export async function refreshAntigravityQuota(connectionId, accessToken, providerSpecificData) {
+ const now = Date.now();
+ // Coalesce concurrent refreshes before applying the interval gate.
+ const inflight = inflightRefresh.get(connectionId);
+ if (inflight) return inflight;
+
+ const lastRefresh = lastRefreshAt.get(connectionId) || 0;
+ if (now - lastRefresh < MIN_REFRESH_INTERVAL_MS) {
+ log.debug("AG_QUOTA", `${connectionId.slice(0, 8)} | skip refresh (${Math.round((now - lastRefresh) / 1000)}s ago)`);
+ return quotaCache.get(connectionId) || null;
+ }
+
+ // Record every attempt so failed quota calls cannot amplify an upstream 429 burst.
+ lastRefreshAt.set(connectionId, now);
+ const promise = _doRefresh(connectionId, accessToken, providerSpecificData, now);
+ inflightRefresh.set(connectionId, promise);
+ try {
+ return await promise;
+ } finally {
+ inflightRefresh.delete(connectionId);
+ }
+}
+
+async function _doRefresh(connectionId, accessToken, providerSpecificData, now) {
+ try {
+ const proxyCfg = await resolveConnectionProxyConfig(providerSpecificData || {});
+ const proxyOptions = {
+ connectionProxyEnabled: proxyCfg.connectionProxyEnabled === true,
+ connectionProxyUrl: proxyCfg.connectionProxyUrl || "",
+ connectionNoProxy: proxyCfg.connectionNoProxy || "",
+ vercelRelayUrl: proxyCfg.vercelRelayUrl || "",
+ strictProxy: proxyCfg.strictProxy === true,
+ };
+
+ const usage = await getAntigravityUsage(accessToken, providerSpecificData, proxyOptions);
+ // 401/403 usage responses can contain an empty quotas object plus message.
+ // Preserve known cache instead of replacing it with an upstream error response.
+ if (!usage?.quotas || usage.message) return null;
+
+ // Update in-memory cache. Caller logs CACHE_BLOCK only if requested model is exhausted.
+ quotaCache.set(connectionId, usage.quotas);
+
+ return usage.quotas;
+ } catch (e) {
+ log.warn("AG_QUOTA", `${connectionId.slice(0, 8)} | refresh failed: ${e.message}`);
+ return null;
+ }
+}
+
+/**
+ * Handle Antigravity 409/429 — refresh RAM cache and return model resetAt when exhausted.
+ * Called from chat handler error path.
+ * @returns {number|null} resetAt timestamp ms (for resetsAtMs passthrough) or null
+ */
+export async function handleAntigravityQuotaError(connectionId, status, model, accessToken, providerSpecificData) {
+ log.info("AG_QUOTA", `${connectionId.slice(0, 8)} | ${status} on ${model} — refreshing quota`);
+
+ // Throttle applies to error paths too: one quota request per account/30s.
+ // The first 409/429 populates cache; concurrent or repeated errors reuse it.
+ const quota = (await refreshAntigravityQuota(connectionId, accessToken, providerSpecificData))?.[model];
+ if (!quota || quota.remainingPercentage > 0 || !quota.resetAt) return null;
+
+ const resetMs = new Date(quota.resetAt).getTime();
+ if (resetMs <= Date.now()) return null;
+
+ log.warn("AG_QUOTA", `${connectionId.slice(0, 8)} | UPSTREAM_${status} ${model} — quota exhausted; CACHE_BLOCK until ${quota.resetAt}`);
+ return resetMs;
+}
diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js
index eaf42614..53094973 100644
--- a/src/sse/services/auth.js
+++ b/src/sse/services/auth.js
@@ -3,6 +3,7 @@ import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/con
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js";
+import { getAntigravityQuotaCache } from "./antigravityQuota.js";
import * as log from "../utils/logger.js";
// Mutex to prevent race conditions during account selection
@@ -104,10 +105,23 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
return null;
}
- // Filter out model-locked and excluded connections
+ // Antigravity quota cache is lazy: only populated after that account returns 409/429.
+ const isAntigravity = providerId === "antigravity";
+ const antigravityQuotaCache = isAntigravity && model ? getAntigravityQuotaCache() : null;
+
+ // Filter out model-locked, excluded, and Antigravity quota-exhausted connections.
const availableConnections = connections.filter(c => {
if (excludeSet.has(c.id)) return false;
if (isModelLockActive(c, model)) return false;
+ // Antigravity: skip if live quota exhausted for this model
+ if (isAntigravity && model && antigravityQuotaCache) {
+ const quota = antigravityQuotaCache.get(c.id)?.[model];
+ if (quota && quota.remainingPercentage <= 0 && quota.resetAt && new Date(quota.resetAt).getTime() > Date.now()) {
+ const account = c.id?.slice(0, 8) || "unknown";
+ log.info("AG_QUOTA", `${account} | CACHE_BLOCK ${model} — skip upstream until ${quota.resetAt}`);
+ return false;
+ }
+ }
return true;
});
@@ -122,9 +136,15 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
});
if (availableConnections.length === 0) {
- // Find earliest lock expiry across all connections for retry timing
+ // Find earliest persistent lock or lazy Antigravity quota-cache reset for retry timing.
const lockedConns = connections.filter(c => isModelLockActive(c, model));
const expiries = lockedConns.map(c => getEarliestModelLockUntil(c)).filter(Boolean);
+ if (isAntigravity && model && antigravityQuotaCache) {
+ connections.forEach((c) => {
+ const resetAt = antigravityQuotaCache.get(c.id)?.[model]?.resetAt;
+ if (resetAt && new Date(resetAt).getTime() > Date.now()) expiries.push(resetAt);
+ });
+ }
const earliest = expiries.sort()[0] || null;
if (earliest) {
const earliestConn = lockedConns[0];
@@ -266,7 +286,10 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
newBackoffLevel = 0;
} else if (resetsAtMs && resetsAtMs > Date.now()) {
shouldFallback = true;
- cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
+ // Antigravity quota API provides exact per-model resetAt. Do not truncate it.
+ cooldownMs = resolveProviderId(provider) === "antigravity"
+ ? resetsAtMs - Date.now()
+ : Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
newBackoffLevel = 0;
} else {
({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel));
diff --git a/tests/translator/__snapshots__/golden-url-header.test.js.snap b/tests/translator/__snapshots__/golden-url-header.test.js.snap
deleted file mode 100644
index 584e0313..00000000
--- a/tests/translator/__snapshots__/golden-url-header.test.js.snap
+++ /dev/null
@@ -1,1823 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`GOLDEN buildHeaders (default executor providers) > alicode → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > alicode-intl → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > alims-intl → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "anthropic-version": "2023-06-01",
- "x-api-key": "",
- },
- "nonStream": {
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "anthropic-version": "2023-06-01",
- "x-api-key": "",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "anthropic-version": "2023-06-01",
- "x-api-key": "",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > api-airforce → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://endpoint-proxy.local",
- "X-Title": "Endpoint Proxy",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://endpoint-proxy.local",
- "X-Title": "Endpoint Proxy",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://endpoint-proxy.local",
- "X-Title": "Endpoint Proxy",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > baidu → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > bluesminds → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > cerebras → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > chutes → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > claude → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
- "Anthropic-Dangerous-Direct-Browser-Access": "true",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "User-Agent": "claude-cli/2.1.92 (external, sdk-cli)",
- "X-App": "cli",
- "X-Stainless-Arch": "arm64",
- "X-Stainless-Helper-Method": "stream",
- "X-Stainless-Lang": "js",
- "X-Stainless-Os": "MacOS",
- "X-Stainless-Package-Version": "0.80.0",
- "X-Stainless-Retry-Count": "0",
- "X-Stainless-Runtime": "node",
- "X-Stainless-Runtime-Version": "v24.14.0",
- "X-Stainless-Timeout": "600",
- "x-api-key": "",
- },
- "nonStream": {
- "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
- "Anthropic-Dangerous-Direct-Browser-Access": "true",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "User-Agent": "claude-cli/2.1.92 (external, sdk-cli)",
- "X-App": "cli",
- "X-Stainless-Arch": "arm64",
- "X-Stainless-Helper-Method": "stream",
- "X-Stainless-Lang": "js",
- "X-Stainless-Os": "MacOS",
- "X-Stainless-Package-Version": "0.80.0",
- "X-Stainless-Retry-Count": "0",
- "X-Stainless-Runtime": "node",
- "X-Stainless-Runtime-Version": "v24.14.0",
- "X-Stainless-Timeout": "600",
- "x-api-key": "",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
- "Anthropic-Dangerous-Direct-Browser-Access": "true",
- "Anthropic-Version": "2023-06-01",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "claude-cli/2.1.92 (external, sdk-cli)",
- "X-App": "cli",
- "X-Stainless-Arch": "arm64",
- "X-Stainless-Helper-Method": "stream",
- "X-Stainless-Lang": "js",
- "X-Stainless-Os": "MacOS",
- "X-Stainless-Package-Version": "0.80.0",
- "X-Stainless-Retry-Count": "0",
- "X-Stainless-Runtime": "node",
- "X-Stainless-Runtime-Version": "v24.14.0",
- "X-Stainless-Timeout": "600",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://cline.bot",
- "User-Agent": "9Router/0.4.80",
- "X-CLIENT-TYPE": "9router",
- "X-CLIENT-VERSION": "0.4.80",
- "X-CORE-VERSION": "0.4.80",
- "X-IS-MULTIROOT": "false",
- "X-PLATFORM": "darwin",
- "X-PLATFORM-VERSION": "v22.22.0",
- "X-Title": "Cline",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://cline.bot",
- "User-Agent": "9Router/0.4.80",
- "X-CLIENT-TYPE": "9router",
- "X-CLIENT-VERSION": "0.4.80",
- "X-CORE-VERSION": "0.4.80",
- "X-IS-MULTIROOT": "false",
- "X-PLATFORM": "darwin",
- "X-PLATFORM-VERSION": "v22.22.0",
- "X-Title": "Cline",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://cline.bot",
- "User-Agent": "9Router/0.4.80",
- "X-CLIENT-TYPE": "9router",
- "X-CLIENT-VERSION": "0.4.80",
- "X-CORE-VERSION": "0.4.80",
- "X-IS-MULTIROOT": "false",
- "X-PLATFORM": "darwin",
- "X-PLATFORM-VERSION": "v22.22.0",
- "X-Title": "Cline",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://cline.bot",
- "User-Agent": "9Router/0.5.50",
- "X-CLIENT-TYPE": "9router",
- "X-CLIENT-VERSION": "0.5.50",
- "X-CORE-VERSION": "0.5.50",
- "X-IS-MULTIROOT": "false",
- "X-PLATFORM": "linux",
- "X-PLATFORM-VERSION": "v24.15.0",
- "X-Title": "Cline",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://cline.bot",
- "User-Agent": "9Router/0.5.50",
- "X-CLIENT-TYPE": "9router",
- "X-CLIENT-VERSION": "0.5.50",
- "X-CORE-VERSION": "0.5.50",
- "X-IS-MULTIROOT": "false",
- "X-PLATFORM": "linux",
- "X-PLATFORM-VERSION": "v24.15.0",
- "X-Title": "Cline",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://cline.bot",
- "User-Agent": "9Router/0.5.50",
- "X-CLIENT-TYPE": "9router",
- "X-CLIENT-VERSION": "0.5.50",
- "X-CORE-VERSION": "0.5.50",
- "X-IS-MULTIROOT": "false",
- "X-PLATFORM": "linux",
- "X-PLATFORM-VERSION": "v24.15.0",
- "X-Title": "Cline",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-cn → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
- "X-IDE-Name": "CLI",
- "X-IDE-Type": "CLI",
- "X-Product": "SaaS",
- "x-codebuddy-request": "1",
- "x-requested-with": "XMLHttpRequest",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
- "X-IDE-Name": "CLI",
- "X-IDE-Type": "CLI",
- "X-Product": "SaaS",
- "x-codebuddy-request": "1",
- "x-requested-with": "XMLHttpRequest",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
- "X-IDE-Name": "CLI",
- "X-IDE-Type": "CLI",
- "X-Product": "SaaS",
- "x-codebuddy-request": "1",
- "x-requested-with": "XMLHttpRequest",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-intl → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
- "X-IDE-Name": "IDE",
- "X-IDE-Type": "IDE",
- "X-Product": "SaaS",
- "x-codebuddy-request": "1",
- "x-requested-with": "XMLHttpRequest",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
- "X-IDE-Name": "IDE",
- "X-IDE-Type": "IDE",
- "X-Product": "SaaS",
- "x-codebuddy-request": "1",
- "x-requested-with": "XMLHttpRequest",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
- "X-IDE-Name": "IDE",
- "X-IDE-Type": "IDE",
- "X-Product": "SaaS",
- "x-codebuddy-request": "1",
- "x-requested-with": "XMLHttpRequest",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > deepgram → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > gemini → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Content-Type": "application/json",
- "x-goog-api-key": "",
- },
- "nonStream": {
- "Content-Type": "application/json",
- "x-goog-api-key": "",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > gitlab → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > glm → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
- "nonStream": {
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
- "x-grok-client-identifier": "grok-shell",
- "x-grok-client-version": "0.2.99",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
- "x-grok-client-identifier": "grok-shell",
- "x-grok-client-version": "0.2.99",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
- "x-grok-client-identifier": "grok-shell",
- "x-grok-client-version": "0.2.99",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > kilo-gateway → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "kimchi/0.1.50",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "kimchi/0.1.50",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "User-Agent": "kimchi/0.1.50",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
- "nonStream": {
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > kimi-coding → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "X-Msh-Device-Id": "kimi-",
- "X-Msh-Device-Model": "darwin arm64",
- "X-Msh-Platform": "9router",
- "X-Msh-Version": "2.1.2",
- "x-api-key": "",
- },
- "nonStream": {
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "X-Msh-Device-Id": "kimi-",
- "X-Msh-Device-Model": "darwin arm64",
- "X-Msh-Platform": "9router",
- "X-Msh-Version": "2.1.2",
- "x-api-key": "",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "X-Msh-Device-Id": "kimi-",
- "X-Msh-Device-Model": "darwin arm64",
- "X-Msh-Platform": "9router",
- "X-Msh-Version": "2.1.2",
- "x-api-key": "",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > llm7 → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
- "nonStream": {
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > minimax-cn → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
- "nonStream": {
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
- "Anthropic-Version": "2023-06-01",
- "Content-Type": "application/json",
- "x-api-key": "",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > mistral → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > morph → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > nebius → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > nvidia → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > ollama → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > openai → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > openrouter → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://endpoint-proxy.local",
- "X-Title": "Endpoint Proxy",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://endpoint-proxy.local",
- "X-Title": "Endpoint Proxy",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://endpoint-proxy.local",
- "X-Title": "Endpoint Proxy",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > perplexity → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > tencent → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > tokenrouter → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > volcengine-ark → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > xai → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildHeaders (default executor providers) > zed → headers (apiKey / oauth) 1`] = `
-{
- "apiKey": {
- "Accept": "text/event-stream",
- "Authorization": "",
- "Content-Type": "application/json",
- "content-type": "application/json",
- },
- "nonStream": {
- "Authorization": "",
- "Content-Type": "application/json",
- "content-type": "application/json",
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "",
- "Content-Type": "application/json",
- "content-type": "application/json",
- },
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > alicode → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
- "stream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > alicode-intl → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
- "stream": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > alims-intl → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
- "stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.anthropic.com/v1/messages",
- "stream": "https://api.anthropic.com/v1/messages",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > api-airforce → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.airforce/v1/chat/completions",
- "stream": "https://api.airforce/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.assemblyai.com/v1/audio/transcriptions",
- "stream": "https://api.assemblyai.com/v1/audio/transcriptions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > baidu → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://qianfan.baidubce.com/v2/chat/completions",
- "stream": "https://qianfan.baidubce.com/v2/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > bazaarlink → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://bazaarlink.ai/api/v1/chat/completions",
- "stream": "https://bazaarlink.ai/api/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.blackbox.ai/chat/completions",
- "stream": "https://api.blackbox.ai/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > bluesminds → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.bluesminds.com/v1/chat/completions",
- "stream": "https://api.bluesminds.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > byteplus → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
- "stream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > cerebras → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.cerebras.ai/v1/chat/completions",
- "stream": "https://api.cerebras.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > chutes → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://llm.chutes.ai/v1/chat/completions",
- "stream": "https://llm.chutes.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > claude → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.anthropic.com/v1/messages?beta=true",
- "stream": "https://api.anthropic.com/v1/messages?beta=true",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.cline.bot/api/v1/chat/completions",
- "stream": "https://api.cline.bot/api/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.cline.bot/api/v1/chat/completions",
- "stream": "https://api.cline.bot/api/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
- "stream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > codebuddy-cn → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://copilot.tencent.com/v2/chat/completions",
- "stream": "https://copilot.tencent.com/v2/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > codebuddy-intl → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://www.codebuddy.ai/v2/chat/completions",
- "stream": "https://www.codebuddy.ai/v2/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.cohere.ai/v1/chat/completions",
- "stream": "https://api.cohere.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > deepgram → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.deepgram.com/v1/listen",
- "stream": "https://api.deepgram.com/v1/listen",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.deepseek.com/chat/completions",
- "stream": "https://api.deepseek.com/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.featherless.ai/v1/chat/completions",
- "stream": "https://api.featherless.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
- "stream": "https://api.fireworks.ai/inference/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > gemini → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://generativelanguage.googleapis.com/v1beta/models/test-model:generateContent",
- "stream": "https://generativelanguage.googleapis.com/v1beta/models/test-model:streamGenerateContent?alt=sse",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > gitlab → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://gitlab.com/api/v4/chat/completions",
- "stream": "https://gitlab.com/api/v4/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > glm → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.z.ai/api/anthropic/v1/messages?beta=true",
- "stream": "https://api.z.ai/api/anthropic/v1/messages?beta=true",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
- "stream": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://cli-chat-proxy.grok.com/v1/responses",
- "stream": "https://cli-chat-proxy.grok.com/v1/responses",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.groq.com/openai/v1/chat/completions",
- "stream": "https://api.groq.com/openai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > hyperbolic → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.hyperbolic.xyz/v1/chat/completions",
- "stream": "https://api.hyperbolic.xyz/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > kilo-gateway → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.kilo.ai/api/gateway/chat/completions",
- "stream": "https://api.kilo.ai/api/gateway/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.kilo.ai/api/openrouter/chat/completions",
- "stream": "https://api.kilo.ai/api/openrouter/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions",
- "stream": "https://llm.kimchi.dev/openai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
- "stream": "https://api.kimi.com/coding/v1/messages?beta=true",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > kimi-coding → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
- "stream": "https://api.kimi.com/coding/v1/messages?beta=true",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > llm7 → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.llm7.io/v1/chat/completions",
- "stream": "https://api.llm7.io/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > minimax → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.minimax.io/anthropic/v1/messages?beta=true",
- "stream": "https://api.minimax.io/anthropic/v1/messages?beta=true",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > minimax-cn → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.minimaxi.com/anthropic/v1/messages?beta=true",
- "stream": "https://api.minimaxi.com/anthropic/v1/messages?beta=true",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > mistral → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.mistral.ai/v1/chat/completions",
- "stream": "https://api.mistral.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > mmf → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.xiaomimimo.com/api/free-ai/openai/chat",
- "stream": "https://api.xiaomimimo.com/api/free-ai/openai/chat",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > morph → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.morphllm.com/v1/chat/completions",
- "stream": "https://api.morphllm.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.nanobananaapi.ai/v1/chat/completions",
- "stream": "https://api.nanobananaapi.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > nebius → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.studio.nebius.ai/v1/chat/completions",
- "stream": "https://api.studio.nebius.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > nvidia → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://integrate.api.nvidia.com/v1/chat/completions",
- "stream": "https://integrate.api.nvidia.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > ollama → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://ollama.com/api/chat",
- "stream": "https://ollama.com/api/chat",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > openai → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.openai.com/v1/chat/completions",
- "stream": "https://api.openai.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > openrouter → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://openrouter.ai/api/v1/chat/completions",
- "stream": "https://openrouter.ai/api/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.perplexity.ai/chat/completions",
- "stream": "https://api.perplexity.ai/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.perplexity.ai/v1/responses",
- "stream": "https://api.perplexity.ai/v1/responses",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > poolside → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://inference.poolside.ai/v1/chat/completions",
- "stream": "https://inference.poolside.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > sambanova → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.sambanova.ai/v1/chat/completions",
- "stream": "https://api.sambanova.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.siliconflow.com/v1/chat/completions",
- "stream": "https://api.siliconflow.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > tencent → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
- "stream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.together.xyz/v1/chat/completions",
- "stream": "https://api.together.xyz/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > tokenrouter → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.tokenrouter.com/v1/chat/completions",
- "stream": "https://api.tokenrouter.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.venice.ai/api/v1/chat/completions",
- "stream": "https://api.venice.ai/api/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
- "stream": "https://ai-gateway.vercel.sh/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > volcengine-ark → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
- "stream": "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > xai → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.x.ai/v1/chat/completions",
- "stream": "https://api.x.ai/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > xiaomi-mimo → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://api.xiaomimimo.com/v1/chat/completions",
- "stream": "https://api.xiaomimimo.com/v1/chat/completions",
-}
-`;
-
-exports[`GOLDEN buildUrl (default executor providers) > zed → url (stream + non-stream) 1`] = `
-{
- "nonStream": "https://cloud.zed.dev/completions",
- "stream": "https://cloud.zed.dev/completions",
-}
-`;
diff --git a/tests/translator/claude-claude-stream-decloak.test.js b/tests/translator/claude-claude-stream-decloak.test.js
new file mode 100644
index 00000000..35c0fcdf
--- /dev/null
+++ b/tests/translator/claude-claude-stream-decloak.test.js
@@ -0,0 +1,49 @@
+// Regression test: claude → claude streaming passthrough must still decloak
+// tool names. translateRequest() cloaks client tool names with CLAUDE_TOOL_SUFFIX
+// for OAuth-cloaked Claude providers (cloakToolsOnOAuth) even when source and
+// target formats match; the same-format fast path in translateResponse() used
+// to return chunks untouched, leaking the suffixed name (e.g. "run_code_ide")
+// to the client, which then rejected the call as an unknown tool.
+import { describe, it, expect } from "vitest";
+import "./registerAll.js";
+import { translateResponse } from "../../open-sse/translator/index.js";
+import { FORMATS } from "../../open-sse/translator/formats.js";
+import { CLAUDE_TOOL_SUFFIX } from "../../open-sse/config/appConstants.js";
+
+const CLOAKED = "run_code" + CLAUDE_TOOL_SUFFIX;
+
+const toolUseStart = (name) => ({
+ type: "content_block_start",
+ index: 1,
+ content_block: { type: "tool_use", id: "toolu_01XYZ", name, input: {} }
+});
+
+describe("Claude → Claude streaming passthrough (OAuth tool cloak)", () => {
+ const state = { toolNameMap: new Map([[CLOAKED, "run_code"]]) };
+
+ it("restores the original tool name on tool_use content_block_start", () => {
+ const [out] = translateResponse(FORMATS.CLAUDE, FORMATS.CLAUDE, toolUseStart(CLOAKED), state);
+ expect(out.content_block.name).toBe("run_code");
+ });
+
+ it("leaves uncloaked chunks untouched (identity passthrough)", () => {
+ const chunk = toolUseStart("Bash"); // decoy name, not in the map
+ const [out] = translateResponse(FORMATS.CLAUDE, FORMATS.CLAUDE, chunk, state);
+ expect(out).toBe(chunk);
+
+ const textChunk = { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } };
+ const [outText] = translateResponse(FORMATS.CLAUDE, FORMATS.CLAUDE, textChunk, state);
+ expect(outText).toBe(textChunk);
+ });
+
+ it("is a no-op when no cloak map is present", () => {
+ const chunk = toolUseStart(CLOAKED);
+ const [out] = translateResponse(FORMATS.CLAUDE, FORMATS.CLAUDE, chunk, {});
+ expect(out).toBe(chunk);
+ });
+
+ it("tolerates the null flush chunk", () => {
+ const [out] = translateResponse(FORMATS.CLAUDE, FORMATS.CLAUDE, null, state);
+ expect(out).toBeNull();
+ });
+});
diff --git a/tests/translator/thinking-unified.test.js b/tests/translator/thinking-unified.test.js
index ee48e3bb..e6214008 100644
--- a/tests/translator/thinking-unified.test.js
+++ b/tests/translator/thinking-unified.test.js
@@ -58,6 +58,18 @@ describe("extractThinking", () => {
it("no intent → null", () => {
expect(extractThinking({ messages: [] })).toBeNull();
});
+ it("reasoning_effort wins over thinking:{type:enabled} (no budget)", () => {
+ expect(extractThinking({
+ thinking: { type: "enabled" },
+ reasoning_effort: "high",
+ })).toEqual({ mode: "level", level: "high" });
+ });
+ it("reasoning.effort wins over thinking:{type:enabled} (no budget)", () => {
+ expect(extractThinking({
+ thinking: { type: "enabled" },
+ reasoning: { effort: "medium" },
+ })).toEqual({ mode: "level", level: "medium" });
+ });
});
describe("applyThinking per provider format", () => {
@@ -114,6 +126,27 @@ describe("applyThinking per provider format", () => {
expect(out.enable_thinking).toBe(false);
expect(out.thinking).toBeUndefined();
});
+ it.each([
+ ["high", "high"],
+ ["max", "max"],
+ ["xhigh", "max"],
+ ["low", "low"],
+ ["medium", "high"],
+ ["minimal", "low"],
+ ])("GLM-5.3 %s → reasoning_effort=%s (low|high|max only, per z.ai docs)", (input, expected) => {
+ const out = apply("openai", "glm-5.3", { reasoning_effort: input }, "glm-cn");
+ expect(out.thinking).toEqual({ type: "enabled" });
+ expect(out.reasoning_effort).toBe(expected);
+ });
+ it("GLM-5.2 also gets reasoning_effort (supported from 5.2 onward)", () => {
+ const out = apply("openai", "glm-5.2", { reasoning_effort: "low" }, "glm-cn");
+ expect(out.reasoning_effort).toBe("low");
+ });
+ it("GLM-4.7 (pre-5.2) does not get reasoning_effort — z.ai ignores it", () => {
+ const out = apply("openai", "glm-4.7", { reasoning_effort: "low" }, "glm-cn");
+ expect(out.thinking).toEqual({ type: "enabled" });
+ expect(out.reasoning_effort).toBeUndefined();
+ });
it("Qwen on → enable_thinking + thinking_budget", () => {
const out = apply("openai", "qwen3-max", { reasoning_effort: "medium" }, "qwen");
expect(out.enable_thinking).toBe(true);
diff --git a/tests/unit/antigravity-quota-routing.test.js b/tests/unit/antigravity-quota-routing.test.js
new file mode 100644
index 00000000..c7778a3c
--- /dev/null
+++ b/tests/unit/antigravity-quota-routing.test.js
@@ -0,0 +1,172 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ getProviderConnections: vi.fn(),
+ getSettings: vi.fn(),
+ resolveConnectionProxyConfig: vi.fn(),
+ getAntigravityUsage: vi.fn(),
+}));
+
+vi.mock("@/lib/localDb", () => ({
+ getProviderConnections: mocks.getProviderConnections,
+ getSettings: mocks.getSettings,
+ getProxyPools: vi.fn(),
+ validateApiKey: vi.fn(),
+ updateProviderConnection: vi.fn(),
+}));
+vi.mock("@/lib/network/connectionProxy", () => ({
+ resolveConnectionProxyConfig: mocks.resolveConnectionProxyConfig,
+ pickProxyPoolId: vi.fn(),
+}));
+vi.mock("@/shared/constants/providers.js", () => ({
+ FREE_PROVIDERS: {},
+ resolveProviderId: (provider) => provider,
+}));
+vi.mock("open-sse/services/usage/google.js", () => ({
+ getAntigravityUsage: mocks.getAntigravityUsage,
+}));
+vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() }));
+
+const { getAntigravityQuotaCache, handleAntigravityQuotaError, refreshAntigravityQuota } = await import("@/sse/services/antigravityQuota.js");
+const { getProviderCredentials } = await import("@/sse/services/auth.js");
+
+const MODEL = "claude-opus-4-6-thinking";
+const FUTURE_RESET = "2026-09-01T00:00:00.000Z";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ getAntigravityQuotaCache().clear();
+ mocks.resolveConnectionProxyConfig.mockResolvedValue({});
+ mocks.getSettings.mockResolvedValue({});
+});
+
+describe("Antigravity quota-aware routing", () => {
+ it("records exhausted upstream quota after 429 and returns its exact reset time", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
+ mocks.getAntigravityUsage.mockResolvedValue({ quotas: {
+ [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET },
+ } });
+
+ try {
+ await expect(handleAntigravityQuotaError("ag-a", 429, MODEL, "token", {}))
+ .resolves.toBe(Date.parse(FUTURE_RESET));
+ expect(getAntigravityQuotaCache().get("ag-a")[MODEL]).toEqual({
+ remainingPercentage: 0,
+ resetAt: FUTURE_RESET,
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("skips exhausted account/model and selects the next account", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
+ mocks.getProviderConnections.mockResolvedValue([
+ { id: "ag-a", email: "a@example.com", isActive: true },
+ { id: "ag-b", email: "b@example.com", isActive: true },
+ ]);
+ getAntigravityQuotaCache().set("ag-a", {
+ [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET },
+ });
+
+ try {
+ await expect(getProviderCredentials("antigravity", null, MODEL)).resolves.toMatchObject({
+ connectionId: "ag-b",
+ connectionName: "b@example.com",
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("reports retry time when every account is cache-blocked", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
+ mocks.getProviderConnections.mockResolvedValue([{ id: "ag-a", email: "a@example.com", isActive: true }]);
+ getAntigravityQuotaCache().set("ag-a", {
+ [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET },
+ });
+
+ try {
+ await expect(getProviderCredentials("antigravity", null, MODEL)).resolves.toMatchObject({
+ allRateLimited: true,
+ retryAfter: FUTURE_RESET,
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("lets account back into rotation once reset time has passed", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-09-01T00:00:01.000Z"));
+ mocks.getProviderConnections.mockResolvedValue([{ id: "ag-a", email: "a@example.com", isActive: true }]);
+ getAntigravityQuotaCache().set("ag-a", {
+ [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET },
+ });
+
+ try {
+ await expect(getProviderCredentials("antigravity", null, MODEL)).resolves.toMatchObject({
+ connectionId: "ag-a",
+ connectionName: "a@example.com",
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("coalesces concurrent quota refreshes for one account", async () => {
+ let resolveUsage;
+ mocks.getAntigravityUsage.mockReturnValue(new Promise(resolve => { resolveUsage = resolve; }));
+
+ const first = refreshAntigravityQuota("ag-concurrent", "token", {});
+ const second = refreshAntigravityQuota("ag-concurrent", "token", {});
+ resolveUsage({ quotas: { [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET } } });
+
+ await expect(Promise.all([first, second])).resolves.toEqual([
+ { [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET } },
+ { [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET } },
+ ]);
+ expect(mocks.getAntigravityUsage).toHaveBeenCalledTimes(1);
+ });
+
+ it("preserves strict proxy policy for usage refresh", async () => {
+ mocks.resolveConnectionProxyConfig.mockResolvedValue({ strictProxy: true });
+ mocks.getAntigravityUsage.mockResolvedValue({ quotas: {} });
+
+ await refreshAntigravityQuota("ag-strict-proxy", "token", {});
+
+ expect(mocks.getAntigravityUsage).toHaveBeenCalledWith("token", {}, expect.objectContaining({
+ strictProxy: true,
+ }));
+ });
+
+ it("keeps known cache when quota endpoint returns an error payload", async () => {
+ const cached = { [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET } };
+ getAntigravityQuotaCache().set("ag-error-response", cached);
+ mocks.getAntigravityUsage.mockResolvedValue({ message: "Unauthorized", quotas: {} });
+
+ await expect(refreshAntigravityQuota("ag-error-response", "token", {})).resolves.toBeNull();
+ expect(getAntigravityQuotaCache().get("ag-error-response")).toBe(cached);
+ });
+
+ it("throttles failed refresh attempts for 30 seconds", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
+ mocks.getAntigravityUsage.mockRejectedValue(new Error("usage unavailable"));
+
+ try {
+ await refreshAntigravityQuota("ag-failed-refresh", "token", {});
+ await refreshAntigravityQuota("ag-failed-refresh", "token", {});
+ expect(mocks.getAntigravityUsage).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(30_000);
+ await refreshAntigravityQuota("ag-failed-refresh", "token", {});
+ expect(mocks.getAntigravityUsage).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/tests/unit/cached-token-usage.test.js b/tests/unit/cached-token-usage.test.js
index 878110d0..9b3ebd97 100644
--- a/tests/unit/cached-token-usage.test.js
+++ b/tests/unit/cached-token-usage.test.js
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { canonicalizeUsage, extractUsage, mergeUsage } from "../../open-sse/utils/usageTracking.js";
import { calculateCostFromTokens } from "../../open-sse/providers/pricing.js";
-import { toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js";
+import { buildUsage, toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js";
// Canonical convention (single source of truth for storage + cost):
// prompt_tokens = total input INCLUDING cache read + cache creation
@@ -49,6 +49,18 @@ describe("canonicalizeUsage", () => {
expect(out.reasoning_tokens).toBe(40);
});
+ it("reads cached_tokens from the nested buildUsage() shape", () => {
+ // buildUsage() only emits cache reads under prompt_tokens_details. The
+ // Responses translator overwrites state.usage with that shape on
+ // response.completed, so a top-level-only read silently drops the cache
+ // count for every Responses provider (codex, grok-cli, ...).
+ const out = canonicalizeUsage(
+ buildUsage({ promptTokens: 330, completionTokens: 50, totalTokens: 380, cachedTokens: 200 })
+ );
+ expect(out.prompt_tokens).toBe(330);
+ expect(out.cached_tokens).toBe(200);
+ });
+
it("handles no-cache usage", () => {
const out = canonicalizeUsage({ prompt_tokens: 100, completion_tokens: 50 });
expect(out.prompt_tokens).toBe(100);
diff --git a/tests/unit/claude-cloaking.test.js b/tests/unit/claude-cloaking.test.js
index 6b6e5dc6..cd81081a 100644
--- a/tests/unit/claude-cloaking.test.js
+++ b/tests/unit/claude-cloaking.test.js
@@ -3,10 +3,11 @@
*
* Tests cover:
* - cloakClaudeTools() - tool renaming and forced tool_choice suffixing
+ * - decloakStreamChunk() - restoring tool names in streamed Claude SSE events
*/
import { describe, it, expect } from "vitest";
-import { cloakClaudeTools } from "../../open-sse/utils/claudeCloaking.js";
+import { cloakClaudeTools, decloakStreamChunk } from "../../open-sse/utils/claudeCloaking.js";
import { CLAUDE_TOOL_SUFFIX } from "../../open-sse/config/appConstants.js";
describe("cloakClaudeTools", () => {
@@ -74,3 +75,44 @@ describe("cloakClaudeTools", () => {
expect(toolNameMap).toBeNull();
});
});
+
+describe("decloakStreamChunk", () => {
+ // Cloaked exactly as cloakClaudeTools() does on the request side
+ const toolNameMap = new Map([["run_code" + CLAUDE_TOOL_SUFFIX, "run_code"]]);
+
+ const toolUseStart = (name) => ({
+ type: "content_block_start",
+ index: 1,
+ content_block: { type: "tool_use", id: "toolu_01abc", name, input: {} }
+ });
+
+ it("restores the original name on a tool_use content_block_start", () => {
+ const out = decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), toolNameMap);
+ expect(out.content_block.name).toBe("run_code");
+ });
+
+ it("does not mutate the input chunk", () => {
+ const chunk = toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX);
+ decloakStreamChunk(chunk, toolNameMap);
+ expect(chunk.content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX);
+ });
+
+ it("passes through names the map does not know (e.g. decoy tools)", () => {
+ const chunk = toolUseStart("Bash");
+ expect(decloakStreamChunk(chunk, toolNameMap)).toBe(chunk);
+ });
+
+ it("passes through non-tool_use events unchanged", () => {
+ const textStart = { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } };
+ expect(decloakStreamChunk(textStart, toolNameMap)).toBe(textStart);
+
+ const delta = { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "{}" } };
+ expect(decloakStreamChunk(delta, toolNameMap)).toBe(delta);
+ });
+
+ it("tolerates null chunks and missing maps (stream flush path)", () => {
+ expect(decloakStreamChunk(null, toolNameMap)).toBeNull();
+ expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), null).content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX);
+ expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), new Map()).content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX);
+ });
+});
diff --git a/tests/unit/codex-spark-quota-tracking.test.js b/tests/unit/codex-spark-quota-tracking.test.js
new file mode 100644
index 00000000..bde434cf
--- /dev/null
+++ b/tests/unit/codex-spark-quota-tracking.test.js
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "vitest";
+import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
+
+describe("Codex Spark Quota Tracking (#3431)", () => {
+ it("correctly normalizes spark_session and spark_weekly quotas with display labels", () => {
+ const mockCodexUsage = {
+ plan: "team",
+ quotas: {
+ session: { used: 20, total: 100, remaining: 80, resetAt: "2026-08-22T05:00:00.000Z" },
+ weekly: { used: 40, total: 100, remaining: 60, resetAt: "2026-08-28T05:00:00.000Z" },
+ review_session: { used: 0, total: 100, remaining: 100, resetAt: "2026-08-22T05:00:00.000Z" },
+ spark_session: { used: 12, total: 100, remaining: 88, resetAt: "2026-08-22T05:00:00.000Z" },
+ spark_weekly: { used: 25, total: 100, remaining: 75, resetAt: "2026-08-28T05:00:00.000Z" },
+ },
+ };
+
+ const parsed = parseQuotaData("codex", mockCodexUsage);
+
+ const sparkSession = parsed.find((q) => q.name === "Spark (5h)");
+ const sparkWeekly = parsed.find((q) => q.name === "Spark (Weekly)");
+ const session = parsed.find((q) => q.name === "5h");
+ const weekly = parsed.find((q) => q.name === "Weekly");
+
+ expect(sparkSession).toBeDefined();
+ expect(sparkSession.used).toBe(12);
+ expect(sparkSession.remaining).toBe(88);
+
+ expect(sparkWeekly).toBeDefined();
+ expect(sparkWeekly.used).toBe(25);
+ expect(sparkWeekly.remaining).toBe(75);
+
+ expect(session).toBeDefined();
+ expect(session.used).toBe(20);
+ expect(weekly).toBeDefined();
+ expect(weekly.used).toBe(40);
+ });
+});
diff --git a/tests/unit/commandcode-executor.test.js b/tests/unit/commandcode-executor.test.js
new file mode 100644
index 00000000..bd0a23cd
--- /dev/null
+++ b/tests/unit/commandcode-executor.test.js
@@ -0,0 +1,192 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+ parseCommandCodeError,
+ inspectAndWrapCommandCodeResponse,
+ CommandCodeExecutor,
+} from "../../open-sse/executors/commandcode.js";
+import { handleComboChat } from "../../open-sse/services/combo.js";
+
+function createNdjsonStream(lines) {
+ const encoder = new TextEncoder();
+ return new ReadableStream({
+ start(controller) {
+ for (const line of lines) {
+ controller.enqueue(encoder.encode(typeof line === "string" ? line : JSON.stringify(line) + "\n"));
+ }
+ controller.close();
+ },
+ });
+}
+
+describe("parseCommandCodeError", () => {
+ it("parses user exact error payload with statusCode 503 and isRetryable", () => {
+ const event = {
+ type: "error",
+ error: {
+ type: "server_error",
+ message: "Service temporarily unavailable. Please try again shortly.",
+ statusCode: 503,
+ isRetryable: true,
+ },
+ };
+ const parsed = parseCommandCodeError(event);
+ expect(parsed.statusCode).toBe(503);
+ expect(parsed.message).toBe("Service temporarily unavailable. Please try again shortly.");
+ expect(parsed.type).toBe("server_error");
+ });
+
+ it("handles string error message", () => {
+ const event = {
+ type: "error",
+ message: "Rate limit exceeded. Please wait 30s.",
+ };
+ const parsed = parseCommandCodeError(event);
+ expect(parsed.statusCode).toBe(429);
+ expect(parsed.message).toBe("Rate limit exceeded. Please wait 30s.");
+ });
+
+ it("handles plain error string in error property", () => {
+ const event = {
+ type: "error",
+ error: "Unauthorized access",
+ };
+ const parsed = parseCommandCodeError(event);
+ expect(parsed.statusCode).toBe(401);
+ expect(parsed.message).toBe("Unauthorized access");
+ });
+});
+
+describe("inspectAndWrapCommandCodeResponse", () => {
+ it("converts initial upstream 200 with error event to 503 Response", async () => {
+ const ndjsonBody = createNdjsonStream([
+ JSON.stringify({
+ type: "error",
+ error: {
+ type: "server_error",
+ message: "Service temporarily unavailable. Please try again shortly.",
+ statusCode: 503,
+ isRetryable: true,
+ },
+ }) + "\n",
+ ]);
+
+ const fakeResponse = new Response(ndjsonBody, {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+
+ const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
+ expect(result.ok).toBe(false);
+ expect(result.status).toBe(503);
+
+ const body = await result.json();
+ expect(body.error.message).toContain("Service temporarily unavailable");
+ expect(body.error.code).toBe(503);
+ });
+
+ it("converts initial upstream 200 with start/start-step followed by error to 503 Response", async () => {
+ const ndjsonBody = createNdjsonStream([
+ JSON.stringify({ type: "start" }) + "\n",
+ JSON.stringify({ type: "start-step" }) + "\n",
+ JSON.stringify({
+ type: "error",
+ error: {
+ type: "server_error",
+ message: "Service temporarily unavailable. Please try again shortly.",
+ statusCode: 503,
+ isRetryable: true,
+ },
+ }) + "\n",
+ ]);
+
+ const fakeResponse = new Response(ndjsonBody, {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+
+ const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
+ expect(result.ok).toBe(false);
+ expect(result.status).toBe(503);
+
+ const body = await result.json();
+ expect(body.error.message).toContain("Service temporarily unavailable");
+ });
+
+ it("streams successful responses when content is emitted", async () => {
+ const ndjsonBody = createNdjsonStream([
+ JSON.stringify({ type: "start" }) + "\n",
+ JSON.stringify({ type: "text-delta", text: "Hello from Laguna" }) + "\n",
+ JSON.stringify({ type: "finish" }) + "\n",
+ ]);
+
+ const fakeResponse = new Response(ndjsonBody, {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+
+ const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
+ expect(result.ok).toBe(true);
+ expect(result.status).toBe(200);
+
+ const text = await result.text();
+ expect(text).toContain("Hello from Laguna");
+ expect(text).toContain("data: [DONE]");
+ });
+});
+
+describe("CommandCode in Combo Fallback", () => {
+ it("automatically falls back to next model when commandcode returns 503 error", async () => {
+ const log = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ debug: vi.fn(),
+ };
+
+ const handleSingleModel = vi.fn(async (body, modelStr) => {
+ if (modelStr === "commandcode/poolside/laguna-s-2.1-free") {
+ // Simulated failed CommandCode response
+ return new Response(
+ JSON.stringify({
+ error: {
+ message: "Service temporarily unavailable. Please try again shortly.",
+ type: "server_error",
+ code: 503,
+ },
+ }),
+ { status: 503, headers: { "Content-Type": "application/json" } }
+ );
+ }
+
+ if (modelStr === "openai/gpt-4o-mini") {
+ // Fallback model succeeds
+ return new Response(
+ JSON.stringify({
+ id: "chatcmpl-test",
+ choices: [{ message: { role: "assistant", content: "Fallback success!" } }],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ }
+
+ return new Response("Not found", { status: 404 });
+ });
+
+ const comboResponse = await handleComboChat({
+ body: { messages: [{ role: "user", content: "Hello" }] },
+ models: ["commandcode/poolside/laguna-s-2.1-free", "openai/gpt-4o-mini"],
+ handleSingleModel,
+ log,
+ comboName: "test-combo",
+ comboStrategy: "fallback",
+ });
+
+ expect(comboResponse.ok).toBe(true);
+ expect(comboResponse.status).toBe(200);
+
+ const data = await comboResponse.json();
+ expect(data.choices[0].message.content).toBe("Fallback success!");
+ expect(handleSingleModel).toHaveBeenCalledTimes(2);
+ expect(handleSingleModel).toHaveBeenNthCalledWith(1, expect.anything(), "commandcode/poolside/laguna-s-2.1-free");
+ expect(handleSingleModel).toHaveBeenNthCalledWith(2, expect.anything(), "openai/gpt-4o-mini");
+ });
+});
diff --git a/tests/unit/executor-const-guard.test.js b/tests/unit/executor-const-guard.test.js
index 2c466ce1..e84f5733 100644
--- a/tests/unit/executor-const-guard.test.js
+++ b/tests/unit/executor-const-guard.test.js
@@ -9,6 +9,7 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../open-sse/config/ru
import mimoFree from "../../open-sse/providers/registry/mimo-free.js";
import opencode from "../../open-sse/providers/registry/opencode.js";
import antigravity from "../../open-sse/providers/registry/antigravity.js";
+import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js";
describe("compat base URLs / version", () => {
it("OPENAI_COMPAT_BASE", () => {
@@ -46,3 +47,36 @@ describe("antigravity retry (intentional change: 429=6, 503=3)", () => {
expect(antigravity.transport.retry["503"].attempts).toBe(3);
});
});
+
+describe("OpenCode Free endpoint routing", () => {
+ const MUSE = "muse-spark-1.2-contributor-free";
+
+ it("declares the Responses format only on the Muse Spark model", () => {
+ expect(opencode.transport.format).toBeUndefined();
+ const muse = opencode.models.find((m) => m.id === MUSE);
+ expect(muse?.targetFormat).toBe("openai-responses");
+ });
+
+ it("routes Muse Spark to /responses and every other model to /chat/completions", () => {
+ const executor = new OpenCodeExecutor();
+ expect(executor.buildUrl(MUSE)).toBe("https://opencode.ai/zen/v1/responses");
+ expect(executor.buildUrl(`${MUSE}(xhigh)`)).toBe("https://opencode.ai/zen/v1/responses");
+ expect(executor.buildUrl("big-pickle")).toBe("https://opencode.ai/zen/v1/chat/completions");
+ expect(executor.buildUrl("hy3-free")).toBe("https://opencode.ai/zen/v1/chat/completions");
+ });
+
+ it("normalizes Chat token/thinking fields only for the Responses model", () => {
+ const executor = new OpenCodeExecutor();
+ const muse = { max_tokens: 4096, reasoning_effort: "high" };
+ executor.transformRequest(MUSE, muse, true, {});
+ expect(muse.max_output_tokens).toBe(4096);
+ expect(muse.max_tokens).toBeUndefined();
+ expect(muse.reasoning).toEqual({ effort: "high", summary: "auto" });
+
+ const chat = { max_tokens: 4096, reasoning_effort: "high" };
+ executor.transformRequest("big-pickle", chat, true, {});
+ expect(chat.max_tokens).toBe(4096);
+ expect(chat.max_output_tokens).toBeUndefined();
+ expect(chat.reasoning_effort).toBe("high");
+ });
+});
diff --git a/tests/unit/gemini-37-integration.test.js b/tests/unit/gemini-37-integration.test.js
new file mode 100644
index 00000000..ed842d6f
--- /dev/null
+++ b/tests/unit/gemini-37-integration.test.js
@@ -0,0 +1,100 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createRequire } from "node:module";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+import { getModelUpstreamId } from "../../open-sse/config/providerModels.js";
+import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
+import { applyThinking, stripThinkingSuffix } from "../../open-sse/translator/concerns/thinkingUnified.js";
+import gemini from "../../open-sse/providers/registry/gemini.js";
+import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
+import { MITM_TOOLS } from "../../src/shared/constants/cliTools.js";
+
+const require = createRequire(import.meta.url);
+const mitmConfig = require("../../src/mitm/config.js");
+const here = dirname(fileURLToPath(import.meta.url));
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("Gemini 3.7 Antigravity tiers", () => {
+ it.each(["high", "medium", "low"])(
+ "maps the %s tier to the shared upstream model with matching thinking level",
+ (tier) => {
+ const publicModel = `gemini-3.7-flash-${tier}`;
+ const upstreamModel = getModelUpstreamId("ag", publicModel);
+ const body = {
+ model: stripThinkingSuffix(upstreamModel),
+ request: {
+ contents: [{ role: "user", parts: [{ text: "hello" }] }],
+ generationConfig: {},
+ },
+ };
+
+ applyThinking("antigravity", upstreamModel, body, "antigravity");
+ const finalBody = new AntigravityExecutor().transformRequest(
+ publicModel,
+ body,
+ true,
+ { projectId: "project", connectionId: "connection" }
+ );
+
+ expect(upstreamModel).toBe(`gemini-3.7-flash-tiered(${tier})`);
+ expect(finalBody.model).toBe("gemini-3.7-flash-tiered");
+ expect(finalBody.request.generationConfig.thinkingConfig).toEqual({
+ thinkingLevel: tier,
+ includeThoughts: true,
+ });
+ }
+ );
+});
+
+describe("Gemini 3.7 MITM model extraction", () => {
+ it.each(["high", "medium", "low"])("extracts the %s thinking tier for gemini-3.7-flash-tiered", (tier) => {
+ const body = Buffer.from(JSON.stringify({
+ request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } },
+ }));
+
+ expect(mitmConfig.extractModel(
+ "/v1internal/models/gemini-3.7-flash-tiered:streamGenerateContent",
+ body
+ )).toBe(`gemini-3.7-flash-${tier}`);
+ });
+
+ it("defaults invalid or missing thinking levels to medium", () => {
+ const body = Buffer.from(JSON.stringify({
+ request: { generationConfig: { thinkingConfig: { thinkingLevel: "unknown" } } },
+ }));
+
+ expect(mitmConfig.extractModel(
+ "/v1internal/models/gemini-3.7-flash-tiered:streamGenerateContent",
+ body
+ )).toBe("gemini-3.7-flash-medium");
+ });
+});
+
+describe("Gemini 3.7 MITM tools and catalog", () => {
+ it("includes gemini-3.7-flash tiers in MITM_TOOLS defaultModels", () => {
+ const defaultModelIds = MITM_TOOLS.antigravity.defaultModels.map((m) => m.id);
+ expect(defaultModelIds).toContain("gemini-3.7-flash-high");
+ expect(defaultModelIds).toContain("gemini-3.7-flash-medium");
+ expect(defaultModelIds).toContain("gemini-3.7-flash-low");
+ });
+
+ it("exposes the direct Gemini 3.7 API models and pricing", () => {
+ const ids = gemini.models.map((model) => model.id);
+ expect(ids).toContain("gemini-3.7-flash");
+ expect(MODEL_PRICING["gemini-3.7-flash"]).toMatchObject({ input: 1.5, output: 7.5 });
+ });
+
+ it("keeps the standalone CLI Antigravity catalog synchronized", () => {
+ const source = readFileSync(join(here, "../../cli/src/cli/menus/providers.js"), "utf8");
+ const agCatalog = source.match(/\n ag: \[([\s\S]*?)\n \],/)?.[1] || "";
+
+ expect(agCatalog).toContain("gemini-3.7-flash-high");
+ expect(agCatalog).toContain("gemini-3.7-flash-medium");
+ expect(agCatalog).toContain("gemini-3.7-flash-low");
+ });
+});
diff --git a/tests/unit/glm-usage.test.js b/tests/unit/glm-usage.test.js
new file mode 100644
index 00000000..b381f112
--- /dev/null
+++ b/tests/unit/glm-usage.test.js
@@ -0,0 +1,192 @@
+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 { getGlmUsage } from "../../open-sse/services/usage/glm.js";
+import {
+ USAGE_SUPPORTED_PROVIDERS,
+ USAGE_APIKEY_PROVIDERS,
+} from "../../src/shared/constants/providers.js";
+
+function jsonResponse(body, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+const SAMPLE_GLM_CREDIT_USAGE = {
+ code: 200,
+ msg: "Operation successful",
+ data: {
+ limits: [
+ {
+ type: "CREDIT_LIMIT",
+ unit: 3,
+ number: 5,
+ usage: 2000,
+ currentValue: 0,
+ remaining: 1999,
+ percentage: 25,
+ nextResetTime: 1787905548392,
+ },
+ {
+ type: "CREDIT_LIMIT",
+ unit: 6,
+ number: 1,
+ usage: 10000,
+ currentValue: 0,
+ remaining: 9999,
+ percentage: 10,
+ nextResetTime: 1788492142997,
+ },
+ ],
+ level: "lite",
+ },
+ success: true,
+};
+
+const SAMPLE_GLM_TOKENS_USAGE = {
+ code: 200,
+ msg: "Operation successful",
+ data: {
+ limits: [
+ {
+ type: "TOKENS_LIMIT",
+ percentage: 40,
+ nextResetTime: 1787905548392,
+ },
+ ],
+ level: "standard",
+ },
+ success: true,
+};
+
+describe("glm registry usage flags", () => {
+ it("is listed for apikey quota dashboard", () => {
+ expect(USAGE_SUPPORTED_PROVIDERS).toContain("glm");
+ expect(USAGE_SUPPORTED_PROVIDERS).toContain("glm-cn");
+ expect(USAGE_APIKEY_PROVIDERS).toContain("glm");
+ expect(USAGE_APIKEY_PROVIDERS).toContain("glm-cn");
+ });
+});
+
+describe("getGlmUsage and getUsageForProvider(glm)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("handles CREDIT_LIMIT with session 5h and weekly 7d quotas", async () => {
+ proxyAwareFetch.mockResolvedValueOnce(jsonResponse(SAMPLE_GLM_CREDIT_USAGE));
+
+ const usage = await getUsageForProvider({
+ provider: "glm",
+ apiKey: "glm-key-123",
+ });
+
+ expect(usage.message).toBeUndefined();
+ expect(usage.plan).toBe("Lite");
+ expect(usage.quotas["Session (5h)"]).toEqual({
+ used: 25,
+ total: 100,
+ remaining: 75,
+ remainingPercentage: 75,
+ resetAt: new Date(1787905548392).toISOString(),
+ unlimited: false,
+ });
+ expect(usage.quotas["Weekly (7d)"]).toEqual({
+ used: 100 ? 10 : 10,
+ total: 100,
+ remaining: 90,
+ remainingPercentage: 90,
+ resetAt: new Date(1788492142997).toISOString(),
+ unlimited: false,
+ });
+ });
+
+ it("handles TOKENS_LIMIT quotas", async () => {
+ proxyAwareFetch.mockResolvedValueOnce(jsonResponse(SAMPLE_GLM_TOKENS_USAGE));
+
+ const usage = await getUsageForProvider({
+ provider: "glm-cn",
+ apiKey: "glm-cn-key",
+ });
+
+ expect(usage.message).toBeUndefined();
+ expect(usage.plan).toBe("Standard");
+ expect(usage.quotas["Tokens"]).toEqual({
+ used: 40,
+ total: 100,
+ remaining: 60,
+ remainingPercentage: 60,
+ resetAt: new Date(1787905548392).toISOString(),
+ unlimited: false,
+ });
+ });
+
+ it("handles fallback key for custom limit units", async () => {
+ proxyAwareFetch.mockResolvedValueOnce(
+ jsonResponse({
+ code: 200,
+ data: {
+ limits: [
+ {
+ type: "CREDIT_LIMIT",
+ unit: 99,
+ number: 12,
+ percentage: 5,
+ nextResetTime: 0,
+ },
+ ],
+ level: "pro",
+ },
+ })
+ );
+
+ const usage = await getGlmUsage("glm-key", "glm");
+ expect(usage.plan).toBe("Pro");
+ expect(usage.quotas["Limit (12)"]).toEqual({
+ used: 5,
+ total: 100,
+ remaining: 95,
+ remainingPercentage: 95,
+ resetAt: null,
+ unlimited: false,
+ });
+ });
+
+ it("surfaces invalid key message on 401", async () => {
+ proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401));
+
+ const usage = await getUsageForProvider({
+ provider: "glm",
+ apiKey: "invalid-key",
+ });
+
+ expect(usage.message).toMatch(/invalid or expired/i);
+ });
+
+ it("handles non-200 error response", async () => {
+ proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "server error" }, 500));
+
+ const usage = await getUsageForProvider({
+ provider: "glm",
+ apiKey: "valid-key",
+ });
+
+ expect(usage.message).toMatch(/GLM quota API error \(500\)/);
+ });
+
+ it("returns message when apiKey is missing", async () => {
+ const usage = await getUsageForProvider({
+ provider: "glm",
+ apiKey: "",
+ });
+
+ expect(usage.message).toBe("GLM API key not available.");
+ });
+});
diff --git a/tests/unit/headroom.test.js b/tests/unit/headroom.test.js
index a5d28112..10989edc 100644
--- a/tests/unit/headroom.test.js
+++ b/tests/unit/headroom.test.js
@@ -202,6 +202,101 @@ describe("compressWithHeadroom", () => {
expect(stats).toBeNull();
expect(global.fetch).not.toHaveBeenCalled();
});
+
+ describe("timeout normalization", () => {
+ const mockResponse = JSON.stringify({
+ messages: [{ role: "user", content: "short" }],
+ tokens_before: 100,
+ tokens_after: 20,
+ tokens_saved: 80,
+ });
+
+ function makeSuccessfulFetch() {
+ global.fetch = vi.fn(async () =>
+ new Response(mockResponse, { status: 200 })
+ );
+ }
+
+ function captureTimeoutCalls() {
+ const calls = [];
+ vi.spyOn(AbortSignal, "timeout").mockImplementation((ms) => {
+ calls.push(ms);
+ const controller = new AbortController();
+ return controller.signal;
+ });
+ return calls;
+ }
+
+ it("passes a valid positive timeout to AbortSignal.timeout", async () => {
+ makeSuccessfulFetch();
+ const calls = captureTimeoutCalls();
+ const body = { messages: [{ role: "user", content: "hello" }] };
+
+ await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: 5000 });
+
+ expect(calls).toContain(5000);
+ });
+
+ it("falls back to the default timeout when timeoutMs is null", async () => {
+ makeSuccessfulFetch();
+ const calls = captureTimeoutCalls();
+ const body = { messages: [{ role: "user", content: "hello" }] };
+
+ await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: null });
+
+ expect(calls).toContain(3000);
+ });
+
+ it("falls back to the default timeout when timeoutMs is 0", async () => {
+ makeSuccessfulFetch();
+ const calls = captureTimeoutCalls();
+ const body = { messages: [{ role: "user", content: "hello" }] };
+
+ await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: 0 });
+
+ expect(calls).toContain(3000);
+ });
+
+ it("falls back to the default timeout when timeoutMs is negative", async () => {
+ makeSuccessfulFetch();
+ const calls = captureTimeoutCalls();
+ const body = { messages: [{ role: "user", content: "hello" }] };
+
+ await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: -100 });
+
+ expect(calls).toContain(3000);
+ });
+
+ it("falls back to the default timeout when timeoutMs is NaN", async () => {
+ makeSuccessfulFetch();
+ const calls = captureTimeoutCalls();
+ const body = { messages: [{ role: "user", content: "hello" }] };
+
+ await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: NaN });
+
+ expect(calls).toContain(3000);
+ });
+
+ it("falls back to the default timeout when timeoutMs is Infinity", async () => {
+ makeSuccessfulFetch();
+ const calls = captureTimeoutCalls();
+ const body = { messages: [{ role: "user", content: "hello" }] };
+
+ await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: Infinity });
+
+ expect(calls).toContain(3000);
+ });
+
+ it("falls back to the default timeout when timeoutMs is a string", async () => {
+ makeSuccessfulFetch();
+ const calls = captureTimeoutCalls();
+ const body = { messages: [{ role: "user", content: "hello" }] };
+
+ await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: "5000" });
+
+ expect(calls).toContain(3000);
+ });
+ });
});
describe("formatHeadroomLog", () => {
diff --git a/tests/unit/minimax-transport-target-format.test.js b/tests/unit/minimax-transport-target-format.test.js
new file mode 100644
index 00000000..489252da
--- /dev/null
+++ b/tests/unit/minimax-transport-target-format.test.js
@@ -0,0 +1,189 @@
+/**
+ * Multi-transport providers must keep the request body and selected endpoint on
+ * the same wire format. MiniMax-M3 declares a Claude target for compatibility,
+ * but an OpenAI client should use MiniMax's matching OpenAI transport without
+ * an OpenAI -> Claude translation.
+ * Regression: https://github.com/decolua/9router/issues/3418
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const {
+ executeMock,
+ translateRequestMock,
+ handleNonStreamingResponseMock,
+} = vi.hoisted(() => ({
+ executeMock: vi.fn(),
+ translateRequestMock: vi.fn((sourceFormat, targetFormat, model, body) => ({
+ ...body,
+ model,
+ _translatedFrom: sourceFormat,
+ _translatedTo: targetFormat,
+ })),
+ handleNonStreamingResponseMock: vi.fn(async () => ({ success: true })),
+}));
+
+vi.mock("../../open-sse/executors/index.js", () => ({
+ getExecutor: vi.fn(() => ({
+ execute: executeMock,
+ refreshCredentials: vi.fn().mockResolvedValue(null),
+ })),
+}));
+
+vi.mock("../../open-sse/translator/index.js", () => ({
+ translateRequest: translateRequestMock,
+}));
+
+vi.mock("../../open-sse/handlers/chatCore/nonStreamingHandler.js", () => ({
+ handleNonStreamingResponse: handleNonStreamingResponseMock,
+}));
+
+vi.mock("../../open-sse/utils/requestLogger.js", () => ({
+ createRequestLogger: vi.fn(async () => ({
+ logClientRawRequest: vi.fn(),
+ logRawRequest: vi.fn(),
+ logTargetRequest: vi.fn(),
+ logError: vi.fn(),
+ })),
+}));
+
+vi.mock("../../open-sse/utils/clientDetector.js", () => ({
+ detectClientTool: vi.fn(() => null),
+ isNativePassthrough: vi.fn(() => false),
+}));
+
+vi.mock("../../open-sse/utils/bypassHandler.js", () => ({
+ handleBypassRequest: vi.fn(() => null),
+}));
+
+vi.mock("../../open-sse/utils/streamHandler.js", () => ({
+ createStreamController: vi.fn(() => ({
+ signal: undefined,
+ handleComplete: vi.fn(),
+ handleError: vi.fn(),
+ })),
+}));
+
+vi.mock("../../open-sse/services/tokenRefresh.js", () => ({
+ refreshWithRetry: vi.fn(),
+}));
+
+vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
+ default: vi.fn(),
+ proxyAwareFetch: vi.fn(),
+}));
+
+vi.mock("../../open-sse/translator/formats/claude.js", () => ({
+ normalizeClaudePassthrough: vi.fn(),
+ anchorClaudeCache: vi.fn(),
+}));
+
+vi.mock("../../open-sse/utils/toolDeduper.js", () => ({
+ dedupeTools: vi.fn((tools) => ({ tools, stripped: [] })),
+}));
+
+vi.mock("../../open-sse/rtk/caveman.js", () => ({ injectCaveman: vi.fn() }));
+vi.mock("../../open-sse/rtk/ponytail.js", () => ({ injectPonytail: vi.fn() }));
+vi.mock("../../open-sse/rtk/index.js", () => ({
+ compressMessages: vi.fn(() => null),
+ formatRtkLog: vi.fn(() => ""),
+}));
+vi.mock("../../open-sse/rtk/headroom.js", () => ({
+ compressWithHeadroom: vi.fn(async () => null),
+ formatHeadroomLog: vi.fn(() => ""),
+ formatHeadroomSizeLog: vi.fn(() => ""),
+ isHeadroomPhantomSavings: vi.fn(() => false),
+}));
+vi.mock("../../open-sse/rtk/pxpipe.js", () => ({
+ compressWithPxpipe: vi.fn(async () => ({ body: null, summary: null })),
+}));
+
+vi.mock("../../open-sse/translator/concerns/prefetch.js", () => ({
+ prefetchRemoteImages: vi.fn(async () => 0),
+}));
+
+vi.mock("../../open-sse/handlers/chatCore/requestDetail.js", () => ({
+ buildRequestDetail: vi.fn((detail) => detail),
+ extractRequestConfig: vi.fn((body, stream) => ({ body, stream })),
+}));
+
+vi.mock("../../open-sse/utils/error.js", () => ({
+ createErrorResult: vi.fn((status, message) => ({ success: false, status, error: message })),
+ formatProviderError: vi.fn((error) => error.message),
+ parseUpstreamError: vi.fn(),
+}));
+
+vi.mock("@/lib/usageDb.js", () => ({
+ trackPendingRequest: vi.fn(),
+ appendRequestLog: vi.fn(() => Promise.resolve()),
+ saveRequestDetail: vi.fn(() => Promise.resolve()),
+}));
+
+function makeOptions(body) {
+ return {
+ body,
+ modelInfo: { provider: "minimax-cn", model: "MiniMax-M3" },
+ credentials: { apiKey: "test-api-key", providerSpecificData: {} },
+ clientRawRequest: {
+ endpoint: "/v1/chat/completions",
+ body,
+ headers: { accept: "application/json" },
+ },
+ connectionId: "test-connection",
+ log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ };
+}
+
+describe("MiniMax-M3 multi-transport routing", () => {
+ beforeEach(() => {
+ executeMock.mockReset();
+ translateRequestMock.mockClear();
+ handleNonStreamingResponseMock.mockClear();
+ executeMock.mockResolvedValue({
+ response: new Response("{}", {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ url: "https://api.minimaxi.com/v1/chat/completions",
+ headers: {},
+ transformedBody: {},
+ });
+ });
+
+ it("keeps OpenAI image blocks on the matching OpenAI transport", async () => {
+ const imageBlock = {
+ type: "image_url",
+ image_url: { url: "data:image/png;base64,AAAB" },
+ };
+ const body = {
+ model: "minimax-cn/MiniMax-M3",
+ stream: false,
+ messages: [{
+ role: "user",
+ content: [{ type: "text", text: "Describe this image" }, imageBlock],
+ }],
+ };
+
+ const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js");
+ await handleChatCore(makeOptions(body));
+
+ expect(translateRequestMock).toHaveBeenCalledWith(
+ "openai",
+ "openai",
+ "MiniMax-M3",
+ expect.any(Object),
+ false,
+ expect.any(Object),
+ "minimax-cn",
+ expect.any(Object),
+ expect.anything(),
+ "test-connection",
+ null,
+ );
+ expect(executeMock).toHaveBeenCalledTimes(1);
+ const requestBody = executeMock.mock.calls[0][0].body;
+ expect(requestBody.messages[0].content).toContainEqual(imageBlock);
+ expect(requestBody._translatedTo).toBe("openai");
+ expect(requestBody).not.toHaveProperty("system");
+ expect(executeMock.mock.calls[0][0].credentials.runtimeTransport.format).toBe("openai");
+ });
+});
diff --git a/tests/unit/ollama-stream-tail.test.js b/tests/unit/ollama-stream-tail.test.js
new file mode 100644
index 00000000..1f6bdb7e
--- /dev/null
+++ b/tests/unit/ollama-stream-tail.test.js
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+
+import { FORMATS } from "../../open-sse/translator/formats.js";
+import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js";
+
+// Ollama streams NDJSON — one raw JSON object per line, no "data: " prefix.
+// Whatever arrives without a closing newline stays in the line buffer and is
+// only parsed when the transform flushes.
+async function runOllamaStream(input) {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(input));
+ controller.close();
+ },
+ });
+
+ const output = stream.pipeThrough(
+ createSSETransformStreamWithLogger(FORMATS.OLLAMA, FORMATS.OPENAI, "ollama", null, null, "gpt-oss:120b"),
+ );
+
+ const reader = output.getReader();
+ const decoder = new TextDecoder();
+ let text = "";
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ text += decoder.decode(value, { stream: true });
+ }
+ return text + decoder.decode();
+}
+
+const chunk = (content, done = false) => JSON.stringify({
+ model: "gpt-oss:120b",
+ created_at: "2026-08-25T00:00:00Z",
+ message: { role: "assistant", content },
+ done,
+ ...(done ? { done_reason: "stop", prompt_eval_count: 11, eval_count: 7 } : {}),
+});
+
+const deltas = (sse) => sse
+ .split("\n")
+ .filter((l) => l.startsWith("data: ") && l !== "data: [DONE]")
+ .map((l) => JSON.parse(l.slice(6)));
+
+describe("Ollama NDJSON stream: the tail left in the line buffer", () => {
+ it("delivers a content chunk that arrived without its newline", async () => {
+ const out = await runOllamaStream([chunk("hello"), chunk(" world")].join("\n"));
+ const content = deltas(out).map((c) => c.choices?.[0]?.delta?.content || "").join("");
+ expect(content).toBe("hello world");
+ });
+
+ it("delivers the final chunk — finish_reason and usage — when it arrives without its newline", async () => {
+ const out = await runOllamaStream([chunk("hello"), chunk("", true)].join("\n"));
+ const last = deltas(out).at(-1);
+ expect(last.choices[0].finish_reason).toBe("stop");
+ expect(last.usage).toEqual({ prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 });
+ });
+
+ it("is unchanged when every line is newline-terminated", async () => {
+ const out = await runOllamaStream(`${[chunk("hello"), chunk(" world"), chunk("", true)].join("\n")}\n`);
+ const parsed = deltas(out);
+ expect(parsed.map((c) => c.choices?.[0]?.delta?.content || "").join("")).toBe("hello world");
+ expect(parsed.at(-1).choices[0].finish_reason).toBe("stop");
+ expect(parsed.at(-1).usage.total_tokens).toBe(18);
+ });
+});
+
+describe("SSE providers keep their sentinel handling", () => {
+ it("does not translate a trailing data: [DONE]", async () => {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "hi" } }] })}\ndata: [DONE]`,
+ ));
+ controller.close();
+ },
+ });
+ const out = stream.pipeThrough(
+ createSSETransformStreamWithLogger(FORMATS.OPENAI, FORMATS.OPENAI, "openai", null, null, "gpt-4o"),
+ );
+ const reader = out.getReader();
+ const decoder = new TextDecoder();
+ let text = "";
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ text += decoder.decode(value, { stream: true });
+ }
+ text += decoder.decode();
+ expect(text).toContain('"content":"hi"');
+ // The sentinel is a framing marker, not a chunk — it must not be translated.
+ expect(text).not.toContain('"done":true');
+ });
+});
diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js
index fcbffae9..335897ff 100644
--- a/tests/unit/opencode-go-models.test.js
+++ b/tests/unit/opencode-go-models.test.js
@@ -22,8 +22,8 @@ describe("OpenCode Go model catalog", () => {
it("matches the documented model IDs", () => {
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
expect(ids).toEqual([
- "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
- "deepseek-v4-pro", "deepseek-v4-flash",
+ "glm-5.3-flash", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
+ "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
"mimo-v2.5", "mimo-v2.5-pro",
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
diff --git a/tests/unit/opencode-muse-spark-thinking.test.js b/tests/unit/opencode-muse-spark-thinking.test.js
new file mode 100644
index 00000000..36338117
--- /dev/null
+++ b/tests/unit/opencode-muse-spark-thinking.test.js
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vitest";
+import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
+import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js";
+import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js";
+import { FORMATS } from "../../open-sse/translator/formats.js";
+import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js";
+import "../translator/registerAll.js";
+import { translateRequest } from "../../open-sse/translator/index.js";
+
+const MODEL = "muse-spark-1.2-contributor-free";
+const PROVIDER = "opencode";
+
+const input = [{
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "Think, then answer: 2 + 2?" }],
+}];
+
+describe("OpenCode Free Muse Spark thinking", () => {
+ it("advertises reasoning and the requested model limits", () => {
+ expect(PROVIDER_MODELS.oc?.some((model) => model.id === MODEL)).toBe(true);
+ expect(getCapabilitiesForModel(PROVIDER, MODEL)).toMatchObject({
+ reasoning: true,
+ thinkingFormat: "openai",
+ contextWindow: 1048576,
+ maxOutput: 131072,
+ });
+ expect(getCapabilitiesForModel(PROVIDER, `oc/${MODEL}`)).toMatchObject({
+ reasoning: true,
+ contextWindow: 1048576,
+ maxOutput: 131072,
+ });
+ expect(getThinkingLevels(PROVIDER, MODEL)).toEqual([
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ ]);
+ });
+
+ it("clamps max to xhigh and emits the Responses reasoning shape", () => {
+ const body = {
+ input,
+ reasoning: { effort: "max" },
+ max_tokens: 131072,
+ };
+
+ const out = new OpenCodeExecutor().transformRequest(MODEL, body, true, {
+ connectionId: "opencode-muse-spark-test",
+ });
+
+ expect(out.reasoning).toEqual({ effort: "xhigh", summary: "auto" });
+ expect(out.reasoning_effort).toBeUndefined();
+ expect(out.max_output_tokens).toBe(131072);
+ expect(out.max_tokens).toBeUndefined();
+ });
+
+ it("leaves the other free models on Chat Completions", () => {
+ const executor = new OpenCodeExecutor();
+ const body = { messages: [{ role: "user", content: "hi" }], max_tokens: 1024 };
+ executor.transformRequest("big-pickle", body, true, {});
+ expect(executor.buildUrl("big-pickle")).toBe("https://opencode.ai/zen/v1/chat/completions");
+ expect(body.max_tokens).toBe(1024);
+ expect(body.max_output_tokens).toBeUndefined();
+ });
+
+ it("translates Chat Completions max thinking into a Responses request", () => {
+ const body = {
+ model: `oc/${MODEL}`,
+ messages: [{ role: "user", content: "Think, then answer: 2 + 2?" }],
+ reasoning_effort: "max",
+ max_tokens: 131072,
+ };
+
+ const translated = translateRequest(
+ FORMATS.OPENAI,
+ FORMATS.OPENAI_RESPONSES,
+ MODEL,
+ body,
+ true,
+ {},
+ PROVIDER,
+ );
+ const out = new OpenCodeExecutor().transformRequest(MODEL, translated, true, {
+ connectionId: "opencode-muse-spark-translation-test",
+ });
+
+ expect(out.reasoning).toEqual({ effort: "xhigh", summary: "auto" });
+ expect(out.max_output_tokens).toBe(131072);
+ expect(out.max_tokens).toBeUndefined();
+ });
+});
diff --git a/tests/unit/system-inject.test.js b/tests/unit/system-inject.test.js
new file mode 100644
index 00000000..bff7c94c
--- /dev/null
+++ b/tests/unit/system-inject.test.js
@@ -0,0 +1,491 @@
+import { describe, it, expect } from "vitest";
+import { injectSystemPrompt } from "../../open-sse/rtk/systemInject.js";
+import { FORMATS } from "../../open-sse/translator/formats.js";
+import { OPENAI_BLOCK, CLAUDE_BLOCK, RESPONSES_ITEM } from "../../open-sse/translator/schema/blocks.js";
+import { ROLE } from "../../open-sse/translator/schema/roles.js";
+import { injectCaveman } from "../../open-sse/rtk/caveman.js";
+import { injectPonytail } from "../../open-sse/rtk/ponytail.js";
+import { CAVEMAN_PROMPTS } from "../../open-sse/rtk/cavemanPrompts.js";
+import { PONYTAIL_PROMPTS } from "../../open-sse/rtk/ponytailPrompt.js";
+
+const SEP = "\n\n";
+const P1 = "CAVEMAN_TEST_PROMPT_AAA";
+const P2 = "PONYTAIL_TEST_PROMPT_BBB";
+
+describe("system-inject chat messages", () => {
+ it("appends TEXT block to existing system string with SEP", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }, { role: ROLE.USER, content: "hi" }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.messages[0].content).toBe(`hello${SEP}${P1}`);
+ });
+
+ it("appends TEXT block to existing system array with OPENAI_BLOCK.TEXT never input_text", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: [{ type: OPENAI_BLOCK.TEXT, text: "hello" }] }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ const arr = body.messages[0].content;
+ expect(arr[arr.length - 1]).toEqual({ type: OPENAI_BLOCK.TEXT, text: P1 });
+ expect(arr.some(c => c.type === "input_text")).toBe(false);
+ });
+
+ it("unshifts system message when no system/developer present", () => {
+ const body = { messages: [{ role: ROLE.USER, content: "hi" }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.messages[0]).toEqual({ role: ROLE.SYSTEM, content: P1 });
+ expect(body.messages[1].role).toBe(ROLE.USER);
+ });
+
+ it("handles developer role as system", () => {
+ const body = { messages: [{ role: ROLE.DEVELOPER, content: "dev" }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.messages[0].content).toBe(`dev${SEP}${P1}`);
+ });
+
+ it("exact full-prompt idempotency for chat string", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.messages[0].content).toBe(`hello${SEP}${P1}`);
+ // different prompt both apply
+ injectSystemPrompt(body, FORMATS.OPENAI, P2);
+ expect(body.messages[0].content).toBe(`hello${SEP}${P1}${SEP}${P2}`);
+ });
+
+ it("exact full-prompt idempotency for chat array", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: [{ type: OPENAI_BLOCK.TEXT, text: "hello" }] }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ const texts = body.messages[0].content.filter(c => c.text === P1);
+ expect(texts.length).toBe(1);
+ injectSystemPrompt(body, FORMATS.OPENAI, P2);
+ expect(body.messages[0].content.filter(c => c.text === P2).length).toBe(1);
+ });
+
+ it("never uses first-100 fingerprint: long prompt exact idempotency", () => {
+ const longA = "X".repeat(150) + "_A";
+ const longB = "X".repeat(150) + "_B";
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "base" }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, longA);
+ injectSystemPrompt(body, FORMATS.OPENAI, longB);
+ expect(body.messages[0].content).toContain(longA);
+ expect(body.messages[0].content).toContain(longB);
+ // retry same longA is idempotent
+ injectSystemPrompt(body, FORMATS.OPENAI, longA);
+ const countA = body.messages[0].content.split(longA).length - 1;
+ expect(countA).toBe(1);
+ });
+});
+
+describe("system-inject responses input[]", () => {
+ it("modifies only type: message system/developer and preserves non-message order", () => {
+ const body = {
+ input: [
+ { type: RESPONSES_ITEM.FUNCTION_CALL, call_id: "c1", name: "fn" },
+ { type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "sys" }] },
+ { type: RESPONSES_ITEM.REASONING, summary: "x" },
+ { type: RESPONSES_ITEM.FUNCTION_CALL_OUTPUT, call_id: "c1", output: "ok" },
+ ],
+ };
+ const before = JSON.parse(JSON.stringify(body.input));
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ // length unchanged except injection inside message
+ expect(body.input.length).toBe(before.length);
+ expect(body.input[0]).toEqual(before[0]);
+ expect(body.input[2]).toEqual(before[2]);
+ expect(body.input[3]).toEqual(before[3]);
+ // system message got INPUT_TEXT appended
+ const sys = body.input[1];
+ expect(sys.content[sys.content.length - 1]).toEqual({ type: RESPONSES_ITEM.INPUT_TEXT, text: P1 });
+ });
+
+ it("appends INPUT_TEXT to array content", () => {
+ const body = { input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "hi" }] }, { type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "base" }] }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ const sys = body.input.find(m => m.role === ROLE.SYSTEM);
+ expect(sys.content[sys.content.length - 1].type).toBe(RESPONSES_ITEM.INPUT_TEXT);
+ expect(sys.content[sys.content.length - 1].text).toBe(P1);
+ });
+
+ it("creates typed message at index 0 if absent preserving order", () => {
+ const body = { input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "hi" }] }, { type: RESPONSES_ITEM.FUNCTION_CALL, call_id: "1", name: "a" }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.input[0]).toEqual({ type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: P1 }] });
+ expect(body.input[1].role).toBe(ROLE.USER);
+ });
+
+ it("instructions string takes precedence over input[]", () => {
+ const body = { instructions: "instr", input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "hi" }] }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.instructions).toBe(`instr${SEP}${P1}`);
+ expect(body.input.length).toBe(1);
+ expect(body.input[0].content[0].text).toBe("hi");
+ });
+
+ it("does not coerce string input", () => {
+ const body = { input: "hello string" };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.input).toBe("hello string");
+ expect(body.instructions).toBeUndefined();
+ });
+
+ it("exact idempotency for responses input", () => {
+ const body = { input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "base" }] }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ const sys = body.input[0];
+ expect(sys.content.filter(c => c.text === P1).length).toBe(1);
+ injectSystemPrompt(body, FORMATS.OPENAI, P2);
+ expect(sys.content.filter(c => c.text === P2).length).toBe(1);
+ });
+});
+
+describe("system-inject instructions", () => {
+ it("appends to instructions string with idempotency", () => {
+ const body = { instructions: "base" };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.instructions).toBe(`base${SEP}${P1}`);
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.instructions).toBe(`base${SEP}${P1}`);
+ injectSystemPrompt(body, FORMATS.OPENAI, P2);
+ expect(body.instructions).toBe(`base${SEP}${P1}${SEP}${P2}`);
+ });
+
+ it("creates instructions when empty", () => {
+ const body = { instructions: "" };
+ // empty string still taken as string field, should become prompt
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.instructions).toBe(P1);
+ });
+});
+
+describe("system-inject dispatch by wire shape", () => {
+ it("messages[] means Chat even when format is openai-responses label", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "hi" }] };
+ injectSystemPrompt(body, FORMATS.OPENAI_RESPONSES, P1);
+ // should still treat as Chat because messages present
+ expect(body.messages[0].content).toBe(`hi${SEP}${P1}`);
+ });
+ it("input[] means Responses even when format is openai", () => {
+ const body = { input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "hi" }] }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.input[0].role).toBe(ROLE.SYSTEM);
+ expect(body.input[0].content[0].type).toBe(RESPONSES_ITEM.INPUT_TEXT);
+ });
+});
+
+describe("system-inject claude", () => {
+ it("string system appends with SEP and idempotent", () => {
+ const body = { system: "base" };
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ expect(body.system).toBe(`base${SEP}${P1}`);
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ expect(body.system).toBe(`base${SEP}${P1}`);
+ injectSystemPrompt(body, FORMATS.CLAUDE, P2);
+ expect(body.system).toBe(`base${SEP}${P1}${SEP}${P2}`);
+ });
+
+ it("array system uses CLAUDE_BLOCK.TEXT and inserts before last cache_control", () => {
+ const body = { system: [{ type: CLAUDE_BLOCK.TEXT, text: "a" }, { type: CLAUDE_BLOCK.TEXT, text: "b", cache_control: { type: "ephemeral" } }, { type: CLAUDE_BLOCK.TEXT, text: "c", cache_control: { type: "ephemeral" } }] };
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ // should be inserted before last cache_control (index 2)
+ expect(body.system[2]).toEqual({ type: CLAUDE_BLOCK.TEXT, text: P1 });
+ expect(body.system[3].text).toBe("c");
+ expect(body.system[3].cache_control).toBeDefined();
+ });
+
+ it("array without cache_control appends", () => {
+ const body = { system: [{ type: CLAUDE_BLOCK.TEXT, text: "a" }] };
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ expect(body.system[body.system.length - 1]).toEqual({ type: CLAUDE_BLOCK.TEXT, text: P1 });
+ });
+
+ it("exact idempotency for claude array", () => {
+ const body = { system: [{ type: CLAUDE_BLOCK.TEXT, text: "a" }] };
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ expect(body.system.filter(b => b.text === P1).length).toBe(1);
+ });
+
+ it("creates system when absent", () => {
+ const body = {};
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ expect(body.system).toBe(P1);
+ });
+
+ it("real body with messages[] injects into system, never a system role turn", () => {
+ const body = { system: "base", messages: [{ role: ROLE.USER, content: "hi" }] };
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ expect(body.system).toBe(`base${SEP}${P1}`);
+ expect(body.messages).toEqual([{ role: ROLE.USER, content: "hi" }]);
+ });
+
+ it("absent system with messages[] creates system field, not a system message", () => {
+ const body = { messages: [{ role: ROLE.USER, content: "hi" }] };
+ injectSystemPrompt(body, FORMATS.CLAUDE, P1);
+ expect(body.system).toBe(P1);
+ expect(body.messages.some(m => m.role === ROLE.SYSTEM)).toBe(false);
+ });
+});
+
+describe("system-inject gemini", () => {
+ it("preserves snake_case key", () => {
+ const body = { system_instruction: { parts: [{ text: "base" }] } };
+ injectSystemPrompt(body, FORMATS.GEMINI, P1);
+ expect(body.system_instruction.parts.length).toBe(2);
+ expect(body.system_instruction.parts[1].text).toBe(P1);
+ expect(body.systemInstruction).toBeUndefined();
+ });
+ it("preserves camelCase key", () => {
+ const body = { systemInstruction: { parts: [{ text: "base" }] } };
+ injectSystemPrompt(body, FORMATS.GEMINI, P1);
+ expect(body.systemInstruction.parts[1].text).toBe(P1);
+ expect(body.system_instruction).toBeUndefined();
+ });
+ it("handles Antigravity wrapper request.systemInstruction", () => {
+ const body = { request: { systemInstruction: { parts: [{ text: "base" }] } } };
+ injectSystemPrompt(body, FORMATS.ANTIGRAVITY, P1);
+ expect(body.request.systemInstruction.parts[1].text).toBe(P1);
+ });
+ it("exact idempotency for gemini", () => {
+ const body = { systemInstruction: { parts: [{ text: "base" }] } };
+ injectSystemPrompt(body, FORMATS.GEMINI, P1);
+ injectSystemPrompt(body, FORMATS.GEMINI, P1);
+ expect(body.systemInstruction.parts.filter(p => p.text === P1).length).toBe(1);
+ injectSystemPrompt(body, FORMATS.GEMINI, P2);
+ expect(body.systemInstruction.parts.filter(p => p.text === P2).length).toBe(1);
+ });
+ it("creates when absent", () => {
+ const body = {};
+ injectSystemPrompt(body, FORMATS.GEMINI, P1);
+ expect(body.systemInstruction.parts[0].text).toBe(P1);
+ });
+});
+
+describe("system-inject kiro", () => {
+ it("updates systemPrompt and mirrored prefix of first history user preserving tail", () => {
+ const oldPrompt = "OLD_SYS";
+ const timeCtx = "[Context: Current time is 2026-01-01T00:00:00.000Z]";
+ const tail = "user tail content";
+ const historyUserContent = `${oldPrompt}${SEP}${timeCtx}${SEP}${tail}`;
+ const body = {
+ systemPrompt: oldPrompt,
+ conversationState: {
+ history: [{ userInputMessage: { content: historyUserContent, modelId: "m" } }, { assistantResponseMessage: { content: "..." } }],
+ currentMessage: { userInputMessage: { content: "current " + tail, modelId: "m" } },
+ },
+ };
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ const next = `${oldPrompt}${SEP}${P1}`;
+ expect(body.systemPrompt).toBe(next);
+ expect(body.conversationState.history[0].userInputMessage.content).toBe(`${next}${SEP}${timeCtx}${SEP}${tail}`);
+ // currentMessage must stay untouched
+ expect(body.conversationState.currentMessage.userInputMessage.content).toBe("current " + tail);
+ });
+
+ it("when no history user, updates currentMessage instead", () => {
+ const oldPrompt = "OLD";
+ const body = {
+ systemPrompt: oldPrompt,
+ conversationState: {
+ history: [],
+ currentMessage: { userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } },
+ },
+ };
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`);
+ expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`);
+ });
+
+ it("empty old prompt prepends to chosen user content", () => {
+ const body = {
+ systemPrompt: "",
+ conversationState: {
+ history: [{ userInputMessage: { content: "tail hello", modelId: "m" } }],
+ currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
+ },
+ };
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ expect(body.systemPrompt).toBe(P1);
+ expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}tail hello`);
+ });
+
+ it("if old prompt not mirrored at head, do not alter user content", () => {
+ const body = {
+ systemPrompt: "OLD",
+ conversationState: {
+ history: [{ userInputMessage: { content: "different head content", modelId: "m" } }],
+ currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
+ },
+ };
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ expect(body.systemPrompt).toBe(`OLD${SEP}${P1}`);
+ expect(body.conversationState.history[0].userInputMessage.content).toBe("different head content");
+ });
+
+ it("exact retry idempotency for kiro", () => {
+ const oldPrompt = "OLD";
+ const body = {
+ systemPrompt: oldPrompt,
+ conversationState: {
+ history: [{ userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } }],
+ currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
+ },
+ };
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ const after1 = JSON.parse(JSON.stringify(body));
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ expect(body.systemPrompt).toBe(after1.systemPrompt);
+ expect(body.conversationState.history[0].userInputMessage.content).toBe(after1.conversationState.history[0].userInputMessage.content);
+ // different prompt both apply
+ injectSystemPrompt(body, FORMATS.KIRO, P2);
+ expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}${SEP}${P2}`);
+ });
+
+ it("preserves non-enumerable _kiroUpstreamModel", () => {
+ const body = {
+ systemPrompt: "OLD",
+ conversationState: { history: [{ userInputMessage: { content: "OLD" + SEP + "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "OLD" + SEP + "tail2", modelId: "m" } } },
+ };
+ Object.defineProperty(body, "_kiroUpstreamModel", { value: "m", enumerable: false });
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ expect(body._kiroUpstreamModel).toBe("m");
+ expect(Object.getOwnPropertyDescriptor(body, "_kiroUpstreamModel").enumerable).toBe(false);
+ });
+});
+
+describe("system-inject regression fixes", () => {
+ it("kiro partial mutation converges on retry after transient content write failure", () => {
+ const oldPrompt = "OLD";
+ let failNextWrite = true;
+ const um = { content: `${oldPrompt}${SEP}tail`, modelId: "m" };
+ const proxiedUm = new Proxy(um, {
+ set(t, p, v) {
+ if (p === "content" && failNextWrite) { failNextWrite = false; throw new Error("transient"); }
+ t[p] = v; return true;
+ },
+ });
+ const body = {
+ systemPrompt: oldPrompt,
+ conversationState: {
+ history: [{ userInputMessage: proxiedUm }],
+ },
+ };
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ // first pass rolled back atomically — nothing half-applied
+ expect(body.systemPrompt).toBe(oldPrompt);
+ expect(um.content).toBe(`${oldPrompt}${SEP}tail`);
+ // retry converges
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`);
+ expect(um.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`);
+ });
+
+ it("kiro rolls back systemPrompt when user content write fails (atomicity)", () => {
+ const oldPrompt = "OLD";
+ const body = {
+ systemPrompt: oldPrompt,
+ conversationState: {
+ history: [{ userInputMessage: Object.freeze({ content: `${oldPrompt}${SEP}tail`, modelId: "m" }) }],
+ },
+ };
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ expect(body.systemPrompt).toBe(oldPrompt);
+ });
+
+ it("kiro shape gate: stray conversationState without history/currentMessage does not hijack chat body", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], systemPrompt: "", conversationState: {} };
+ injectSystemPrompt(body, FORMATS.OPENAI, P1);
+ expect(body.messages[0].content).toBe(`hello${SEP}${P1}`);
+ });
+
+ it("substring occurrence does not suppress injection (exact SEP-delimited idempotency)", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "You are RULE follower" }] };
+ injectSystemPrompt(body, FORMATS.OPENAI, "RULE");
+ expect(body.messages[0].content).toBe(`You are RULE follower${SEP}RULE`);
+ });
+
+ it("instructions substring occurrence does not suppress injection", () => {
+ const body = { instructions: "You are RULE follower" };
+ injectSystemPrompt(body, FORMATS.OPENAI, "RULE");
+ expect(body.instructions).toBe(`You are RULE follower${SEP}RULE`);
+ });
+
+ it("kiro empty-old prepend fires when prompt appears mid-tail only", () => {
+ const body = {
+ systemPrompt: "",
+ conversationState: {
+ history: [{ userInputMessage: { content: `some ${P1} here`, modelId: "m" } }],
+ },
+ };
+ injectSystemPrompt(body, FORMATS.KIRO, P1);
+ expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}some ${P1} here`);
+ });
+});
+
+describe("system-inject fail-open", () => {
+ it("null/undefined bodies never throw", () => {
+ expect(() => injectSystemPrompt(null, FORMATS.OPENAI, P1)).not.toThrow();
+ expect(() => injectSystemPrompt(undefined, FORMATS.OPENAI, P1)).not.toThrow();
+ expect(() => injectSystemPrompt({}, FORMATS.OPENAI, null)).not.toThrow();
+ });
+
+ it("malformed messages array never throws", () => {
+ expect(() => injectSystemPrompt({ messages: null }, FORMATS.OPENAI, P1)).not.toThrow();
+ expect(() => injectSystemPrompt({ messages: "bad" }, FORMATS.OPENAI, P1)).not.toThrow();
+ expect(() => injectSystemPrompt({ messages: [{ role: null, content: null }] }, FORMATS.OPENAI, P1)).not.toThrow();
+ });
+
+ it("frozen body never throws and does not partially mutate", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }] };
+ Object.freeze(body);
+ Object.freeze(body.messages);
+ Object.freeze(body.messages[0]);
+ expect(() => injectSystemPrompt(body, FORMATS.OPENAI, P1)).not.toThrow();
+ expect(body.messages[0].content).toBe("hello");
+ });
+
+ it("Proxy throwing setter never throws", () => {
+ const throwingMsg = new Proxy({ role: ROLE.SYSTEM, content: "hello" }, {
+ set() { throw new Error("msg setter fail"); },
+ });
+ const arrProxy = new Proxy([throwingMsg], {
+ get(t, p, r) { return Reflect.get(t, p, r); },
+ set() { throw new Error("arr setter fail"); },
+ });
+ const proxy = new Proxy({}, {
+ set(t, p, v) { if (p === "messages") throw new Error("setter fail"); return Reflect.set(t, p, v); },
+ get(t, p) { if (p === "messages") return arrProxy; return t[p]; },
+ });
+ expect(() => injectSystemPrompt(proxy, FORMATS.OPENAI, P1)).not.toThrow();
+ expect(() => injectSystemPrompt(proxy, FORMATS.OPENAI_RESPONSES, P1)).not.toThrow();
+ });
+
+ it("frozen claude never throws", () => {
+ const body = { system: [{ type: CLAUDE_BLOCK.TEXT, text: "a" }] };
+ Object.freeze(body.system);
+ expect(() => injectSystemPrompt(body, FORMATS.CLAUDE, P1)).not.toThrow();
+ });
+
+ it("frozen gemini never throws", () => {
+ const body = { systemInstruction: { parts: [{ text: "a" }] } };
+ Object.freeze(body.systemInstruction.parts);
+ expect(() => injectSystemPrompt(body, FORMATS.GEMINI, P1)).not.toThrow();
+ });
+
+ it("injectCaveman and injectPonytail fail open on frozen", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "hi" }] };
+ Object.freeze(body);
+ Object.freeze(body.messages);
+ expect(() => injectCaveman(body, FORMATS.OPENAI, "full")).not.toThrow();
+ expect(() => injectPonytail(body, FORMATS.OPENAI, "full")).not.toThrow();
+ });
+
+ it("different caveman and ponytail prompts both apply", () => {
+ const body = { messages: [{ role: ROLE.SYSTEM, content: "base" }] };
+ injectCaveman(body, FORMATS.OPENAI, "full");
+ const afterCaveman = body.messages[0].content;
+ expect(afterCaveman).toContain(CAVEMAN_PROMPTS.full.slice(0, 30));
+ injectPonytail(body, FORMATS.OPENAI, "full");
+ expect(body.messages[0].content).toContain(PONYTAIL_PROMPTS.full.slice(0, 30));
+ expect(body.messages[0].content).toContain(afterCaveman);
+ });
+});
diff --git a/tests/unit/token-refresh-generic.test.js b/tests/unit/token-refresh-generic.test.js
index e14c9038..51cd1ae4 100644
--- a/tests/unit/token-refresh-generic.test.js
+++ b/tests/unit/token-refresh-generic.test.js
@@ -102,19 +102,38 @@ describe("refreshAccessToken — config-driven profiles", () => {
expect(fm).toHaveBeenCalledTimes(1);
});
});
-
-describe("refreshAccessToken — legacy generic path (no profile)", () => {
+describe("Cline refresh", () => {
beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; });
afterEach(() => { global.fetch = originalFetch; });
- it("still works for an unprofiled provider via config.refreshUrl/clientId/clientSecret", async () => {
- const fm = mockFetchOnce({ access_token: "gen-acc", expires_in: 3600 });
- const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
+ it("uses the extension JSON refresh contract", async () => {
+ const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
+ const fm = mockFetchOnce({
+ data: {
+ accessToken: "cline-acc",
+ refreshToken: "cline-rot",
+ expiresAt,
+ },
+ });
+ const { refreshTokenByProvider } = await import(
+ "open-sse/services/tokenRefresh.js"
+ );
- await refreshAccessToken("cline", "gen-old", {}, console);
+ const out = await refreshTokenByProvider(
+ "cline",
+ { refreshToken: "cline-old" },
+ console
+ );
- const body = new URLSearchParams(fm.mock.calls[0][1].body);
- expect(body.get("grant_type")).toBe("refresh_token");
- expect(body.get("client_id")).toBeTruthy();
+ const [, init] = fm.mock.calls[0];
+ expect(init.headers["Content-Type"]).toBe("application/json");
+ expect(JSON.parse(init.body)).toEqual({
+ refreshToken: "cline-old",
+ grantType: "refresh_token",
+ clientType: "extension",
+ });
+ expect(out.accessToken).toBe("cline-acc");
+ expect(out.refreshToken).toBe("cline-rot");
+ expect(out.expiresIn).toBeGreaterThan(0);
});
});
diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js
index e0e80840..5b86ee9e 100644
--- a/tests/unit/usage-dispatch.test.js
+++ b/tests/unit/usage-dispatch.test.js
@@ -16,7 +16,7 @@ const SUPPORTED = [
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
"qoder", "iflow", "ollama", "glm", "glm-cn",
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
- "deepseek",
+ "deepseek", "zed",
];
describe("usage dispatch", () => {
diff --git a/tests/unit/xquik-search-provider.test.js b/tests/unit/xquik-search-provider.test.js
new file mode 100644
index 00000000..3f48ef4f
--- /dev/null
+++ b/tests/unit/xquik-search-provider.test.js
@@ -0,0 +1,154 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import REGISTRY from "../../open-sse/providers/registry/index.js";
+import { buildSearchRequest } from "../../open-sse/handlers/search/callers.js";
+import { handleSearchCore } from "../../open-sse/handlers/search/index.js";
+import { normalizeSearchResponse } from "../../open-sse/handlers/search/normalizers.js";
+import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers.js";
+
+const CONFIG = {
+ id: "xquik",
+ baseUrl: "https://xquik.com/api/v1/x/tweets/search",
+ method: "GET",
+ authType: "apikey",
+ searchTypes: ["x"],
+ defaultMaxResults: 5,
+ maxMaxResults: 100,
+ creditsPerResult: 1,
+};
+
+const PARAMS = {
+ query: "from:github release notes",
+ searchType: "x",
+ maxResults: 10,
+ token: "xq_test_key",
+ language: "en",
+ providerOptions: { queryType: "Latest", cursor: "next page" },
+};
+
+const RESPONSE = {
+ tweets: [
+ {
+ id: "1234567890",
+ text: "Release notes are live.",
+ createdAt: "2026-08-25T12:00:00Z",
+ author: { username: "github", name: "GitHub" },
+ media: [{ mediaUrl: "https://pbs.twimg.com/media/example.jpg", type: "photo" }],
+ },
+ ],
+ has_next_page: true,
+ next_cursor: "cursor-2",
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("Xquik search provider", () => {
+ it("registers a dedicated X search provider with no-charge key validation", () => {
+ const entry = REGISTRY.find((candidate) => candidate.id === "xquik");
+
+ expect(entry).toMatchObject({
+ category: "apikey",
+ serviceKinds: ["webSearch"],
+ searchConfig: {
+ authHeader: "x-api-key",
+ validateUrl: "https://xquik.com/api/v1/credits",
+ searchTypes: ["x"],
+ creditsPerResult: 1,
+ },
+ });
+ expect(AI_PROVIDERS.xquik?.searchConfig).toEqual(entry.searchConfig);
+ expect(getProvidersByKind("webSearch").map((provider) => provider.id)).toContain("xquik");
+ });
+
+ it("builds the documented GET request without putting the key in the URL", () => {
+ const request = buildSearchRequest(CONFIG, PARAMS);
+ const url = new URL(request.url);
+
+ expect(url.origin + url.pathname).toBe("https://xquik.com/api/v1/x/tweets/search");
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ q: "from:github release notes",
+ limit: "10",
+ cursor: "next page",
+ queryType: "Latest",
+ language: "en",
+ });
+ expect(url.search).not.toContain("xq_test_key");
+ expect(request.init).toEqual({
+ method: "GET",
+ headers: { Accept: "application/json", "x-api-key": "xq_test_key" },
+ });
+ });
+
+ it("rejects unsupported query types before contacting Xquik", () => {
+ expect(() => buildSearchRequest(CONFIG, {
+ ...PARAMS,
+ providerOptions: { queryType: "Popular" },
+ })).toThrow("Xquik queryType must be Latest or Top");
+ });
+
+ it("normalizes posts and preserves cursor pagination", () => {
+ const normalized = normalizeSearchResponse("xquik", RESPONSE, PARAMS.query, "x");
+
+ expect(normalized.totalResults).toBeNull();
+ expect(normalized.pagination).toEqual({ has_more: true, next_cursor: "cursor-2" });
+ expect(normalized.results).toHaveLength(1);
+ expect(normalized.results[0]).toMatchObject({
+ title: "@github on X",
+ url: "https://x.com/github/status/1234567890",
+ display_url: "x.com/github/status/1234567890",
+ snippet: "Release notes are live.",
+ published_at: "2026-08-25T12:00:00Z",
+ metadata: {
+ author: "@github",
+ source_type: "x_post",
+ image_url: "https://pbs.twimg.com/media/example.jpg",
+ },
+ citation: { provider: "xquik", rank: 1 },
+ });
+ expect(normalized.results[0].content).toEqual({
+ format: "text",
+ text: "Release notes are live.",
+ length: 23,
+ });
+ });
+
+ it("uses the stable status URL when author data is unavailable", () => {
+ const normalized = normalizeSearchResponse("xquik", {
+ tweets: [{ id: "9876543210", text: "Author data is unavailable." }],
+ has_next_page: false,
+ next_cursor: "",
+ }, PARAMS.query, "x");
+
+ expect(normalized.results[0]).toMatchObject({
+ title: "X post",
+ url: "https://x.com/i/web/status/9876543210",
+ metadata: { author: null, source_type: "x_post" },
+ });
+ expect(normalized.pagination).toEqual({ has_more: false, next_cursor: null });
+ });
+
+ it("reports Xquik credits without claiming an unknown USD cost", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(RESPONSE), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })));
+
+ const result = await handleSearchCore({
+ body: { query: PARAMS.query, max_results: 10, provider_options: PARAMS.providerOptions },
+ provider: { id: "xquik" },
+ providerConfig: CONFIG,
+ credentials: { apiKey: "xq_test_key" },
+ });
+ const payload = await result.response.json();
+
+ expect(result.success).toBe(true);
+ expect(payload.usage).toEqual({
+ queries_used: 1,
+ search_cost_usd: null,
+ provider_credits_used: 1,
+ });
+ expect(payload.pagination).toEqual({ has_more: true, next_cursor: "cursor-2" });
+ });
+});
diff --git a/tests/unit/zed-usage.test.js b/tests/unit/zed-usage.test.js
new file mode 100644
index 00000000..4ad7421c
--- /dev/null
+++ b/tests/unit/zed-usage.test.js
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("../../open-sse/shared/zedAuth.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ fetchZedAuthenticatedUser: vi.fn(),
+ };
+});
+
+import { fetchZedAuthenticatedUser } from "../../open-sse/shared/zedAuth.js";
+import { getUsageForProvider } from "../../open-sse/services/usage.js";
+import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js";
+import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
+import {
+ formatZedPlanLabel,
+ parseZedUsageLimit,
+ parseZedAuthenticatedUserUsage,
+} from "../../open-sse/services/usage/zed.js";
+
+describe("zed registry usage flags", () => {
+ it("is listed in USAGE_SUPPORTED_PROVIDERS", () => {
+ expect(USAGE_SUPPORTED_PROVIDERS).toContain("zed");
+ });
+});
+
+describe("parseZedUsageLimit", () => {
+ it("parses unlimited string and object forms", () => {
+ expect(parseZedUsageLimit("unlimited")).toEqual({ unlimited: true, total: 0 });
+ expect(parseZedUsageLimit({ unlimited: true })).toEqual({ unlimited: true, total: 0 });
+ });
+
+ it("parses numeric and limited object forms", () => {
+ expect(parseZedUsageLimit(50)).toEqual({ unlimited: false, total: 50 });
+ expect(parseZedUsageLimit("25")).toEqual({ unlimited: false, total: 25 });
+ expect(parseZedUsageLimit({ limited: 40 })).toEqual({ unlimited: false, total: 40 });
+ });
+});
+
+describe("formatZedPlanLabel", () => {
+ it("maps known plan ids", () => {
+ expect(formatZedPlanLabel("zed_pro")).toBe("Zed Pro");
+ expect(formatZedPlanLabel("zed_pro_trial")).toBe("Zed Pro Trial");
+ });
+});
+
+describe("parseZedAuthenticatedUserUsage", () => {
+ it("maps edit_predictions and billing cycle reset", () => {
+ const parsed = parseZedAuthenticatedUserUsage({
+ plan: {
+ plan_v3: "zed_pro",
+ subscription_period: {
+ started_at: "2026-07-01T00:00:00Z",
+ ended_at: "2026-08-01T00:00:00Z",
+ },
+ usage: {
+ edit_predictions: { used: 12, limit: 50 },
+ },
+ },
+ });
+
+ expect(parsed.plan).toBe("Zed Pro");
+ expect(parsed.quotas["Edit Predictions"]).toMatchObject({
+ used: 12,
+ total: 50,
+ remainingPercentage: 76,
+ resetAt: "2026-08-01T00:00:00.000Z",
+ });
+ });
+
+ it("marks unlimited edit predictions at 100% remaining", () => {
+ const parsed = parseZedAuthenticatedUserUsage({
+ plan: {
+ plan_v3: "zed_pro",
+ usage: {
+ edit_predictions: { used: 999, limit: "unlimited" },
+ },
+ },
+ });
+
+ expect(parsed.quotas["Edit Predictions"]).toMatchObject({
+ used: 999,
+ total: 0,
+ remainingPercentage: 100,
+ unlimited: true,
+ });
+ });
+
+ it("skips token-billed model_requests limit=0 and adds billing note", () => {
+ const parsed = parseZedAuthenticatedUserUsage({
+ plan: {
+ plan_v3: "zed_student",
+ usage: {
+ model_requests: { used: 0, limit: { limited: 0 } },
+ edit_predictions: { used: 0, limit: "unlimited" },
+ },
+ },
+ });
+
+ expect(parsed.quotas["Hosted Model Requests"]).toBeUndefined();
+ expect(parsed.quotas["Edit Predictions"]).toBeDefined();
+ expect(parsed.message).toMatch(/token/i);
+ expect(parsed.message).toMatch(/dashboard\.zed\.dev/);
+ });
+
+ it("surfaces overdue invoice warning", () => {
+ const parsed = parseZedAuthenticatedUserUsage({
+ plan: {
+ plan_v3: "zed_pro",
+ has_overdue_invoices: true,
+ usage: {
+ edit_predictions: { used: 0, limit: "unlimited" },
+ },
+ },
+ });
+
+ expect(parsed.hasOverdueInvoices).toBe(true);
+ expect(parsed.message).toMatch(/overdue invoices/i);
+ });
+});
+
+describe("getUsageForProvider(zed)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns quotas from /client/users/me", async () => {
+ fetchZedAuthenticatedUser.mockResolvedValueOnce({
+ plan: {
+ plan_v3: "zed_student",
+ usage: {
+ edit_predictions: { used: 3, limit: 30 },
+ },
+ },
+ });
+
+ const usage = await getUsageForProvider({
+ provider: "zed",
+ accessToken: "plain-token",
+ providerSpecificData: { userId: "user-42", systemId: "sys-1" },
+ });
+
+ expect(usage.plan).toBe("Zed Student");
+ expect(usage.quotas["Edit Predictions"]).toMatchObject({
+ used: 3,
+ total: 30,
+ remainingPercentage: 90,
+ });
+
+ expect(fetchZedAuthenticatedUser).toHaveBeenCalledWith(
+ {
+ accessToken: "plain-token",
+ providerSpecificData: { userId: "user-42", systemId: "sys-1" },
+ },
+ { proxyOptions: null },
+ );
+ });
+
+ it("requires user id on the connection", async () => {
+ const usage = await getUsageForProvider({
+ provider: "zed",
+ accessToken: "plain-token",
+ providerSpecificData: {},
+ });
+
+ expect(usage.message).toMatch(/missing user id/i);
+ expect(fetchZedAuthenticatedUser).not.toHaveBeenCalled();
+ });
+});
+
+describe("parseQuotaData(zed)", () => {
+ it("normalizes zed quotas for QuotaTable", () => {
+ const data = parseZedAuthenticatedUserUsage({
+ plan: {
+ plan_v3: "zed_pro",
+ usage: { edit_predictions: { used: 10, limit: 20 } },
+ },
+ });
+
+ const rows = parseQuotaData("zed", data);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ name: "Edit Predictions",
+ used: 10,
+ total: 20,
+ remainingPercentage: 50,
+ });
+ });
+});