Merge remote-tracking branch 'upstream/master'

# Conflicts:
#	open-sse/handlers/chatCore.js
#	open-sse/providers/registry/index.js
#	open-sse/services/usage.js
This commit is contained in:
MUH. IQRAM BAHRING
2026-08-30 23:23:48 +08:00
114 changed files with 5586 additions and 2183 deletions
+113
View File
@@ -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:<provider>`
- **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
+28 -4
View File
@@ -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/<platform>-<arch>.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,
+1 -1
View File
@@ -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"
+7
View File
@@ -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
+6 -6
View File
@@ -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);
}
}
}
+235 -7
View File
@@ -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",
},
});
}
+51 -10
View File
@@ -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`;
}
+15 -3
View File
@@ -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) {
@@ -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,
+78
View File
@@ -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: "<json>" }] } }
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,
};
/**
+101 -3
View File
@@ -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();
+9 -1
View File
@@ -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: []
}
+87 -1
View File
@@ -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: "<json>" }] } }
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];
+71 -5
View File
@@ -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);
}
+72
View File
@@ -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 });
}
+6 -1
View File
@@ -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,
},
+1
View File
@@ -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" },
],
+2
View File
@@ -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" },
],
+15
View File
@@ -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,
+4
View File
@@ -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,
];
@@ -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,
},
};
@@ -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"] },
+5 -1
View File
@@ -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,
};
+2
View File
@@ -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" },
+35
View File
@@ -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
}
};
+42
View File
@@ -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 "<digit>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);
}
+11 -1
View File
@@ -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
+315 -76
View File
@@ -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 (_) {}
}
+3
View File
@@ -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
@@ -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);
+3 -1
View File
@@ -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 = {}) {
+20
View File
@@ -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,
};
+88
View File
@@ -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}` };
}
}
+2 -61
View File
@@ -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
+222
View File
@@ -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"}` };
}
}
+11 -7
View File
@@ -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) {
@@ -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": {
+14
View File
@@ -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" });
}
+7 -4
View File
@@ -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];
@@ -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" };
+29 -2
View File
@@ -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: {} } },
+117
View File
@@ -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);
+5 -1
View File
@@ -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}`;
+54 -34
View File
@@ -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();
}
}
});
+2 -1
View File
@@ -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
+4 -1
View File
@@ -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 = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.5.55",
"version": "0.5.59",
"description": "9Router web dashboard",
"private": true,
"scripts": {
+144
View File
@@ -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."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+23 -2
View File
@@ -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"`.

Some files were not shown because too many files have changed in this diff Show More