diff --git a/.env.example b/.env.example index 470a6107d6..67097b7104 100644 --- a/.env.example +++ b/.env.example @@ -418,7 +418,9 @@ ALLOW_API_KEY_REVEAL=false # provider dispatch. Heavyweight capacity is reserved before parsing; excess work # receives 503 + Retry-After instead of overlapping until the process OOMs. # Used by: src/shared/middleware/chatBodyAdmission.ts -# Actual bodies at or above this size require a heavyweight lease. Default 262144 (256 KB). +# Actual bodies at or above this size take the heavyweight lease (BYTE path, +# including POST /v1/responses) and use the same #10437 healthy-headroom escape +# as structure-heavy. Default 262144 (256 KB). # OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144 # Actual-byte hard cap enforced during bounded ingestion. Default 52428800 (50 MB). # OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800 @@ -426,6 +428,11 @@ ALLOW_API_KEY_REVEAL=false # left unset, heavyweight admission is gated by OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES below # instead (an auto-derived byte budget), fixing coding-agent fan-out (multiple # subagents/CLIs) collapsing to an effective concurrency of ~1 and 503ing. +# Two overlapping ~750k-token /v1/responses abort ~12 Gi heaps (#7849) — a +# memory-budget warning, not a hard product max of 2. A healthy heap may admit +# more via HEALTHY_HEADROOM. Tens of long SSE clients (40-50) is heap + +# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES / #10110. Multiply heaps with N independent +# DATA_DIRs (#11024); never replicas>1 on one SQLite. # OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1 # Override for the auto-derived ingest byte budget (#503-fanout). Default: 25% of the # process's effective memory ceiling (V8 heap limit, or the tighter cgroup/container @@ -433,13 +440,15 @@ ALLOW_API_KEY_REVEAL=false # 2 GiB; explicit overrides are clamped to the same safe range. Read # chatAdmission.maxInflightBytes/budgetSource at /api/monitoring/health before overriding. # OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES=134217728 -# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate -# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT -# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a -# healthy heap it is admitted instead. Range (0, 1]. Default 0.75. +# Heap-pressure shed ratio (heapUsed/heap_size_limit) for BYTE and STRUCTURE +# heavyweight admission (#10183, #10268, #10437): a concurrent heavyweight request +# past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT is only shed with a retryable 503 when the +# heap is ALSO under this much pressure — on a healthy heap it is admitted via +# healthy-headroom instead. Range (0, 1]. Default 0.75. # OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75 # Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT -# (#10437): once this many concurrent leases are active through the healthy-heap bypass, +# (#10437, BYTE + STRUCTURE, including bodies >= OMNIROUTE_CHAT_LARGE_BODY_BYTES): +# once this many concurrent leases are active through the healthy-heap bypass, # further busy requests fall through to the same bounded-wait/shed path used under real heap # pressure. 0 disables the bypass entirely. Default 1. # OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1 @@ -1323,6 +1332,16 @@ CURSOR_USER_AGENT="Cursor/3.4" # Override Codex client version sent in headers independently of the # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. # CODEX_CLIENT_VERSION=0.144.1 +# +# Override the advertised Claude Code client version independently of +# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this +# value; a UA-only override is not enough (#12417). Used by: +# src/shared/constants/claudeCodeClient.ts. +# CLAUDE_CODE_CLIENT_VERSION=2.1.259 +# +# Override the advertised GitHub Copilot CLI version independently of +# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts. +# GITHUB_COPILOT_CLI_VERSION=1.0.82 # Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits) # from the Codex Responses stream. These frames break the OpenAI SDK's @@ -1914,6 +1933,11 @@ APP_LOG_TO_FILE=true # Default: true # MODEL_CATALOG_INCLUDE_NAMES=true +# Cold-path wait bound for a coalesced GET /v1/models catalog rebuild (#12627). +# Used by: src/app/api/v1/models/catalogCache.ts +# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available. +# CATALOG_BUILD_TIMEOUT_MS=8000 + # ── NanoBanana (Image Generation) ── # Polling config for async image generation jobs. # Used by: open-sse/handlers/imageGeneration.ts diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 527ff4555e..96ae7b0729 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -23,7 +23,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 18545bfece..4738c0e422 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -75,6 +75,7 @@ import { AUTO_VARIANT_DESCRIPTIONS, type FreeModelFreeType, } from "./naming.js"; +import { applyOmniRouteInferenceTelemetry } from "./telemetry.js"; /** * Minimal leveled logger sink accepted by the default fetchers and the static @@ -1199,7 +1200,7 @@ export type OmniRouteModelsFetcher = ( export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( baseURL, apiKey, - timeoutMs = 10_000 + timeoutMs = 30_000 ) => { if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models"); if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models"); @@ -1221,9 +1222,12 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( signal: controller.signal, }); if (!res.ok) { - throw new Error( + const err = new Error( `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` - ); + ) as Error & { statusCode: number; status: number }; + err.statusCode = res.status; + err.status = res.status; + throw err; } const body = (await res.json()) as unknown; const rawList: unknown[] = Array.isArray(body) @@ -3766,6 +3770,8 @@ export function createOmniRouteFetchInterceptor(config: { baseOrigin = baseUrl.origin; const basePath = ensureV1Suffix(baseUrl.pathname); inferencePaths.add(`${basePath}/chat/completions`); + inferencePaths.add(`${basePath}/responses`); + inferencePaths.add(`${basePath}/messages`); inferencePaths.add(`${basePath}/models`); } catch { // Credential-attached base URLs are not schema-validated. A malformed @@ -3809,7 +3815,7 @@ export function createOmniRouteFetchInterceptor(config: { headers.set("Content-Type", "application/json"); } - return fetch(input, { ...init, headers }); + return applyOmniRouteInferenceTelemetry(await fetch(input, { ...init, headers })); }; } @@ -5398,7 +5404,7 @@ export function createOmniRouteConfigHook( // exact warn message so per-endpoint fallbacks are preserved. const doModels = async (): Promise => { try { - localRawModels = await fetcher(baseURL, apiKey, 10_000); + localRawModels = await fetcher(baseURL, apiKey, 30_000); } catch (err) { logAt( "error", diff --git a/@omniroute/opencode-plugin/src/telemetry.ts b/@omniroute/opencode-plugin/src/telemetry.ts new file mode 100644 index 0000000000..36ba07ea25 --- /dev/null +++ b/@omniroute/opencode-plugin/src/telemetry.ts @@ -0,0 +1,249 @@ +/** + * Map gateway-reported OmniRoute inference telemetry onto the JSON/SSE + * payload OpenCode already consumes. Prefer headers / usage fields from the + * gateway. Never invent tok/s from tokens / latency (that includes TTFT). + */ +export type OmniRouteInferenceTelemetry = { + costUsd?: number; + tokensIn?: number; + tokensOut?: number; + tokensPerSecond?: number; + ttftMs?: number; + latencyMs?: number; + model?: string; + provider?: string; +}; + +const HEADER = { + cost: "x-omniroute-response-cost", + tokensIn: "x-omniroute-tokens-in", + tokensOut: "x-omniroute-tokens-out", + tokensPerSecond: "x-omniroute-tokens-per-second", + ttftMs: "x-omniroute-ttft-ms", + latencyMs: "x-omniroute-latency-ms", + model: "x-omniroute-model", + provider: "x-omniroute-provider", +} as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readFiniteNumber(raw: string | null): number | undefined { + if (raw == null) return undefined; + const trimmed = raw.trim(); + if (trimmed === "") return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function readPositiveNumber(raw: string | null): number | undefined { + const parsed = readFiniteNumber(raw); + if (parsed === undefined || parsed <= 0) return undefined; + return parsed; +} + +function readNonNegativeInt(raw: string | null): number | undefined { + const parsed = readFiniteNumber(raw); + if (parsed === undefined || parsed < 0) return undefined; + return Math.round(parsed); +} + +function readToken(raw: string | null): string | undefined { + if (raw == null) return undefined; + const trimmed = raw.trim(); + return trimmed === "" ? undefined : trimmed; +} + +export function parseOmniRouteInferenceTelemetry(headers: Headers): OmniRouteInferenceTelemetry { + const out: OmniRouteInferenceTelemetry = {}; + const cost = readFiniteNumber(headers.get(HEADER.cost)); + if (cost !== undefined && cost >= 0) out.costUsd = cost; + const tokensIn = readNonNegativeInt(headers.get(HEADER.tokensIn)); + if (tokensIn !== undefined) out.tokensIn = tokensIn; + const tokensOut = readNonNegativeInt(headers.get(HEADER.tokensOut)); + if (tokensOut !== undefined) out.tokensOut = tokensOut; + const tps = readPositiveNumber(headers.get(HEADER.tokensPerSecond)); + if (tps !== undefined) out.tokensPerSecond = tps; + const ttft = readPositiveNumber(headers.get(HEADER.ttftMs)); + if (ttft !== undefined) out.ttftMs = ttft; + const latency = readPositiveNumber(headers.get(HEADER.latencyMs)); + if (latency !== undefined) out.latencyMs = latency; + const model = readToken(headers.get(HEADER.model)); + if (model) out.model = model; + const provider = readToken(headers.get(HEADER.provider)); + if (provider) out.provider = provider; + return out; +} + +function telemetryFromUsage(usage: Record): OmniRouteInferenceTelemetry { + const out: OmniRouteInferenceTelemetry = {}; + const tps = usage.tokens_per_second; + if (typeof tps === "number" && Number.isFinite(tps) && tps > 0) { + out.tokensPerSecond = tps; + } + const ttft = usage.ttft_ms; + if (typeof ttft === "number" && Number.isFinite(ttft) && ttft > 0) { + out.ttftMs = ttft; + } + return out; +} + +function mergeTelemetry( + base: OmniRouteInferenceTelemetry, + extra: OmniRouteInferenceTelemetry, +): OmniRouteInferenceTelemetry { + return { + ...base, + ...Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined)), + }; +} + +function isInferencePayload(payload: Record): boolean { + return ( + isRecord(payload.usage) || + Array.isArray(payload.choices) || + payload.object === "chat.completion" || + payload.object === "response" || + payload.type === "message" || + Array.isArray(payload.output) + ); +} + +function attachToUsage( + usage: Record, + telemetry: OmniRouteInferenceTelemetry, +): Record { + const next = { ...usage }; + if ( + telemetry.tokensPerSecond !== undefined && + (typeof next.tokens_per_second !== "number" || next.tokens_per_second <= 0) + ) { + next.tokens_per_second = telemetry.tokensPerSecond; + } + if (telemetry.ttftMs !== undefined && (typeof next.ttft_ms !== "number" || next.ttft_ms <= 0)) { + next.ttft_ms = telemetry.ttftMs; + } + if (telemetry.costUsd !== undefined && typeof next.cost !== "number") { + next.cost = telemetry.costUsd; + } + return next; +} + +export function attachOmniRouteTelemetryToPayload( + payload: unknown, + telemetry: OmniRouteInferenceTelemetry, +): unknown { + if (!isRecord(payload) || !isInferencePayload(payload)) { + return payload; + } + const next: Record = { ...payload }; + if (telemetry.model) { + next.model = telemetry.model; + } + if (isRecord(next.usage)) { + next.usage = attachToUsage(next.usage, mergeTelemetry(telemetry, telemetryFromUsage(next.usage))); + } + if (isRecord(next.response) && isRecord(next.response.usage)) { + next.response = { + ...next.response, + usage: attachToUsage( + next.response.usage, + mergeTelemetry(telemetry, telemetryFromUsage(next.response.usage)), + ), + }; + } + return next; +} + +export function attachOmniRouteTelemetryToSseLine( + line: string, + telemetry: OmniRouteInferenceTelemetry, +): string { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) { + return line; + } + const jsonText = trimmed.slice("data:".length).trim(); + if (!jsonText.startsWith("{")) { + return line; + } + try { + const parsed = JSON.parse(jsonText) as unknown; + const updated = attachOmniRouteTelemetryToPayload(parsed, telemetry); + if (updated === parsed) { + return line; + } + const prefix = line.slice(0, line.indexOf(jsonText)); + const suffix = line.endsWith("\r") ? "\r" : ""; + return `${prefix}${JSON.stringify(updated)}${suffix}`; + } catch { + return line; + } +} + +export async function applyOmniRouteInferenceTelemetry(response: Response): Promise { + const telemetry = parseOmniRouteInferenceTelemetry(response.headers); + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("text/event-stream") && response.body) { + return new Response(mapSseBody(response.body, telemetry), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + if (!contentType.includes("json")) { + return response; + } + const text = await response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return new Response(text, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + const next = attachOmniRouteTelemetryToPayload(parsed, telemetry); + if (next === parsed) { + return new Response(text, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + return new Response(JSON.stringify(next), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +function mapSseBody( + body: ReadableStream, + telemetry: OmniRouteInferenceTelemetry, +): ReadableStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + let live = { ...telemetry }; + return body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, { stream: true }); + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) { + controller.enqueue(encoder.encode(`${attachOmniRouteTelemetryToSseLine(line, live)}\n`)); + } + }, + flush(controller) { + if (pending.length > 0) { + controller.enqueue(encoder.encode(attachOmniRouteTelemetryToSseLine(pending, live))); + } + }, + }), + ); +} diff --git a/@omniroute/opencode-plugin/tests/models-fetcher.test.ts b/@omniroute/opencode-plugin/tests/models-fetcher.test.ts new file mode 100644 index 0000000000..c33b37b1e0 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/models-fetcher.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { defaultOmniRouteModelsFetcher } from "../src/index.js"; + +test("defaultOmniRouteModelsFetcher attaches statusCode on HTTP 401", async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "authentication expired" }), { + status: 401, + statusText: "Unauthorized", + })) as typeof fetch; + try { + await assert.rejects( + () => defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"), + (err: unknown) => { + assert.ok(err instanceof Error); + const rec = err as Error & { statusCode?: number; status?: number }; + assert.equal(rec.statusCode, 401); + assert.equal(rec.status, 401); + assert.match(rec.message, /401/); + return true; + }, + ); + } finally { + globalThis.fetch = original; + } +}); + +test("defaultOmniRouteModelsFetcher default timeout is 30s", async () => { + const original = globalThis.fetch; + let signal: AbortSignal | undefined; + globalThis.fetch = (async (_input, init) => { + signal = init?.signal ?? undefined; + return new Response(JSON.stringify({ object: "list", data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + try { + await defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"); + assert.equal(signal instanceof AbortSignal, true); + } finally { + globalThis.fetch = original; + } +}); diff --git a/@omniroute/opencode-plugin/tests/telemetry.test.ts b/@omniroute/opencode-plugin/tests/telemetry.test.ts new file mode 100644 index 0000000000..fc36db266d --- /dev/null +++ b/@omniroute/opencode-plugin/tests/telemetry.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + applyOmniRouteInferenceTelemetry, + attachOmniRouteTelemetryToPayload, + attachOmniRouteTelemetryToSseLine, + parseOmniRouteInferenceTelemetry, +} from "../src/telemetry.js"; + +test("parseOmniRouteInferenceTelemetry: copies cost, tokens, tok/s, winning model", () => { + const headers = new Headers({ + "X-OmniRoute-Response-Cost": "0.0123", + "X-OmniRoute-Tokens-In": "10", + "X-OmniRoute-Tokens-Out": "200", + "X-OmniRoute-Tokens-Per-Second": "100.5", + "X-OmniRoute-Ttft-Ms": "300", + "X-OmniRoute-Latency-Ms": "2300", + "X-OmniRoute-Model": "winner-model", + "X-OmniRoute-Provider": "openai", + }); + const got = parseOmniRouteInferenceTelemetry(headers); + assert.equal(got.costUsd, 0.0123); + assert.equal(got.tokensIn, 10); + assert.equal(got.tokensOut, 200); + assert.equal(got.tokensPerSecond, 100.5); + assert.equal(got.ttftMs, 300); + assert.equal(got.model, "winner-model"); + assert.equal(got.provider, "openai"); +}); + +test("parseOmniRouteInferenceTelemetry: omits tok/s when header missing (do not invent from latency)", () => { + const headers = new Headers({ + "X-OmniRoute-Tokens-Out": "200", + "X-OmniRoute-Latency-Ms": "2000", + }); + const got = parseOmniRouteInferenceTelemetry(headers); + assert.equal(got.tokensPerSecond, undefined); + assert.equal(got.tokensOut, 200); + const payload = attachOmniRouteTelemetryToPayload( + { object: "chat.completion", usage: { prompt_tokens: 10, completion_tokens: 200 } }, + got, + ) as { usage: { tokens_per_second?: number } }; + assert.equal(payload.usage.tokens_per_second, undefined); +}); + +test("attachOmniRouteTelemetryToPayload: writes usage.tokens_per_second and winning model", () => { + const got = attachOmniRouteTelemetryToPayload( + { + object: "chat.completion", + model: "combo/auto", + usage: { prompt_tokens: 10, completion_tokens: 200 }, + }, + { tokensPerSecond: 80, ttftMs: 250, costUsd: 0, model: "gpt-winner" }, + ) as { + model: string; + usage: { tokens_per_second: number; ttft_ms: number; cost: number }; + }; + assert.equal(got.model, "gpt-winner"); + assert.equal(got.usage.tokens_per_second, 80); + assert.equal(got.usage.ttft_ms, 250); + assert.equal(got.usage.cost, 0); +}); + +test("attachOmniRouteTelemetryToPayload: does not mutate /v1/models catalog JSON", () => { + const catalog = { object: "list", data: [{ id: "m1" }] }; + const got = attachOmniRouteTelemetryToPayload(catalog, { + tokensPerSecond: 99, + model: "should-not-apply", + }); + assert.deepEqual(got, catalog); +}); + +test("attachOmniRouteTelemetryToSseLine: patches terminal usage data line", () => { + const line = + 'data: {"object":"chat.completion.chunk","usage":{"completion_tokens":200}}'; + const got = attachOmniRouteTelemetryToSseLine(line, { tokensPerSecond: 50 }); + assert.match(got, /"tokens_per_second":50/); + assert.match(got, /^data: /); +}); + +test("applyOmniRouteInferenceTelemetry: JSON response gets header tok/s", async () => { + const response = new Response( + JSON.stringify({ + object: "chat.completion", + model: "combo/auto", + usage: { prompt_tokens: 1, completion_tokens: 20 }, + }), + { + headers: { + "Content-Type": "application/json", + "X-OmniRoute-Tokens-Per-Second": "40", + "X-OmniRoute-Model": "winner", + }, + }, + ); + const next = await applyOmniRouteInferenceTelemetry(response); + const body = JSON.parse(await next.text()) as { + model: string; + usage: { tokens_per_second: number }; + }; + assert.equal(body.model, "winner"); + assert.equal(body.usage.tokens_per_second, 40); +}); diff --git a/README.md b/README.md index 9814b0e62d..b64f3dc543 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,19 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.47B free tokens/mo · 19 routing strategies · $0 to start.
-## 💰 ~1.51B Free Tokens / Month +## 💰 ~1.47B Free Tokens / Month
-> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **437 free-tier entries across 38 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`). +> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **444 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`). -OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 38 documented recurring pool keys covering 437 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers. +OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 34 documented recurring pool keys covering 444 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers. > Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**. > @@ -209,7 +209,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files. +The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 52 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files.

@@ -518,9 +518,9 @@ Pix copia-e-cola: ## 📡 OmniRoute Radar -The main free-tier headline remains **~1.51B tokens/month** from the documented, +The main free-tier headline remains **~1.47B tokens/month** from the documented, pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first -month to **~2.13B**. Radar is an optional, signed catalog overlay for people who want fresher +month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher free-model availability between OmniRoute releases; the community catalog and every existing free feature remain free. @@ -648,7 +648,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) -> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **437 per-model rows**, **38 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md). +> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **444 per-model rows**, **34 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
@@ -1307,7 +1307,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi Resilience GuideCircuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing Auto-Combo Engine16-factor scoring, mode packs, self-healing Proxy Guide3-level proxy system, 1proxy marketplace, registry CRUD - Free TiersConsolidated directory: 38 documented recurring pools / 437 cataloged free-tier entries + Free TiersConsolidated directory: 34 documented recurring pools / 444 cataloged free-tier entries Features GalleryVisual dashboard tour with screenshots Codebase DocumentationBeginner-friendly codebase walkthrough diff --git a/bin/cli/commands/config.mjs b/bin/cli/commands/config.mjs index 6376ba9217..e8a43e0a6c 100644 --- a/bin/cli/commands/config.mjs +++ b/bin/cli/commands/config.mjs @@ -16,6 +16,29 @@ function ensureBackup(configPath) { return backupPath; } +function mergeClaudeSettings(existingContent, generatedContent) { + const generated = JSON.parse(generatedContent); + let current = {}; + if (existingContent && existingContent.trim()) { + current = JSON.parse(existingContent); + if (!current || typeof current !== "object" || Array.isArray(current)) current = {}; + } + return JSON.stringify( + { + ...current, + ...generated, + env: { + ...(current.env && typeof current.env === "object" && !Array.isArray(current.env) + ? current.env + : {}), + ...(generated.env || {}), + }, + }, + null, + 2 + ); +} + async function runConfigListCommand(opts = {}) { const { detectAllTools } = await import("../../../src/lib/cli-helper/tool-detector.ts"); const tools = await detectAllTools(); @@ -120,7 +143,12 @@ async function runConfigSetCommand(toolId, opts = {}) { const backupPath = ensureBackup(result.configPath); if (backupPath) printInfo(`Backup saved to: ${backupPath}`); - fs.writeFileSync(result.configPath, result.content, "utf-8"); + let content = result.content; + if (toolId === "claude" && fs.existsSync(result.configPath)) { + content = mergeClaudeSettings(fs.readFileSync(result.configPath, "utf-8"), result.content); + } + + fs.writeFileSync(result.configPath, content, "utf-8"); printSuccess(`Config written to ${result.configPath}`); return 0; } diff --git a/bin/cli/commands/tunnel.mjs b/bin/cli/commands/tunnel.mjs index df07507a66..689a8a7f6b 100644 --- a/bin/cli/commands/tunnel.mjs +++ b/bin/cli/commands/tunnel.mjs @@ -18,7 +18,7 @@ export function registerTunnel(program) { }); tunnel - .command("create [type]") + .command("create") .description(t("tunnel.createDescription")) .addArgument( new Argument("[type]", "Tunnel type").choices(VALID_TUNNEL_TYPES).default("cloudflare") diff --git a/changelog.d/features/12473-github-live-catalog-filter.md b/changelog.d/features/12473-github-live-catalog-filter.md new file mode 100644 index 0000000000..8eed69412e --- /dev/null +++ b/changelog.d/features/12473-github-live-catalog-filter.md @@ -0,0 +1 @@ +- **feat(providers):** skip GitHub combo members missing from the live synced catalog, and drop Copilot models that are policy-disabled or hidden from the model picker ([#12473](https://github.com/diegosouzapw/OmniRoute/pull/12473)) — thanks @RaviTharuma diff --git a/changelog.d/features/12616-tokens-per-second.md b/changelog.d/features/12616-tokens-per-second.md new file mode 100644 index 0000000000..164161da99 --- /dev/null +++ b/changelog.d/features/12616-tokens-per-second.md @@ -0,0 +1 @@ +- **feat(api):** Emit gateway-measured `tokens_per_second` (TTFT excluded) on streaming usage and `X-OmniRoute-Tokens-Per-Second` when first-token latency is known ([#12616](https://github.com/diegosouzapw/OmniRoute/issues/12616)) diff --git a/changelog.d/features/12636-opencode-plugin-gateway-telemetry.md b/changelog.d/features/12636-opencode-plugin-gateway-telemetry.md new file mode 100644 index 0000000000..390f3df419 --- /dev/null +++ b/changelog.d/features/12636-opencode-plugin-gateway-telemetry.md @@ -0,0 +1 @@ +- **feat(opencode-plugin): map gateway cost/usage/tok/s onto OpenCode inference payloads** — the official plugin copies `X-OmniRoute-Response-Cost`, token counts, `X-OmniRoute-Tokens-Per-Second` / `usage.tokens_per_second`, TTFT, and the winning `X-OmniRoute-Model` onto the JSON/SSE body OpenCode already consumes. Missing tok/s is left unset (never `tokens / latency`). (#12636) diff --git a/changelog.d/fixes/0000-public-error-boundary-hardening.md b/changelog.d/fixes/0000-public-error-boundary-hardening.md new file mode 100644 index 0000000000..c0531eba1d --- /dev/null +++ b/changelog.d/fixes/0000-public-error-boundary-hardening.md @@ -0,0 +1 @@ +- **fix(security):** Sanitize provider and runtime failures before public API, SSE and MCP responses and before persistent request, proxy and usage logs, preventing credentials, stack traces and host filesystem paths from crossing those boundaries while preserving stable error codes and useful diagnostics. diff --git a/changelog.d/fixes/0000-tunnel-create-crash.md b/changelog.d/fixes/0000-tunnel-create-crash.md new file mode 100644 index 0000000000..5fbfd488c8 --- /dev/null +++ b/changelog.d/fixes/0000-tunnel-create-crash.md @@ -0,0 +1 @@ +- **fix(cli):** `omniroute tunnel create` no longer crashes with `Cannot read properties of undefined (reading optsWithGlobals)` — removed the duplicate positional argument that caused Commander.js to misalign the action callback parameters ([#12295](https://github.com/diegosouzapw/OmniRoute/issues/12295)) diff --git a/changelog.d/fixes/11773-cerebras-free-tier.md b/changelog.d/fixes/11773-cerebras-free-tier.md new file mode 100644 index 0000000000..ecc67975de --- /dev/null +++ b/changelog.d/fixes/11773-cerebras-free-tier.md @@ -0,0 +1 @@ +- **fix(providers):** reclassify Cerebras as a one-time $5 signup credit (payment method required, 30-day validity), not a recurring no-card 1M tokens/day trial ([#11773](https://github.com/diegosouzapw/OmniRoute/issues/11773)) diff --git a/changelog.d/fixes/12326-combo-delete-lkgp-cleanup.md b/changelog.d/fixes/12326-combo-delete-lkgp-cleanup.md new file mode 100644 index 0000000000..be5bc5678f --- /dev/null +++ b/changelog.d/fixes/12326-combo-delete-lkgp-cleanup.md @@ -0,0 +1 @@ +- **fix(combos):** deleting a combo now clears its persisted LKGP pins instead of leaving unreachable `key_value` rows behind ([#12326](https://github.com/diegosouzapw/OmniRoute/issues/12326)) diff --git a/changelog.d/fixes/12368-tunnel-create-crash.md b/changelog.d/fixes/12368-tunnel-create-crash.md new file mode 100644 index 0000000000..a1d71d3abb --- /dev/null +++ b/changelog.d/fixes/12368-tunnel-create-crash.md @@ -0,0 +1 @@ +- **fix(cli):** `omniroute tunnel create` no longer crashes with `Cannot read properties of undefined (reading optsWithGlobals)` — removed the duplicate positional argument that caused Commander.js to misalign the action callback parameters ([#12295](https://github.com/diegosouzapw/OmniRoute/issues/12295), [#12368](https://github.com/diegosouzapw/OmniRoute/pull/12368)) diff --git a/changelog.d/fixes/12369-i18n-cc-onboarding-placeholder.md b/changelog.d/fixes/12369-i18n-cc-onboarding-placeholder.md new file mode 100644 index 0000000000..fac5d9349b --- /dev/null +++ b/changelog.d/fixes/12369-i18n-cc-onboarding-placeholder.md @@ -0,0 +1 @@ +- **fix(i18n):** wrap `ccOnboardingKeyPlaceholder` in ICU single quotes across all 43 locales so angle brackets render literally instead of being parsed as rich-text tags, which crashed the Claude Code onboarding block with `INVALID_MESSAGE: INVALID_TAG` ([#12302](https://github.com/diegosouzapw/OmniRoute/issues/12302)) diff --git a/changelog.d/fixes/12371-kimi-k3-effort-tiers.md b/changelog.d/fixes/12371-kimi-k3-effort-tiers.md new file mode 100644 index 0000000000..49a3310761 --- /dev/null +++ b/changelog.d/fixes/12371-kimi-k3-effort-tiers.md @@ -0,0 +1 @@ +- **fix(models):** publish `effort_tiers` on Kimi K3's synced base-model entries (`kmca/k3`, `kmca/k3-256k`) so catalog-only clients (OpenCode, plain SDK pickers) can see and select the reasoning tiers (`low`/`high`/`max`) the synced metadata already carried — the `isSkippedEffortProvider` gate no longer suppresses tier visibility on those base entries, while synthetic `-` variant generation stays prevented and Codex/GLM base models remain excluded unchanged ([#12299](https://github.com/diegosouzapw/OmniRoute/issues/12299)) diff --git a/changelog.d/fixes/12417-cli-version-env.md b/changelog.d/fixes/12417-cli-version-env.md new file mode 100644 index 0000000000..5fbe8327a1 --- /dev/null +++ b/changelog.d/fixes/12417-cli-version-env.md @@ -0,0 +1 @@ +- **fix(providers):** add `CLAUDE_CODE_CLIENT_VERSION` and `GITHUB_COPILOT_CLI_VERSION` env overrides so Anthropic/Copilot client-version gates can be unblocked without a rebuild ([#12417](https://github.com/diegosouzapw/OmniRoute/issues/12417)) diff --git a/changelog.d/fixes/12602-opencode-plugin-models-timeout-status.md b/changelog.d/fixes/12602-opencode-plugin-models-timeout-status.md new file mode 100644 index 0000000000..e86ff8c78d --- /dev/null +++ b/changelog.d/fixes/12602-opencode-plugin-models-timeout-status.md @@ -0,0 +1 @@ +- OpenCode plugin `/v1/models` catalog fetch now waits 30s by default and attaches HTTP `statusCode` on 401/5xx so host fallback plugins can hop instead of seeing an untyped AbortError/UnknownError. diff --git a/changelog.d/fixes/12627-catalog-inflight-timeout.md b/changelog.d/fixes/12627-catalog-inflight-timeout.md new file mode 100644 index 0000000000..b50416b2c0 --- /dev/null +++ b/changelog.d/fixes/12627-catalog-inflight-timeout.md @@ -0,0 +1 @@ +- **fix(api):** GET /v1/models no longer waits forever on a hung coalesced catalog rebuild; cold-path waits are bounded (`CATALOG_BUILD_TIMEOUT_MS`, default 8s) and a last-good 200 is served when the rebuild times out ([#12627](https://github.com/diegosouzapw/OmniRoute/issues/12627)). diff --git a/changelog.d/fixes/pending-onemin-stream-error-boundary.md b/changelog.d/fixes/pending-onemin-stream-error-boundary.md new file mode 100644 index 0000000000..818b124a2f --- /dev/null +++ b/changelog.d/fixes/pending-onemin-stream-error-boundary.md @@ -0,0 +1 @@ +- **fix(providers):** keep 1min.ai HTTP 200 stream errors out of assistant content, preserve partial output, and expose sanitized terminal errors so pre-content failures can fall back. diff --git a/changelog.d/fixes/reset-aware-model-family.md b/changelog.d/fixes/reset-aware-model-family.md new file mode 100644 index 0000000000..75b65e7613 --- /dev/null +++ b/changelog.d/fixes/reset-aware-model-family.md @@ -0,0 +1 @@ +Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider. diff --git a/changelog.d/maintenance/12352-apikeys-filesize.md b/changelog.d/maintenance/12352-apikeys-filesize.md new file mode 100644 index 0000000000..6dfbfddc4f --- /dev/null +++ b/changelog.d/maintenance/12352-apikeys-filesize.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline `src/lib/db/apiKeys.ts` for the ACL the key-creation path now preserves ([#12352](https://github.com/diegosouzapw/OmniRoute/pull/12352)) diff --git a/changelog.d/maintenance/12641-chat-filesize.md b/changelog.d/maintenance/12641-chat-filesize.md new file mode 100644 index 0000000000..c295e76bde --- /dev/null +++ b/changelog.d/maintenance/12641-chat-filesize.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline `src/sse/handlers/chat.ts` for the effective-input persistence the continuation fix needs ([#12641](https://github.com/diegosouzapw/OmniRoute/pull/12641)) diff --git a/changelog.d/maintenance/error-boundary-campaign-filesize.md b/changelog.d/maintenance/error-boundary-campaign-filesize.md new file mode 100644 index 0000000000..f57eef1c49 --- /dev/null +++ b/changelog.d/maintenance/error-boundary-campaign-filesize.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline the file-size caps the error-boundary campaign grew past (`open-sse/executors/codex.ts`, `open-sse/vendor/codex-chatgpt-web/bridge.ts`, both via [#12444](https://github.com/diegosouzapw/OmniRoute/pull/12444)) diff --git a/changelog.d/maintenance/houminxi-combo-filesize.md b/changelog.d/maintenance/houminxi-combo-filesize.md new file mode 100644 index 0000000000..8a290c2461 --- /dev/null +++ b/changelog.d/maintenance/houminxi-combo-filesize.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline `open-sse/services/combo.ts` for the reset-aware scoring the HouMinXi batch stacked ([#12637](https://github.com/diegosouzapw/OmniRoute/pull/12637)) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index e10d1f6551..fe963b547c 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -854,6 +854,9 @@ "src/app/(dashboard)/dashboard/combos/page.tsx": { "@typescript-eslint/no-unused-vars": { "count": 6 + }, + "react-hooks/set-state-in-effect": { + "count": 1 } }, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index cea73d4708..f8b7a6ca2d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,7 @@ { + "_rebaseline_2026_09_03_reset_aware_model_family": "Own growth: open-sse/services/combo.ts 4036->4041 (+5). buildAutoCandidates now keys the reset-aware quota cache by getQuotaFetchScope and spreads requestedModel onto the connection so Gemini windows stay off a Claude-empty Antigravity account. Irreducible wiring at the existing fetchResetAwareQuotaWithCache call site; the family helper itself lives in antigravityQuotaFamily.ts. Covered by tests/unit/reset-aware-request-scope-12600.test.ts.", + "_rebaseline_2026_09_03_overloaded_not_provider_breaker": "fix/overloaded-not-provider-breaker own growth: open-sse/services/combo.ts 4036->4075 (check-file-size split-newline, +39). Circuit-open pre-skip now records the breaker retryAfter and, when every target was skipped that way, waits the short reset via resolveCircuitOpenWaitDecision (new leaf in comboCooldownRetry.ts) instead of crystallizing ALL_TARGETS_SKIPPED in ~43ms. skippedForCircuitOpen / earliestCircuitOpenRetryMs reset each setTry so a later iteration cannot inherit a stale retryAfter. Irreducible at the existing ALL_TARGETS_SKIPPED chokepoint (same pattern as #7301/#8213 cooldown-wait). Predicate itself lives in circuitBreaker.ts / comboPredicates.ts / chatPredicates.ts, all under cap. Covered by tests/unit/overloaded-not-provider-breaker.test.ts + combo-cooldown-retry.test.ts.", + "_rebaseline_2026_09_03_12649_free_tier_reaudit_gateways": "PR #12649 (fix/free-tier-quota-reaudit) own growth: src/shared/constants/providers/apikey/gateways.ts 1459->1462 (+3 = the nara authHint rewritten for the re-audited 7M/day plan now wraps to two lines, plus the Prettier reflow of two pre-existing >100-col authHint lines (oneminai, freebuff) that lint-staged enforces on any touch of the file; additive text at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #11786 seekai, #10987 logfare, #10531 freebuff). Covered by tests/unit/free-tier-reaudit-2026-09.test.ts and tests/unit/free-providers-batch-2026-07.test.ts.", "_rebaseline_2026_09_03_moonshot_native_quota": "PR feat/moonshot-native-quota own growth on release/v3.8.51: src/lib/db/migrationRunner.ts 1201->1206 (+5, case 172 retroactive guard for daily_quota_reset_* columns); src/sse/handlers/chat.ts 2434->2450 (+16, registerMoonshotQuotaFetcher + startup node scan at the existing quota-fetcher registration chokepoint); src/sse/services/auth.ts 3427->3450 (+23, resolveDailyResetForProvider + dailyReset arg on checkFallbackError); open-sse/services/accountFallback.ts 2422->2461 (+39, compatible-node credits_exhausted carve-out + TPD node-clock lock); tests/unit/account-fallback-service.test.ts 2008->2056 (+48, TPD/empty-wallet cases). Wiring at existing chokepoints; Moonshot host predicates, daily reset clock, and the balance fetcher live in new leaves under cap. Covered by tests/unit/moonshot-*.test.ts + account-fallback-service.test.ts (135/135 focused).", "_rebaseline_2026_09_02_11786_seekai_provider": "PR #11786 (feat/11786-seekai-provider, closes #11786) own growth: src/shared/constants/providers/apikey/gateways.ts 1438->1458 (check-file-size split-newline=1459; the seekai APIKEY_PROVIDERS_GATEWAYS catalog entry plus authHint, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #10987 logfare, #10531 freebuff). Covered by tests/unit/seekai-provider.test.ts.", "_rebaseline_2026_09_02_12325_generic_429_invalidate": "PR #12325 own growth: open-sse/handlers/chatCore.ts 5946->5955 (+9 = the non-Codex 429 else-if that drops the generic quota wrapper and stamps force-refresh, plus a source-regex breadcrumb). Irreducible call-site wiring next to the existing Codex 429 invalidateCodexQuotaCache branch; not extractable without splitting handleChatCore mid-response. Covered by tests/unit/generic-quota-fetcher.test.ts (31/31) and tests/unit/antigravity-429-quota-cooldown.test.ts.", @@ -422,7 +425,7 @@ "open-sse/mcp-server/server.ts": 1572, "open-sse/services/accountFallback.ts": 2467, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, - "open-sse/services/combo.ts": 4036, + "open-sse/services/combo.ts": 4080, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, "open-sse/utils/proxyFetch.ts": 1271, @@ -446,15 +449,15 @@ "src/app/api/providers/[id]/test/route.ts": 1252, "src/app/api/v1/models/catalog.ts": 2075, "src/app/docs/lib/openapi.generated.ts": 1347, - "src/lib/db/apiKeys.ts": 1610, + "src/lib/db/apiKeys.ts": 1625, "src/lib/db/core.ts": 1745, "src/lib/db/migrationRunner.ts": 1206, "src/lib/tailscaleTunnel.ts": 1208, "src/lib/tokenHealthCheck.ts": 1218, "src/shared/components/RequestLoggerV2.tsx": 1718, - "src/shared/constants/providers/apikey/gateways.ts": 1459, + "src/shared/constants/providers/apikey/gateways.ts": 1462, "src/shared/services/cliRuntime.ts": 1296, - "src/sse/handlers/chat.ts": 2450, + "src/sse/handlers/chat.ts": 2454, "src/sse/services/auth.ts": 3450, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656 @@ -555,7 +558,7 @@ "open-sse/services/accountFallback.ts": "1978", "open-sse/services/adobeFireflyClient.ts": "2385", "open-sse/services/claudeCodeCompatible.ts": "1202", - "open-sse/services/combo.ts": "3648", + "open-sse/services/combo.ts": "4075", "open-sse/services/compression/strategySelector.ts": "1060", "open-sse/services/rateLimitManager.ts": "1167", "open-sse/translator/response/openai-responses.ts": "1204", @@ -637,5 +640,9 @@ "_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base).", "_rebaseline_2026_09_03_12604_claude_code_2_1_258": "PR #12604 (bump da wire identity do Claude Code 2.1.220->2.1.258, commits do @ggiak vindos do #12402) crescimento proprio: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1606->1607 (+1, a linha do seletor que acompanha a nova versao de identidade). Uma linha num painel de settings ja existente; nao ha o que extrair. Coberto por client-identity-profiles e claude-codex-identity-version-sync (138/138 focados).", "_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base).", - "_rebaseline_2026_09_03_12605_refresh": "Refresh of #12605 onto origin/release/v3.8.51 after 38 commits. Two frozen files grew on the tip itself: open-sse/executors/codex.ts 1503->1505 and open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335. Caps set to check:file-size measured LOC (split-on-newline). No other entries moved in this refresh." + "_rebaseline_2026_09_03_12605_refresh": "Refresh of #12605 onto origin/release/v3.8.51 after 38 commits. Two frozen files grew on the tip itself: open-sse/executors/codex.ts 1503->1505 and open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335. Caps set to check:file-size measured LOC (split-on-newline). No other entries moved in this refresh.", + "_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente.", + "_rebaseline_2026_09_03_12352_apikey_acl": "PR #12352 (fix/api-key-create-acl-12275) crescimento proprio: src/lib/db/apiKeys.ts 1610->1625 (+15). A criacao de API key descartava a ACL enviada no payload; preservar essa ACL exige carregar e persistir o conjunto no mesmo chokepoint de INSERT do modulo de dominio, sem extracao possivel sem partir a funcao de criacao ao meio. Coberto pelos testes do proprio PR (54/54 focados na leva).", + "_rebaseline_2026_09_03_houminxi_combo_stacked": "Leva HouMinXi (#12624 #12626 #12632 #12637): open-sse/services/combo.ts 4075->4080 (+5), medido no tip com os quatro mergeados. Cada PR registrou o proprio crescimento contra o tip de onde forkou (o #12637 ja subira o cap para 4075); as 5 linhas restantes so aparecem quando eles empilham, porque mais de um toca o mesmo chokepoint de scoring reset-aware em combo.ts. Fiacao em ponto existente, sem extracao possivel sem partir a funcao de selecao de alvos. Coberto por combo-strategies e reset-aware-request-scope-12600 (119/119 focados na leva).", + "_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva)." } diff --git a/docs/architecture/admission-lanes.md b/docs/architecture/admission-lanes.md index 2b2a4cf96b..ddb8ea5fac 100644 --- a/docs/architecture/admission-lanes.md +++ b/docs/architecture/admission-lanes.md @@ -120,3 +120,22 @@ against the **parent's** tenant lane. The byte-level lanes bound the memory-heavy parse/compress path; the adaptive lanes bound dispatch cost per tenant. #9654's criterion 1 ("one session's burst does not 503 another") is enforced by system 1 unconditionally and by system 2 once opt-in is enabled. + +## 4. One-process long `/v1/responses` (healthy-headroom) + +[#10437](https://github.com/diegosouzapw/OmniRoute/pull/10437) added +`tryAcquireHealthyHeadroom` so a second structurally-heavy request is admitted +when the heap is below `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO`. The BYTE +path used by `admitChatRequest` (bodies ≥ `OMNIROUTE_CHAT_LARGE_BODY_BYTES`, +default 256 KiB, including `POST /v1/responses`) uses the **same** escape. + +This is the supported **one-process** recipe for more than two concurrent long +SSE `/v1/responses`: raise primary + healthy-headroom only as far as the heap +and the process-wide inflight-byte budget (`OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` +/ #10110) allow. Tens of long SSE clients (40–50) is that memory-budget +question, not a hard “max 2” product limit. A pressured heap still sheds with +retryable `503` so #7849 does not return. + +To **multiply heaps**, run N independent `DATA_DIR`s (#11024). Never +`replicas > 1` on one SQLite file (#10350). This section is not a reopen of +the DATA_DIR scale-out recipe. diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index 62ef3efff9..610e2a4dba 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -10,16 +10,16 @@ Mermaid sources (`.mmd`) and exported SVGs for OmniRoute v3.8.0 architecture flo ## Canonical diagrams -| Source | Exported | Used in | -| ---------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------ | -| [request-pipeline.mmd](./request-pipeline.mmd) | [SVG](./exported/request-pipeline.svg) | docs/architecture/ARCHITECTURE.md, docs/architecture/CODEBASE_DOCUMENTATION.md | +| Source | Exported | Used in | +| -------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------ | +| [request-pipeline.mmd](./request-pipeline.mmd) | [SVG](./exported/request-pipeline.svg) | docs/architecture/ARCHITECTURE.md, docs/architecture/CODEBASE_DOCUMENTATION.md | | [auto-combo-scoring.mmd](./auto-combo-scoring.mmd) | [SVG](./exported/auto-combo-scoring.svg) | docs/routing/AUTO-COMBO.md | -| [resilience-3layers.mmd](./resilience-3layers.mmd) | [SVG](./exported/resilience-3layers.svg) | docs/architecture/RESILIENCE_GUIDE.md, CLAUDE.md | -| [i18n-flow.mmd](./i18n-flow.mmd) | [SVG](./exported/i18n-flow.svg) | docs/guides/I18N.md | -| [mcp-tools.mmd](./mcp-tools.mmd) | [SVG](./exported/mcp-tools.svg) | docs/frameworks/MCP-SERVER.md | -| [cloud-agent-flow.mmd](./cloud-agent-flow.mmd) | [SVG](./exported/cloud-agent-flow.svg) | docs/frameworks/CLOUD_AGENT.md | -| [authz-pipeline.mmd](./authz-pipeline.mmd) | [SVG](./exported/authz-pipeline.svg) | docs/architecture/AUTHZ_GUIDE.md | -| [db-schema-overview.mmd](./db-schema-overview.mmd) | [SVG](./exported/db-schema-overview.svg) | docs/architecture/CODEBASE_DOCUMENTATION.md | +| [resilience-3layers.mmd](./resilience-3layers.mmd) | [SVG](./exported/resilience-3layers.svg) | docs/architecture/RESILIENCE_GUIDE.md, CLAUDE.md | +| [i18n-flow.mmd](./i18n-flow.mmd) | [SVG](./exported/i18n-flow.svg) | docs/guides/I18N.md | +| [mcp-tools.mmd](./mcp-tools.mmd) | [SVG](./exported/mcp-tools.svg) | docs/frameworks/MCP-SERVER.md | +| [cloud-agent-flow.mmd](./cloud-agent-flow.mmd) | [SVG](./exported/cloud-agent-flow.svg) | docs/frameworks/CLOUD_AGENT.md | +| [authz-pipeline.mmd](./authz-pipeline.mmd) | [SVG](./exported/authz-pipeline.svg) | docs/architecture/AUTHZ_GUIDE.md | +| [db-schema-overview.mmd](./db-schema-overview.mmd) | [SVG](./exported/db-schema-overview.svg) | docs/architecture/CODEBASE_DOCUMENTATION.md | ## Hand-authored animated diagrams @@ -34,7 +34,7 @@ inside GitHub's `` sandbox: | [combo-always-on.svg](./combo-always-on.svg) | style reference | Animated priority-combo fallback (4 layers, 16s loop). Edit the SVG directly — there is no `.mmd` source. | | [cli-terminal.svg](./cli-terminal.svg) | README.md (root) | Compact half-height animated terminal (1200×350): 3 real CLI commands cycling with typewriter + scrolling subcommand ticker; first frame = completed providers screen. Edit the SVG directly — there is no `.mmd` source. | | [compression-pipeline.svg](./compression-pipeline.svg) | README.md (root) | Animated 12-engine compression funnel (8s loop). Edit the SVG directly — there is no `.mmd` source. | -| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.51B/mo quantified headline, 20-pool budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. | +| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.47B/mo quantified headline, 16-pool + Groq-caps budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. | | [readme-hero.svg](./readme-hero.svg) | README.md (root) | Animated hero card (tagline, live provider/free-access headline, full-width compression bar demo, 6 stat chips). Edit the SVG directly — there is no `.mmd` source. | | [promise-pillars.svg](./promise-pillars.svg) | README.md (root) | Animated "The Promise" 6-pillar card (12s border-highlight sweep). Edit the SVG directly — there is no `.mmd` source. | | [why-pain-fix.svg](./why-pain-fix.svg) | README.md (root) | Animated "Why OmniRoute" 10-row pain-vs-fix ledger (15s green row sweep). Edit the SVG directly — there is no `.mmd` source. | diff --git a/docs/diagrams/free-tier-budget.svg b/docs/diagrams/free-tier-budget.svg index 72d1219cb2..f706e4824f 100644 --- a/docs/diagrams/free-tier-budget.svg +++ b/docs/diagrams/free-tier-budget.svg @@ -1,5 +1,5 @@ - - Pool-deduplicated chart of the 20 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately. + + Pool-deduplicated chart of the 16 recurring free-token pools with positive published budgets (plus Groq's five per-model caps as one segment), plus signup credits and uncapped providers shown separately. @@ -61,10 +61,10 @@ - ~1.51B + ~1.47B FREE TOKENS / MONTH · STEADY - up to ~2.13B in your first month — signup credits - documented free tiers · 38 recurring pools · 437 catalog entries · one endpoint + up to ~2.10B in your first month — signup credits + documented free tiers · 34 recurring pools · 444 catalog entries · one endpoint @@ -75,66 +75,60 @@ every rate limit · 24/7 we don't publish that - ~1.51B + ~1.47B each shared free pool counted once ✓ 13 providers ToS-flagged — we flag it · you decide - - WHERE IT COMES FROM · 20 QUANTIFIED RECURRING POOLS + + WHERE IT COMES FROM · 16 QUANTIFIED POOLS + 5 GROQ PER-MODEL CAPS - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - each segment = one recurring pool · widths floored so every pool shows · audited pool budgets below + each segment = one recurring pool (Groq = its five per-model caps) · widths floored so every pool shows · audited pool budgets below - + Mistral 1.00B - LLM7 150M - Nara 150M - Gemini 60M - Cerebras 30M - Cloudflare AI 30M - API Airforce 24M - Ollama Cloud 20M - Groq 15M - Bluesminds 7.2M - SambaNova 6M - Arcee 4.8M - Navy 4.5M - BazaarLink 3.6M - OpenRouter 1.2M - Cohere 800K - HuggingChat 500K - Morph 400K - Hugging Face 200K - Kiro 25K + Nara 210M + LLM7 150M + Groq 30M · 5 caps + Cloudflare AI 30M + API Airforce 24M + Bluesminds 7.2M + SambaNova 6M + Arcee 4.8M + Navy 4.5M + BazaarLink 3.6M + OpenRouter 1.2M + Cohere 800K + HuggingChat 500K + Morph 400K + Hugging Face 200K + Kiro 25K @@ -178,10 +172,14 @@ OpenCode Zen baidu - - + + Gemini + + Ollama Cloud + + - $10 OpenRouter top-up → +24M/mo + $10 OpenRouter top-up → +24M/mo surfaced separately — never inflates the headline diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index 41bcdf397c..43d5fb8381 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -1,4 +1,4 @@ - + Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle. @@ -73,7 +73,7 @@ $0 to start - 150+ providers with a free tier, 53 free + 150+ providers with a free tier, 52 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow… No card needed. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index 2fc0a31918..6f56f7edfc 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. @@ -72,7 +72,7 @@ 90+ FREE TIERS - ~1.51B + ~1.47B FREE TOKENS / MO 15–95% diff --git a/docs/getting-started/FREE-TIERS-GUIDE.md b/docs/getting-started/FREE-TIERS-GUIDE.md index fe7ebbcc49..f93250470a 100644 --- a/docs/getting-started/FREE-TIERS-GUIDE.md +++ b/docs/getting-started/FREE-TIERS-GUIDE.md @@ -1,6 +1,6 @@ # Free Tiers Guide: Understand and Combine Free AI Access -> **TL;DR**: OmniRoute registers 351 provider IDs, with **152 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **39 recurring pool keys / 445 entries** (438 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies. +> **TL;DR**: OmniRoute registers 352 provider IDs, with **152 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **34 recurring pool keys / 444 entries** (437 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies. --- @@ -159,13 +159,13 @@ provider's quota or access policy. The live, pool-deduplicated catalog currently reports: -| Metric | Current audited value | Interpretation | -| ---------------------------------------------------- | -----------------------------------------------: | ----------------------------------------------------------------------------------------- | -| Recurring quantified grant | **~1.51B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum | -| First month with signup grants | **~2.13B tokens** | Recurring total plus one-time and recurring credits | -| Audited free-model inventory | **39 recurring pool keys / 445 catalog entries** | 438 active + 7 discontinued; distinct from the 351-provider catalog | -| Recurring/keyless free-forever providers represented | **55** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types | -| Provider catalog entries marked `hasFree` | **152 / 351** | Broader provider metadata; not all have a quantifiable recurring quota | +| Metric | Current audited value | Interpretation | +| ---------------------------------------------------- | -----------------------------------------------: | -------------------------------------------------------------------------------------------------------------------------- | +| Recurring quantified grant | **~1.47B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum | +| First month with signup grants | **~2.10B tokens** | Recurring total plus one-time and recurring credits | +| Audited free-model inventory | **34 recurring pool keys / 444 catalog entries** | 437 active + 7 discontinued; distinct from the 352-provider catalog | +| Recurring/keyless free-forever providers represented | **52** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types, eligibility-gated rows excluded | +| Provider catalog entries marked `hasFree` | **152 / 352** | Broader provider metadata; not all have a quantifiable recurring quota | These values are computed from `open-sse/config/freeModelCatalog.ts`; see the [Free Tiers Reference](../reference/FREE_TIERS.md) for pool deduplication, ToS flags, diff --git a/docs/getting-started/PROVIDERS-GUIDE.md b/docs/getting-started/PROVIDERS-GUIDE.md index 1ac7bbfecf..c65b6ad647 100644 --- a/docs/getting-started/PROVIDERS-GUIDE.md +++ b/docs/getting-started/PROVIDERS-GUIDE.md @@ -183,7 +183,7 @@ These providers offer **free access** with no credit card: | **LongCat** | 10M one-time | LongCat-2.0 | API key + KYC | | **Cloudflare AI** | 10K neurons/day | 50+ models | No auth needed | | **NVIDIA NIM** | ~40 RPM | 129 models | API key needed | -| **Cerebras** | 1M tokens/day | Qwen3 235B, GPT-OSS 120B | API key needed | +| **Cerebras** | $5 signup credit | GLM 4.7, GPT-OSS 120B | API key + card | | **Qoder** | Unlimited | Kimi-K2, DeepSeek-R1, Qwen3-coder | No auth needed | **Tip**: Connect multiple free providers for **unlimited free AI** with automatic fallback! diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 7d5137f6f9..1bf026c0b8 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -567,19 +567,23 @@ External Postgres / multi-writer HA is **not** a documented stock path. If you n ## Scale-out: N independent processes -One Node process is **one V8 heap**. Two overlapping ~3 MiB / ~750k-token coding-agent `POST /v1/responses` (RTK + Caveman) abort that heap at ~12 Gi (`FATAL ERROR: Reached heap limit`) and can OOM a 16 Gi cgroup. See [#7849](https://github.com/diegosouzapw/OmniRoute/issues/7849). Heavyweight chat admission is gated by an auto-derived ingest byte budget (`OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES`, `src/shared/middleware/admissionBudget.ts`) sized from that same V8/cgroup ceiling -- it already scales itself to the process's real memory, so overriding it upward (or setting the legacy `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` request-count cap) on an already-sized process reintroduces the abort. Small chats, `/healthz`, `/v1/models`, and MCP are **not** in that cap. +One Node process is **one V8 heap**. Two overlapping ~3 MiB / ~750k-token coding-agent `POST /v1/responses` (RTK + Caveman) abort that heap at ~12 Gi (`FATAL ERROR: Reached heap limit`) and can OOM a 16 Gi cgroup. See [#7849](https://github.com/diegosouzapw/OmniRoute/issues/7849). That measurement is a **memory-budget** warning, not a product hard-max of two concurrent long `/v1/responses`. Heavyweight chat admission is gated by an auto-derived ingest byte budget (`OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES`, `src/shared/middleware/admissionBudget.ts`) sized from that same V8/cgroup ceiling — overriding it upward (or setting the legacy `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` request-count cap) on an already-sized process reintroduces the abort. Small chats, `/healthz`, `/v1/models`, and MCP are **not** in that cap. -To go beyond two concurrent **large** jobs **today**: +### One-process: more than two long `/v1/responses` -| Do | Do not | -| -------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| Run **N containers/pods**, each with its **own** `DATA_DIR` / volume | Set `replicas > 1` against one SQLite file | -| Keep each instance at 1–2 heavy in-flight and 12–16 Gi cgroup | Give one process 8× RAM and `max=8` | -| Optional: `QUOTA_STORE_DRIVER=redis` + `QUOTA_STORE_REDIS_URL` for **shared quota counters** | Treat Redis as shared SQLite — it is not | -| Duplicate provider secrets into each instance (or accept partitioned dashboards) | Expect one dashboard / one call-log across instances | -| Front with any load balancer; sticky by API key or session is enough | Require a vendor-specific size-aware middleware | +A **healthy** process (heap below `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO`, default `0.75`) **may** run more than two concurrent long `POST /v1/responses` when the process-wide inflight-byte budget (`OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` / #10110) still has room. Bodies at or above `OMNIROUTE_CHAT_LARGE_BODY_BYTES` (default 256 KiB) take the same heavyweight lease as structure-heavy requests and use the same [#10437](https://github.com/diegosouzapw/OmniRoute/pull/10437) `tryAcquireHealthyHeadroom` escape (`OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM`). Tens of concurrent long SSE clients (operators often need 40–50) is a **memory-budget** question — size heap + primary/headroom slots + `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` — not a hard “max 2” product limit. A pressured heap still sheds with retryable `503` so #7849 does not return. -Hardware: `concurrent_large ≈ N × 2` at ~8–12 Gi heap / ~12–16 Gi cgroup **per instance**. Host RAM must cover `N × cgroup`, not “one 16 Gi pod with N=8.” +To **multiply heaps** (independent V8 old-spaces) **today**: + +| Do | Do not | +| --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| Run **N containers/pods**, each with its **own** `DATA_DIR` / volume | Set `replicas > 1` against one SQLite file | +| Size heavy in-flight + healthy-headroom from heap / inflight-byte budget; 1–2 is the conservative #7849 default, not a hard product max | Give one process 8× RAM and an unbounded count cap | +| Optional: `QUOTA_STORE_DRIVER=redis` + `QUOTA_STORE_REDIS_URL` for **shared quota counters** | Treat Redis as shared SQLite — it is not | +| Duplicate provider secrets into each instance (or accept partitioned dashboards) | Expect one dashboard / one call-log across instances | +| Front with any load balancer; sticky by API key or session is enough | Require a vendor-specific size-aware middleware | + +Hardware: per-instance concurrent long `/v1/responses` is a **memory-budget** question (heap + inflight-byte / #10110). `N` independent `DATA_DIR`s still multiply heaps: host RAM must cover `N × cgroup`, not “one 16 Gi pod with N=8.” Never `replicas > 1` on one SQLite file. Compose sketch (two heaps, two volumes — not `deploy.replicas: 2`): diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 27bf863d21..da053f1db3 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -217,12 +217,12 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | | `DEFAULT_RATE_LIMIT_PER_DAY` | _(unset = unlimited)_ | `src/shared/utils/apiKeyPolicy.ts` | Fallback per-day request budget applied to API keys whose `rate_limits` column is null. Unset or empty: no implicit cap (#2289, #11017). `0` is the same (unlimited). Positive integer N enables N/day, 5N/week, 20N/month. Malformed non-empty values fall back to the legacy 1000/day, 5000/week, 20000/month windows. | | `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | -| `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold require an atomic process-local heavyweight admission lease before JSON parsing. | +| `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold take the atomic process-local heavyweight admission lease before JSON parsing (BYTE path, including `POST /v1/responses`). Same [#10437](https://github.com/diegosouzapw/OmniRoute/pull/10437) healthy-headroom escape as structure-heavy; still bounded by `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` / [#10110](https://github.com/diegosouzapw/OmniRoute/issues/10110) so [#7849](https://github.com/diegosouzapw/OmniRoute/issues/7849) does not return. | | `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap enforced against bytes read during bounded ingestion, including requests with missing, invalid, or dishonest `Content-Length`; excess receives `413`. | -| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | _(unset — no request-count cap)_ | `src/shared/middleware/chatBodyAdmission.ts` | **#503-fanout:** this legacy request-COUNT cap now binds only when explicitly set. Left unset (the default), heavyweight chat admission is instead gated by `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` — an auto-derived BYTE budget sized from the process's real memory ceiling in **one process** (one V8 heap), fixing a bug where coding-agent fan-out (multiple subagents/CLIs, bodies routinely > 256 KB) collapsed to an effective concurrency of ~1 and 503'd under normal load. Setting this var restores the old fixed-count behavior on top of the byte budget for a deployment that already tuned it. Overload is retryable `503` with `Retry-After`. Two overlapping ~750k-token `/v1/responses` already abort ~12 Gi heaps (#7849) — the byte budget accounts for that ceiling automatically, so raising this manually is no longer the recommended lever. Multiply capacity with **N independent `DATA_DIR`s** (#11024), not `replicas>1` on one SQLite file. | -| `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` | _(auto-derived)_ | `src/shared/middleware/admissionBudget.ts` | **#503-fanout:** override for the auto-derived ingest byte budget (25% of the tighter V8/cgroup memory ceiling divided by 8x transient amplification). Derived and explicit values clamp to 8 MiB–2 GiB. A body larger than the effective budget fails immediately with `413 body_exceeds_budget`; contention between individually serviceable bodies remains retryable `503`. Read `chatAdmission.maxInflightBytes` / `budgetSource` / `pressureSeverity` at `/api/monitoring/health` before tuning. | -| `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO` | `0.75` | `src/shared/middleware/chatBodyAdmission.ts` | Heap-pressure shed ratio (`heapUsed / heap_size_limit`) for the structural admission gate (#10183, #10268). A second concurrent heavyweight request past `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` is only shed with the retryable `503` when the heap is ALSO at or above this ratio; on a healthy heap it is admitted instead. | -| `OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM` | `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`) | `src/shared/middleware/chatBodyAdmission.ts` | Bounded extra capacity for the healthy-heap fast path above (#10437). Without this bound, every busy-but-healthy-heap request bypassed admission with no ceiling at all — a slow leak or a burst that never quite trips the heap-shed ratio could still pile up unlimited concurrent heavyweight work. Once this many concurrent leases are active through the healthy-heap path, further busy requests fall through to the SAME bounded-wait/shed path used under real heap pressure. `0` disables the bypass entirely. | +| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | _(unset — no request-count cap)_ | `src/shared/middleware/chatBodyAdmission.ts` | **#503-fanout:** this legacy request-COUNT cap now binds only when explicitly set. Left unset (the default), heavyweight chat admission is instead gated by `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` — an auto-derived BYTE budget sized from the process's real memory ceiling in **one process** (one V8 heap). Two overlapping ~750k-token `/v1/responses` abort ~12 Gi heaps (#7849) — a **memory-budget** warning, not a hard product max of 2. A healthy process (heap below the shed ratio) MAY admit more concurrent long `/v1/responses` via `OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM`. Tens of long SSE clients (40–50) is heap + `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` / #10110, not “max 2”. Blindly raising this to “use the host” reintroduces #7849. Multiply **heaps** with **N independent `DATA_DIR`s** (#11024); never `replicas>1` on one SQLite file. | +| `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` | _(auto-derived)_ | `src/shared/middleware/admissionBudget.ts` | **#503-fanout:** override for the auto-derived ingest byte budget (25% of the tighter V8/cgroup memory ceiling divided by 8x transient amplification). Derived and explicit values clamp to 8 MiB–2 GiB. A body larger than the effective budget fails immediately with `413 body_exceeds_budget`; contention between individually serviceable bodies remains retryable `503`. 40–50 concurrent long SSE clients is this budget + heap, not a hard “max 2”. Read `chatAdmission.maxInflightBytes` / `budgetSource` / `pressureSeverity` at `/api/monitoring/health` before tuning. | +| `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO` | `0.75` | `src/shared/middleware/chatBodyAdmission.ts` | Heap-pressure shed ratio (`heapUsed / heap_size_limit`) for BYTE and STRUCTURE heavyweight admission (#10183, #10268, #10437). A concurrent heavyweight request past `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` is only shed with the retryable `503` when the heap is ALSO at or above this ratio; on a healthy heap it is admitted via healthy-headroom instead. | +| `OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM` | `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`) | `src/shared/middleware/chatBodyAdmission.ts` | Bounded extra capacity for the healthy-heap fast path (#10437) on **both** STRUCTURE and BYTE (`admitChatRequest`, including bodies ≥ `OMNIROUTE_CHAT_LARGE_BODY_BYTES`). Without this bound, every busy-but-healthy-heap request bypassed admission with no ceiling. Once this many concurrent leases are active through the healthy-heap path, further busy requests fall through to the SAME bounded-wait/shed path used under real heap pressure. `0` disables the bypass entirely. | | `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. | @@ -636,6 +636,8 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | | `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | +| `CLAUDE_CODE_CLIENT_VERSION` | `2.1.258` | Override advertised Claude Code version independently of `CLAUDE_USER_AGENT`. Anthropic gates some models on this value (#12417). | +| `GITHUB_COPILOT_CLI_VERSION` | `1.0.81-6` | Override advertised Copilot CLI version independently of `GITHUB_USER_AGENT` | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | When GitHub Copilot Chat updates | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates | | `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates | @@ -1041,6 +1043,7 @@ desktop install. | ---------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. | | `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. | +| `CATALOG_BUILD_TIMEOUT_MS` | `8000` (8s) | `src/app/api/v1/models/catalogCache.ts` | Cold-path wait bound for a coalesced `GET /v1/models` catalog rebuild (#12627). On timeout, a last-good 200 is served when one exists. | | `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | | `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | | `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyUpscale.ts` | Base delay for the Adobe Firefly upscale submit-retry exponential backoff. | diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index de03e3712b..963bd3e421 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -1,35 +1,40 @@ --- title: "Free Tiers & Free-Token Budget" version: 3.8.50 -lastUpdated: 2026-08-31 +lastUpdated: 2026-09-03 --- # Free Tiers & Free-Token Budget > **For Users**: Looking for a simple guide? See the [Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) for step-by-step instructions on getting free AI. -> **Last researched:** 2026-06-17 — per-provider web research (official docs + last-7-days news, 50-agent pass with adversarial verification) refreshing every free-tier quota + ToS. +> **Last researched:** 2026-06-17 — per-provider web research (official docs + last-7-days news, 50-agent pass with adversarial verification) refreshing every free-tier quota + ToS. **Partial re-audit 2026-09-02** (`gemini`, `ollama-cloud`, `groq`, `nara`, `mistral` — see the dated note below). > **Source of truth (catalog):** `open-sse/config/freeModelCatalog.ts` (per-MODEL budgets, pool-deduped). The token-budget numbers below come from live web research and are an **approximation** — see [Methodology & caveats](#methodology--caveats). ## TL;DR — how much free inference does OmniRoute actually aggregate? -| Metric | Tokens / month | Meaning | -| ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Documented recurring grant (steady)** | **~1.51B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** | -| **+ first month with signup credits** | **~2.13B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. | -| **+ permanently free, no published cap** | _un-quantifiable_ | `siliconflow`, `glm-cn` (GLM-4-Flash), `tencent`, `baidu`, `kilo-gateway`, `opencode-zen` — real recurring access, rate/concurrency-limited, **no token cap to count**. Listed, never summed (counting them at `RPM×24/7` is the inflation we reject). | -| **+ deposit-unlock boost** | **+~24M** | A one-time **$10** OpenRouter top-up raises its free pool from 50 → 1000 req/day. Reported separately so it never inflates the steady number. | -| Theoretical ceiling (all rate limits, 24/7) | ~10B | Sum of every provider rate limit extrapolated to non-stop use. **Not a guarantee** — do not headline this. | +| Metric | Tokens / month | Meaning | +| ------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Documented recurring grant (steady)** | **~1.47B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** | +| **+ first month with signup credits** | **~2.10B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. | +| **+ permanently free, no published cap** | _un-quantifiable_ | `siliconflow`, `glm-cn` (GLM-4-Flash), `tencent`, `baidu`, `kilo-gateway`, `opencode-zen`, `gemini`, `ollama-cloud` — real recurring access, rate/concurrency-limited, **no token cap to count**. Listed, never summed (counting them at `RPM×24/7` is the inflation we reject). | +| **+ deposit-unlock boost** | **+~24M** | A one-time **$10** OpenRouter top-up raises its free pool from 50 → 1000 req/day. Reported separately so it never inflates the steady number. | +| **+ behind a regional identity check** | **+~6M** | `modelscope` (Alibaba Cloud binding + mainland-China real-name verification). Real recurring quota, exposed as `gatedRecurringTokens` / `gatedProviders` and on the dashboard. Never summed into the headline: +~6M behind regional identity verification. | +| Theoretical ceiling (all rate limits, 24/7) | ~10B | Sum of every provider rate limit extrapolated to non-stop use. **Not a guarantee** — do not headline this. | -**Honest headline:** _OmniRoute aggregates **~1.51B documented free tokens per month** (up to ~2.13B in your first month with signup credits) across 38 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (15–95% token savings) stretches that further._ +**Honest headline:** _OmniRoute aggregates **~1.47B documented free tokens per month** (up to ~2.10B in your first month with signup credits) across 34 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (15–95% token savings) stretches that further._ > **Why this dropped from the previous ~1.94B.** The 2026-06-17 refresh is an honesty correction, not a loss: `gemini` is now pool-deduped (was inflated by counting each Flash variant separately, 462M → 60M), `cloudflare-ai` corrected to its real 10k-Neurons/day (122M → 30M), `doubao` reclassified as a one-time signup credit (not recurring), and shut-down tiers removed (`chutes`/`phind`/`kluster` discontinued). Partly offset by `llm7` (correct 5M/day → 150M) and new free providers (Kilo, OpenCode Zen, Z.AI GLM-Flash). > > **Further corrected to ~1.37B in v3.8.42:** `longcat` was reclassified from a 150M/mo recurring grant to a one-time 10M signup credit after its free preview ended. Same honesty rule — no provider was dropped by mistake. > -> **Updated on 2026-08-26 after retiring Felo Web:** the source now reports 38 recurring pool keys. Felo Web is excluded while its GPL-derived provenance/licensing remains on HOLD. This is the live, CI-gated number (`check:docs-counts` fails the build if this drifts from `computeFreeModelTotals()`). +> **Updated on 2026-08-26 after retiring Felo Web:** Felo Web is excluded while its GPL-derived provenance/licensing remains on HOLD; the source reported 38 pool keys at the time. The pool count is live and CI-gated (`check:docs-counts` fails the build if the numbers above drift from `computeFreeModelTotals()`). +> +> **Re-audited on 2026-09-02 against the providers' own pages** (sources: the `// evidence:` comments next to each re-audited entry in `open-sse/config/freeModelCatalog.data.ts`): `gemini` and `ollama-cloud` no longer publish a token figure (Google removed the per-model free table on 2025-12-23; Ollama's Free plan is "starter usage credits") and are now listed as **uncapped**, never summed (−80M); `groq` is five **per-model** 200K-TPD caps (6M each, +15M) with three retired IDs dropped; `nara` is one 7M/day bucket (+60M, 210M). `mistral`'s 1B is visible only in the account console — see _Evidence classes_ under Methodology. The source reported 35 such keys at that point (−3: `gemini` and `ollama-cloud` moved to the uncapped list, and Groq's per-model caps are not a shared pool). +> +> **Corrected to ~1.47B on 2026-09-03 (#11773):** `cerebras` was reclassified from a 30M/mo recurring grant (old no-card 1M tokens/day trial) to a one-time $5 signup credit that requires a payment method. Same honesty rule as LongCat. The source now reports 34 recurring pool keys and ~1.47B steady. -Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `nara` 150M, `gemini` 60M, `cerebras` 30M, `cloudflare-ai` 30M, `api-airforce` 24M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.) +Biggest **documented** contributors: `mistral` 1.00B, `nara` 210M, `llm7` 150M, `groq` 30M (five per-model caps), `cloudflare-ai` 30M, `api-airforce` 24M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.) > ⚠️ The theoretical ceiling (~10B) is inflated by rate-limit-only providers with **no published token cap** (`tencent`, `siliconflow`, `nvidia`, `baidu`, `glm-cn`, `sparkdesk`) whose figures would be `RPM/TPM × 24/7 × 30d` — a theoretical maximum no single account will sustain. They are **excluded** from the defensible number (shown in the "permanently free, no cap" row instead). This is the same inflation that makes competitors' multi-billion claims unreliable. @@ -69,11 +74,25 @@ purpose. ## Methodology & caveats - Numbers are **upper-bound estimates** from each provider's documented free-tier limits as of **2026-06-17**, gathered by web research. Free tiers change constantly — re-verify before relying on a figure. -- **What an entry actually vouches for.** No entry carries a per-row confidence rating, and the API serves none — treat every figure above as an estimate of the same, unstated quality. Two facts are different, because they are curated by hand rather than inferred: 7 entries carry an independently documented hard stop, and 13 entries carry a prompt-training disclosure. `hardStopGuaranteed` is set only when the provider's own terms say that exceeding the free allowance refuses the request rather than silently starting to bill you, with the source in a comment next to the entry; it is never defaulted to `true`, and an entry nobody has verified stays unset. So a missing hard-stop flag means "not established", not "known to bill you". +- **What an entry actually vouches for.** No entry carries a per-row confidence rating, and the API serves none — treat every figure above as an estimate of the same, unstated quality. Two facts are different, because they are curated by hand rather than inferred: 5 entries carry an independently documented hard stop, and 13 entries carry a prompt-training disclosure. `hardStopGuaranteed` is set only when the provider's own terms say that exceeding the free allowance refuses the request rather than silently starting to bill you, with the source in a comment next to the entry; it is never defaulted to `true`, and an entry nobody has verified stays unset. So a missing hard-stop flag means "not established", not "known to bill you". - `estMonthlyFreeTokens` = recurring monthly tokens only. **One-time signup credits do not recur** and count as 0. Discontinued tiers are also 0. - Daily token cap → `monthly = daily × 30`. Only RPD documented → `RPD × ~800 output tokens × 30`. Only RPM/TPM (no daily cap) → **uncapped** (see below). -- **Permanently free, but no published token cap** (`siliconflow`, `glm-cn`, `tencent`, `baidu`, `kilo-gateway`, `opencode-zen`): these are real recurring free access, rate/concurrency-limited. We classify them `recurring-uncapped` and **never sum them** — multiplying `RPM × 24/7 × 30d` would produce a fantasy ceiling (the inflation we reject). They are listed so you know they exist. +- **Permanently free, but no published token cap** (`siliconflow`, `glm-cn`, `tencent`, `baidu`, `kilo-gateway`, `opencode-zen`, `gemini`, `ollama-cloud`): these are real recurring free access, rate/concurrency-limited. We classify them `recurring-uncapped` and **never sum them** — multiplying `RPM × 24/7 × 30d` would produce a fantasy ceiling (the inflation we reject). They are listed so you know they exist. - **Deposit-unlock boost:** a one-time small top-up that permanently raises a free quota (OpenRouter: $10 → 1000 req/day ≈ +24M/mo). Reported as a separate figure, kept out of the steady headline. +- **Eligibility-gated quotas** (`eligibilityGate: "regional-identity"`): a real recurring quota that only opens after a region-bound identity check (mainland-China real-name verification today). Counted with the same pool-dedupe rule into a separate figure (`gatedRecurringTokens`), never into the steady headline. The regime (`freeType`) is unchanged, so routing is unchanged. +- **Evidence classes.** The rule: a number in the catalog cites its source in an `// evidence:` comment next to the entry — `public-page` (a provider page anyone can read), `api-public` (an unauthenticated endpoint of the provider, e.g. NaraRouter's public plans endpoint at router.bynara.id), or `console-verified por ` (the figure is only visible inside an account console; the comment records who saw it and when, and the public page that says the cap exists). The state today: the five blocks re-audited on 2026-09-02 carry it (`gemini`, `groq`, `mistral`, `ollama-cloud`, `nara`); entries that predate the 2026-09-02 re-audit inherit the earlier research until they are touched; any **new or changed** number without an evidence comment is a bug. Today only `mistral` is console-verified. + +--- + +## Why our number is smaller than other aggregators' + +Most "free tokens per month" figures in this space are sums of per-model labels. Ours is not, on purpose: + +- **Each shared pool is counted once.** Mistral's free plan is one 1B/month allowance per organization; listing it under five models does not make it 5B. Summed per model, our own catalog would read **~7.4B** (recomputed on 2026-09-03; this figure is not CI-gated — re-measure it whenever the catalog changes) — the headline says **~1.47B** because that is what one account of each provider can actually spend. +- **Daily caps are converted, rates are not.** A documented tokens/day cap becomes `× 30`; a documented requests/day cap becomes `RPD × ~800 tokens × 30`; a provider that only publishes requests-per-minute has **no** monthly figure and is listed as _uncapped_, never summed. Multiplying a rate limit by 24/7 is the inflation we refuse. +- **Quotas behind a regional identity check are shown apart** (`+~6M behind regional identity verification`), because most readers cannot use them. +- **Signup credits are first-month only** and reported as a second figure, never blended into the steady number. +- **The figure is enforced by CI.** `npm run check:docs-counts` recomputes the totals from the catalog and fails the build when this file, the README or the budget card drift from them. --- @@ -183,21 +202,20 @@ purpose. --- -## Per-provider free-tier (refreshed 2026-06-17) +## Per-provider free-tier (refreshed 2026-09-02 for the re-audited rows; 2026-06-17 otherwise) > Regenerated from the per-model catalog (`open-sse/config/freeModelCatalog.ts`), pool-deduped. Sorted by recurring steady tokens/mo. `uncapped*` = permanently free but no published token cap (rate/concurrency-limited) — real access, **not** summed into the headline. `—` = credit-only / keyless / not token-quantifiable. | Provider | Free type | Steady tokens/mo | First-month credit | ToS | Models | | ---------------- | ------------- | ---------------- | ------------------ | --------- | ------ | | `mistral` | recurring | ~1.00B | — | caution | 5 | +| `nara` | recurring | ~210M | — | caution | 8 | | `llm7` | recurring | ~150M | — | caution | 4 | | `longcat` | one-time | — | 10M | caution | 1 | -| `gemini` | recurring | ~60M | — | caution | 4 | -| `cerebras` | recurring | ~30M | — | caution | 2 | +| `cerebras` | one-time | — | $5 credit | caution | 2 | | `cloudflare-ai` | recurring | ~30M | — | caution | 9 | +| `groq` | recurring | ~30M | — | caution | 5 | | `api-airforce` | recurring | ~24M | — | caution | 7 | -| `ollama-cloud` | recurring | ~20M | — | ambiguous | 8 | -| `groq` | recurring | ~15M | — | caution | 5 | | `bluesminds` | recurring | ~7M | — | ambiguous | 22 | | `sambanova` | recurring | ~6M | — | caution | 5 | | `arcee-ai` | recurring | ~5M | — | caution | 1 | @@ -210,7 +228,9 @@ purpose. | `kiro` | recurring | ~25K | — | avoid | 12 | | `glm-cn` | uncapped | uncapped\* | ~20M | ok | 4 | | `baidu` | uncapped | uncapped\* | — | caution | 1 | +| `gemini` | uncapped | uncapped\* | — | caution | 4 | | `kilo-gateway` | uncapped | uncapped\* | — | caution | 7 | +| `ollama-cloud` | uncapped | uncapped\* | — | ambiguous | 8 | | `opencode-zen` | uncapped | uncapped\* | — | caution | 6 | | `siliconflow` | uncapped | uncapped\* | — | caution | 10 | | `tencent` | uncapped | uncapped\* | — | caution | 1 | @@ -276,7 +296,7 @@ purpose. - **`bluesminds`** — Our shipped freeNote was "(none)" — but BluesMinds does have a documented free tier: 500 pi credits, 20 RPM, 300 RPD, permanent free plan. The catalog significantly understates the offering. - **`brave-search`** — The catalog notes "(none)" suggesting no free tier was tracked, but in reality there was a free 5,000 queries/month tier (no card) until February 12, 2026, which has since been replaced by a $5/month… - **`byteplus`** — Our catalog shipped "(none)" but BytePlus ModelArk does have a free tier: a one-time trial credit of 500k tokens per LLM model for new accounts. The catalog underreports this. -- **`cerebras`** — TPM appears tightened from 60K to 30K on current documented models (gpt-oss-120b, zai-glm-4.7). RPM of 5 is now explicitly documented (was not in our shipped note). Daily token cap of 1M/day is uncha… +- **`cerebras`** — The no-card 1M tokens/day trial is gone. Live cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit, payment method required, 30-day validity. Reclassified as `one-time-initial` (LongCat-shaped); dropped from `LEGACY_FREE_PROVIDERS` and the recurring budget. - **`chutes`** — The shipped freeNote says "Free tier available" but as of March 15, 2026, the free tier has been officially discontinued. The catalog note is stale and should be updated to reflect that there is no r… - **`coze`** — The shipped note "Free ByteDance agent platform" is directionally accurate but omits that the free tier is now tightly credit-capped (10 credits/day ≈ 5–100 messages depending on model), a constraint… - **`deepinfra`** — Our shipped freeNote says "Free signup credits for API testing" — this appears stale. The official pricing page now requires card/prepayment with no documented general free signup credit. The free ti… @@ -292,7 +312,7 @@ purpose. - **`gemini`** — The shipped freeNote says "1,500 req/day for Gemini 2.5 Flash" — this was accurate before December 2025. Google cut free-tier limits by 50-80% in December 2025, reducing Gemini 2.5 Flash from 1,500 R… - **`gitlawb`** — The shipped freeNote "Free tier available" is effectively stale. The original free MiMo access was removed in May 2026; the only remaining "free" option is a temporary promotional model (Nemotron 3 U… - **`gitlawb-gmi`** — Partially still accurate — free tier exists but is now narrowed to a single model (Nemotron 3 Ultra) after MiMo free access was revoked in late May 2026. The shipped note "Free tier available" unders… -- **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific … +- **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific … **Resolved 2026-09-02:** the `freeNote` now reads "Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file." and the catalog carries five per-model 6M caps (llama-3.3-70b-versatile retired from the free tier on 2026-08-16) — see the [2026-09-02 re-audit note](#tldr--how-much-free-inference-does-omniroute-actually-aggregate). - **`huggingchat`** — The shipped freeNote ("Free LLM chat — no subscription required. Rate limits apply.") is partially accurate but significantly understates the restrictions. The free tier now operates on a hard $0.10/… - **`huggingface`** — Significantly tightened. The shipped freeNote ("Free Inference API for thousands of models") implied unlimited/generous free access, but as of mid-2025 the free tier is capped at $0.10/month in recur… - **`hyperbolic`** — Our shipped freeNote says "$1-5 trial credits on signup" — the $1 trial credit portion is accurate, but the "$5" figure refers to the minimum deposit required to unlock GPU rental (not free credits g… diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index c7ba75f601..54bfc297cd 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -151,7 +151,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | | `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | | `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | -| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | +| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier. | | `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | | `chat-oripe` | `chat-oripe` | Chat Oripe | API key, aggregator | [link](https://api.oriper.com) | Official metadata advertises 2M tokens/month, but the public site and documentation were blocked during audit; treat the quota and brand mapping as unconfirmed. | | `chatanywhere` | `chatanywhere` | ChatAnywhere | API key, aggregator | [link](https://chatanywhere.tech) | Personal, educational or research use only: public documentation cites 10,000 points/day and 200 requests/day per IP/key; do not use for commercial traffic. | @@ -210,7 +210,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | | `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | | `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | -| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | +| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file. | | `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | | `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn | | `helixmind` | `helixmind` | HelixMind | API key, aggregator | [link](https://helixmind.online) | Previously circulated 3 RPM/50 RPD and no-card claims were not confirmed during the 2026-08-02 audit; current quota and billing require account verification. | @@ -260,7 +260,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `naga-ac` | `naga` | Naga.ac | API key, aggregator | [link](https://naga.ac) | Get API key at naga.ac — Google/GitHub/Discord signup available. | | `naga-ai` | `naga-ai` | Naga AI | API key, aggregator | [link](https://naga.ac) | Models marked :free are publicly listed, but no numeric quota is confirmed. Naga's policy warns that free-tier prompts and outputs may be collected or used for training. | | `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | -| `nara` | `nara` | NaraRouter | API key | [link](https://bynara.id) | Get a free API key via NaraRouter's Telegram channel, then paste it here as a Bearer token. | +| `nara` | `nara` | NaraRouter | API key | [link](https://bynara.id) | Create a free NaraRouter account, link your Telegram (required before /v1 answers), then paste the key here as a Bearer token. | | `navy` | `navy` | NavyAI | API key | [link](https://api.navy) | Create a free API key from the NavyAI dashboard, then paste it here as a Bearer token. | | `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | | `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | @@ -444,7 +444,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each - Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts) - Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts) -- Executors: [`open-sse/executors/`](../../open-sse/executors/) (108 implementations) +- Executors: [`open-sse/executors/`](../../open-sse/executors/) (109 implementations) - Translators: [`open-sse/translator/`](../../open-sse/translator/) ## See Also diff --git a/docs/screenshots/free-tier-budget-card.svg b/docs/screenshots/free-tier-budget-card.svg index c56452439a..15e11774a9 100644 --- a/docs/screenshots/free-tier-budget-card.svg +++ b/docs/screenshots/free-tier-budget-card.svg @@ -1,94 +1,104 @@ - - - -OmniRoute · /dashboard/free-tiers · preview mockup + + + +OmniRoute · /dashboard/free-tiers · preview mockup Monthly free-token budget -20 free pools · 446 models · one endpoint +21 free pools · 444 models · one endpoint Steady / month -~1.51B +~1.47B First month (+ signup credits) -~2.13B +~2.10B ToS-flagged (you decide) 13 providers - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid. Mistral Large 3 1.00B -GPT-4o mini 150M +Agnes 2.0 Flash 210M -Tencent Hy3 150M +GPT-4o mini 150M -Gemini 2.5 Flash 60M +Llama 3.3 70B 30M -Llama 3.3 70B 30M +Grok-3 24M -Grok-3 24M +GPT-4o 7M -DeepSeek V4 Pro 20M +GPT-OSS 120B 6M -GPT-4o 7M +GPT-OSS 20B 6M -MiniMax-M2.7 6M +GPT-OSS Safeguard 20B 6M -Arcee Trinity Large Prev 5M +Qwen3.6 27B 6M -NavyAI free pool 5M +Qwen3.8 27B 6M -Auto Free 4M +MiniMax-M2.7 6M -Auto 1M +Arcee Trinity Large Prev 5M -Command A Reasoning 800K +NavyAI free pool 5M -ERNIE 4.5 VL 424B A47B B 500K +Auto Free 4M -morph-v3-large 400K +Auto 1M -Llama 3.1 8B 200K +Command A Reasoning 800K -Claude Sonnet 4.5 25K - -+ First month: one-time signup credits (~626M) - -vertex 300M - -agentrouter 200M - -predibase 25M - -together 25M - -glm-cn 20M - -doubao 15M - -ai21 10M - -longcat 10M +ERNIE 4.5 VL 424B A47B B 500K + +morph-v3-large 400K + +Llama 3.1 8B 200K + +Claude Sonnet 4.5 25K + ++ First month: one-time signup credits (~626M) -deepseek 5M - -Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide. -+ 12 permanently-free, no-cap providers (e.g. baidu, glm-cn, opencode-zen) · OpenRouter $10 → +24M/mo. +vertex 300M + +agentrouter 200M + +predibase 25M + +together 25M + +glm-cn 20M + +doubao 15M + +ai21 10M + +longcat 10M + +deepseek 5M + +Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide. ++ 15 permanently-free, no-cap providers (e.g. agnes, ainative, aion) · OpenRouter $10 → +24M/mo. ++ ~6M behind regional identity verification (modelscope) — real quota, never in the headline. diff --git a/docs/security/ERROR_SANITIZATION.md b/docs/security/ERROR_SANITIZATION.md index 898ca209d9..e3ffe04c77 100644 --- a/docs/security/ERROR_SANITIZATION.md +++ b/docs/security/ERROR_SANITIZATION.md @@ -1,14 +1,16 @@ --- title: "Error Message Sanitization" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.51 +lastUpdated: 2026-09-02 --- # Error Message Sanitization -> **Source of truth:** `open-sse/utils/error.ts` — `sanitizeErrorMessage`, `buildErrorBody`, `createErrorResult` -> **Tests:** `tests/unit/error-message-sanitization.test.ts` -> **Last updated:** 2026-06-28 — v3.8.40 +> **Source of truth:** `open-sse/utils/errorSanitization.ts`, +> `open-sse/utils/errorPathRedaction.ts`, and the public builders in `open-sse/utils/error.ts` +> **Tests:** `tests/unit/error-message-sanitization.test.ts`, +> `tests/unit/error-public-boundaries-hardening.test.ts` +> **Last updated:** 2026-09-02 — v3.8.51 > **Audience:** Any engineer touching error responses (HTTP routes, SSE streams, executors, MCP handlers). > **Status:** **MANDATORY** for every code path that returns an error message to a client. @@ -20,10 +22,18 @@ CodeQL rule `js/stack-trace-exposure` (CWE-209) flags any code path where an err - Library / framework versions inferred from stack frames → targeted exploit selection. - Sensitive runtime values that may be string-interpolated into errors (DB queries, config values). -The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both classes of leakage: +The `sanitizeErrorMessage` helper exported by `open-sse/utils/error.ts` strips these classes of +leakage: -1. Multi-line stack traces — only the first line (the actual error message) is kept. -2. Absolute paths (`/...*.{ts,js,tsx,jsx,mjs,cjs}[:line[:col]]` and `C:\...`) — replaced with ``. +1. Physical, serialized, and unambiguously inline JavaScript stack-frame tails. +2. Absolute POSIX, Windows, UNC, and `file://` filesystem paths, while preserving safe HTTPS URLs + and explicitly marked API routes. +3. Credential assignments, common provider token formats, private-key PEM blocks, and base64 data + URLs. + +The sanitizer caps input length and fails closed when a thrown value rejects string coercion. +Recursive upstream JSON sanitization also drops unsafe credential/path keys, session aliases, and +prototype-control keys before a response is serialized. ## The mandatory pattern @@ -59,7 +69,10 @@ import { } from "@omniroute/open-sse/utils/error.ts"; ``` -All of these route through `buildErrorBody` and therefore through `sanitizeErrorMessage`. **You never need to call `sanitizeErrorMessage` manually** when using these helpers. +All of these apply the canonical public-error boundary. `errorResponse`, `writeStreamError`, and +`createErrorResult` route through `buildErrorBody`; the three specialized retry/circuit helpers +project and sanitize their public context directly. **You never need to call +`sanitizeErrorMessage` manually** when using these helpers. ### 2. Custom error envelopes (rare) @@ -81,17 +94,25 @@ This is the only sanctioned way to assemble a custom error body. See `open-sse/e ### 3. Logging vs. responding -`sanitizeErrorMessage` should **only** wrap the value that crosses the network boundary. Internal logs (`pino`, `console`) should keep the full message, including stack, so operators can debug. Pattern: +Trusted internal exceptions may keep their full message and stack so operators can debug. Values +originating at provider, validation, browser-session, or credential-adjacent boundaries must be +sanitized before they enter console output, audit metadata, or persistent call logs. Pattern: ```ts try { // ... } catch (err) { - log.error({ err }, "handler failed"); // full err with stack — internal log + log.error({ err }, "handler failed"); // trusted internal exception only return errorResponse(500, getErrorMessage(err)); // sanitized — sent to client } ``` +For provider-controlled failures, project the logged value too: + +```ts +log.error({ message: sanitizeErrorMessage(err) || "Provider request failed" }); +``` + ### 4. Forbidden patterns ❌ **Never** put raw exception output in a Response body: @@ -112,7 +133,9 @@ const safe = String(err).split("\n")[0]; ❌ **Never** sanitize in the route and forget the SSE path. Anything that writes to a stream goes through `writeStreamError` (or its underlying `buildErrorBody`). -❌ **Never** include `process.cwd()`, `__filename`, `__dirname`, env-derived paths in error messages — they bypass the path regex and reveal the deployment topology. +❌ **Never** intentionally include `process.cwd()`, `__filename`, `__dirname`, or env-derived paths +in error messages. The sanitizer covers absolute paths as defense in depth, but callers must not +construct topology-bearing messages in the first place. ## Coverage in CI @@ -129,7 +152,9 @@ When adding a new route or executor, copy the assertion pattern from this file. ## Related controls - `js/stack-trace-exposure` CodeQL alerts in `.github/security` should always be **either** fixed via these helpers **or** dismissed with a comment citing this doc. -- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles structured log redaction separately. This doc covers only the response-message surface. +- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles trusted structured logs + separately. This document covers public response messages and provider-controlled values that + cross persistent call/proxy-log boundaries. - Upstream-header denylist (`src/shared/constants/upstreamHeaders.ts`) covers header leakage — keep both files aligned when adding a new exfiltration concern. ## Upstream details passthrough @@ -138,27 +163,39 @@ When adding a new route or executor, copy the assertion pattern from this file. parsed body from the upstream provider). When provided, it is sanitized by `sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`. -An optional fourth argument `classification` (`{ type?: string; code?: string }`) -preserves an explicit error type/code instead of re-deriving both from the -status-code table — used when the caller already classified the failure (e.g. -HTTP 499 → `client_disconnected`). +An optional fourth argument `classification` +(`{ type?: string; code?: string; reason?: string }`) accepts an explicit public classification. +Every field is projected onto the bounded public-identifier vocabulary. Unsafe, credential-shaped, +control-character, or overlong values fall back to the status-derived type/code; an unsafe optional +reason is omitted. Three-digit HTTP status identifiers (`100` through `599`) remain valid for +provider contracts that expose the numeric upstream status as a machine-readable code. The same +bounded range is accepted in the locally generated HTTP-status placeholder form; arbitrary provider +numbers and names remain outside the vocabulary. + +Pass every explicit classification in that fourth argument. Never overwrite +`body.error.code`, `body.error.type`, or `body.error.reason` after `buildErrorBody()` returns; +post-builder mutation bypasses the public projection. Sanitization rules applied to `upstreamDetails`: 1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths). -2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i` - are removed. +2. Unsafe path, credential, session-alias, and prototype-control keys are removed. 3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`. 4. Arrays are capped at 32 elements. -Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pass -`upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content, -guardrail blocks) do not include `upstream_details`. +Only call sites with a parsed provider error body should pass `upstreamDetails`. Internal OmniRoute +errors (SSE parse failures, empty content, guardrail blocks) must not include it. Do NOT pass raw `err.stack`, `err.message`, or any string from a runtime exception to `upstreamDetails`. Those must still go through `errorResponse` / `buildErrorBody(code, msg)` without an upstream body. +Selective upstream 4xx passthrough preserves the provider's safe JSON shape and wording required by +client auto-recovery, but it is not byte-for-byte passthrough: the recursive sanitizer always runs +before serialization. Cyclic, BigInt-bearing, or hostile `toJSON()` bodies fail closed and are not +eligible for passthrough. OCR and moderation apply the same rule; non-JSON, blank, or mislabeled +upstream bodies are converted to the canonical OmniRoute JSON error envelope. + ## Known CodeQL limitation: custom sanitizers not recognized The CodeQL query [`js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/) uses a fixed allowlist of sanitizer patterns (e.g. inline `.split("\n")[0]`, `String#replace` with specific regex shapes, access to `.message` on `Error`). It does **not** recognize indirection through a custom helper like our `sanitizeErrorMessage()`. diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index a030cd1c4a..6625c4dd07 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -4,6 +4,8 @@ import { CLAUDE_CODE_CLIENT_VERSION, CLAUDE_CODE_RUNTIME_VERSION, CLAUDE_CODE_SDK_PACKAGE_VERSION, + getClaudeCodeClientBillingVersion, + getClaudeCodeClientVersion, getClaudeCodeUserAgent, } from "@/shared/constants/claudeCodeClient"; import { modelSupportsContext1mBeta } from "../config/context1m.ts"; @@ -166,8 +168,17 @@ export function normalizeAnthropicHeaderVariants(headers: Record } export const CLAUDE_CLI_VERSION = CLAUDE_CODE_CLIENT_VERSION; +export function getClaudeCliVersion(): string { + return getClaudeCodeClientVersion(); +} export const CLAUDE_CLI_BUILD_REVISION = CLAUDE_CODE_CLIENT_BUILD_REVISION; +/** Captured-pin snapshot. Wire billing uses getClaudeCliBillingVersion(). */ export const CLAUDE_CLI_BILLING_VERSION = CLAUDE_CODE_CLIENT_BILLING_VERSION; +export function getClaudeCliBillingVersion(): string { + return getClaudeCodeClientBillingVersion(); +} +/** Module-load snapshot of the pin (or env if set before import). Wire UA uses getClaudeCodeUserAgent(). */ export const CLAUDE_CLI_USER_AGENT = getClaudeCodeUserAgent("cli"); +export { getClaudeCodeUserAgent }; export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION; diff --git a/open-sse/config/claudeCodeCompatibleIdentity.ts b/open-sse/config/claudeCodeCompatibleIdentity.ts index b614eea324..29f996278c 100644 --- a/open-sse/config/claudeCodeCompatibleIdentity.ts +++ b/open-sse/config/claudeCodeCompatibleIdentity.ts @@ -2,11 +2,17 @@ import { CLAUDE_CODE_CLIENT_VERSION, CLAUDE_CODE_RUNTIME_VERSION, CLAUDE_CODE_SDK_PACKAGE_VERSION, + getClaudeCodeClientVersion, getClaudeCodeUserAgent, } from "@/shared/constants/claudeCodeClient"; export const CLAUDE_CODE_COMPATIBLE_VERSION = CLAUDE_CODE_CLIENT_VERSION; +export function getClaudeCodeCompatibleVersion(): string { + return getClaudeCodeClientVersion(); +} +/** Module-load snapshot. Wire UA uses getClaudeCodeUserAgent("sdk-cli"). */ export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = getClaudeCodeUserAgent("sdk-cli"); +export { getClaudeCodeUserAgent }; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION; const CONTEXT_1M_NATIVE_MODELS = ["claude-fable-5-1", "claude-opus-5"]; diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index efa902e137..8891f73d9d 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -12,7 +12,7 @@ import { isClaudeCodeCompatible } from "../services/provider.ts"; import { getAntigravityUserAgent, - GITHUB_COPILOT_CHAT_USER_AGENT, + getGitHubCopilotChatUserAgent, } from "./providerHeaderProfiles.ts"; import { normalizeCliCompatProviderId } from "@/shared/utils/cliCompat"; @@ -169,7 +169,7 @@ export const CLI_FINGERPRINTS: Record = { "intent_threshold", "intent_content", ], - userAgent: GITHUB_COPILOT_CHAT_USER_AGENT, + userAgent: getGitHubCopilotChatUserAgent, }, antigravity: { headerOrder: [ diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index f107d7fef4..c6b8b133a3 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -1,12 +1,18 @@ -// AUTO-GENERATED — refreshed by the 2026-06-17 per-provider free-tier research pass. -// 2026-07-20: added the free tiers of providers we could already route but had -// never mapped (requesty, ovhcloud, agnes, glm), plus two new providers (navy, -// aihorde), and reconciled kilo-gateway against its live /models list. -// Source: _tasks/features-v3.8.28/free-tier-research-2026-06-17.raw.json (50-agent web research + adversarial verification). +// HAND-CURATED free-tier catalog — there is no generator; edit the entries below directly. +// Provenance: seeded by the 2026-06-17 per-provider free-tier research pass (50-agent web research + +// adversarial verification); 2026-07-20 added the free tiers of providers we could already route but had +// never mapped (requesty, ovhcloud, agnes, glm), plus two new providers (navy, aihorde), and reconciled +// kilo-gateway against its live /models list; 2026-09-02 re-audited gemini, ollama-cloud, groq, nara and +// mistral against the providers' own pages. +// Evidence: every numeric block MUST carry an `// evidence:` comment naming its source class — +// public-page (a provider page anyone can read), api-public (an unauthenticated provider endpoint), or +// console-verified por (visible only inside an account console). No evidence ⇒ no number +// (the entry stays recurring-uncapped, monthlyTokens 0). Blocks that predate 2026-09-02 and still lack +// the comment inherit the 2026-06-17 research pass; add the comment whenever such a block is touched. // Methodology: honest pool-deduped recurring tokens. "recurring-uncapped" = permanently free but no // published token cap (rate/concurrency-limited) — NOT summed into the steady headline (see freeModelCatalog.ts). // Deposit-unlock boosts (e.g. OpenRouter $10 -> 1000 RPD) live in FREE_TIER_BOOSTS, not per-record. -// Do not edit by hand — re-run the patch generator to refresh. +// Bump FREE_CATALOG_CURATED_AT on every change to the entries below. import type { FreeModelBudget } from "./freeModelCatalog.ts"; /** @@ -16,7 +22,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts"; * rewrites file timestamps on every deploy, which would report a months-old * catalog as "updated today". Bump this whenever the entries below change. */ -export const FREE_CATALOG_CURATED_AT = "2026-08-30"; +export const FREE_CATALOG_CURATED_AT = "2026-09-03"; export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, @@ -106,9 +112,12 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "bytez", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" }, { provider: "bytez", modelId: "mistralai/Mistral-7B-Instruct-v0.3", displayName: "mistralai/Mistral-7B-Instruct-v0.3", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" }, { provider: "bytez", modelId: "Qwen/Qwen2.5-72B-Instruct", displayName: "Qwen/Qwen2.5-72B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" }, - // hardStopGuaranteed: Cerebras pricing page states "Free Trial: 1M tokens/day... no credit card" (open-sse/services/../providers/apikey/inference-hosts.ts:74-84). - { provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true }, - { provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true }, + // #11773: cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit + // gated on a payment method, 30-day expiry — not the old no-card 1M/day + // trial. creditTokens stays 0 because Cerebras publishes dollars, not a + // token grant. hardStopGuaranteed must stay unset: a stored card can bill. + { provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "cerebras", tos: "caution" }, + { provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "cerebras", tos: "caution" }, // #8717: drop dead Workers AI ids (400/403/410). Keep Neurons/day budget on fp8-fast. { provider: "cloudflare-ai", modelId: "@cf/mistral/mistral-7b-instruct-v0.2-lora", displayName: "Mistral 7B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, { provider: "cloudflare-ai", modelId: "@cf/qwen/qwen2.5-coder-32b-instruct", displayName: "Qwen 2.5 Coder 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, @@ -173,20 +182,31 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "freemodel-dev", modelId: "gpt-5.3-codex", displayName: "GPT-5.3 Codex", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "freemodel-dev", tos: "unknown" }, { provider: "friendliai", modelId: "meta-llama-3.1-70b-instruct", displayName: "meta-llama-3.1-70b-instruct", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "friendliai", tos: "avoid" }, { provider: "friendliai", modelId: "meta-llama-3.1-8b-instruct", displayName: "meta-llama-3.1-8b-instruct", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "friendliai", tos: "avoid" }, - { provider: "gemini", modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", monthlyTokens: 60000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" }, - { provider: "gemini", modelId: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" }, - { provider: "gemini", modelId: "gemini-3-flash-preview", displayName: "Gemini 3 Flash Preview", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" }, - { provider: "gemini", modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" }, + // evidence: public-page https://ai.google.dev/gemini-api/docs/rate-limits (2026-08-18) — the per-model + // free-tier table was removed on 2025-12-23; the page now only says limits "can be viewed in Google AI + // Studio" and are "applied per project". No published token/RPD figure ⇒ recurring-uncapped (listed, + // never summed). Re-verify if Google republishes a table. + { provider: "gemini", modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "gemini-free", tos: "caution" }, + { provider: "gemini", modelId: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "gemini-free", tos: "caution" }, + { provider: "gemini", modelId: "gemini-3-flash-preview", displayName: "Gemini 3 Flash Preview", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "gemini-free", tos: "caution" }, + { provider: "gemini", modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "gemini-free", tos: "caution" }, { provider: "glm-cn", modelId: "glm-4-flash", displayName: "GLM-4-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, { provider: "glm-cn", modelId: "glm-4.5-flash", displayName: "GLM-4.5-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, { provider: "glm-cn", modelId: "glm-4.7-flash", displayName: "GLM-4.7-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, { provider: "glm-cn", modelId: "glm-signup-bonus", displayName: "Z.AI — 20M signup bonus", monthlyTokens: 0, creditTokens: 20000000, freeType: "one-time-initial", poolKey: "zhipu-signup", tos: "ok" }, - // hardStopGuaranteed: Groq pricing page states "Free tier: 30 RPM / 14.4K RPD — no credit card" (open-sse/services/../providers/apikey/frontier-labs.ts:71-81). - { provider: "groq", modelId: "meta-llama/llama-4-scout-17b-16e-instruct", displayName: "Llama 4 Scout", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true }, - { provider: "groq", modelId: "llama-3.3-70b-versatile", displayName: "Llama 3.3 70B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true }, - { provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true }, - { provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true }, - { provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true }, + // evidence: public-page https://console.groq.com/docs/rate-limits (2026-09-02) — "Free Plan Limits": + // 200K TPD per model for the five chat models below; "Rate limits apply at the organization level". + // 200K × 30 = 6M per model; the cap is per model, so each row counts on its own (poolKey null). + // hardStopGuaranteed: same page — "When you exceed rate limits, our API returns a 429 Too Many Requests"; + // https://console.groq.com/docs/billing-faqs — the Free tier has no payment method on file ("To upgrade + // from the Free tier to the Developer tier, you'll need to provide a valid payment method"). + // Retired from the free tier (https://console.groq.com/docs/deprecations): llama-4-scout and qwen3-32b + // (2026-07-17), llama-3.3-70b-versatile (2026-08-16) — deliberately absent below. + { provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true }, + { provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true }, + { provider: "groq", modelId: "openai/gpt-oss-safeguard-20b", displayName: "GPT-OSS Safeguard 20B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true }, + { provider: "groq", modelId: "qwen/qwen3.6-27b", displayName: "Qwen3.6 27B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true }, + { provider: "groq", modelId: "qwen/qwen3.8-27b", displayName: "Qwen3.8 27B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true }, { provider: "huggingchat", modelId: "baidu/ERNIE-4.5-VL-424B-A47B-Base-PT", displayName: "ERNIE 4.5 VL 424B A47B Base PT", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" }, { provider: "huggingchat", modelId: "CohereLabs/c4ai-command-r7b-12-2024", displayName: "Command R7B 12-2024", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" }, { provider: "huggingchat", modelId: "CohereLabs/command-a-reasoning-08-2025", displayName: "Command A Reasoning 08-2025", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" }, @@ -255,11 +275,26 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "llm7", modelId: "deepseek-r1-0528", displayName: "DeepSeek R1 (LLM7)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "llm7-free", tos: "caution" }, { provider: "llm7", modelId: "qwen2.5-coder-32b-instruct", displayName: "Qwen2.5 Coder 32B (LLM7)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "llm7-free", tos: "caution" }, { provider: "longcat", modelId: "LongCat-2.0", displayName: "LongCat-2.0", monthlyTokens: 0, creditTokens: 10000000, freeType: "one-time-initial", poolKey: "longcat-free", tos: "caution" }, + // evidence: console-verified 2026-09-02 por diegosouzapw (https://console.mistral.ai → Limits, Free mode, + // "Tokens per month" = 1,000,000,000). Public pages only confirm that the cap exists: + // https://docs.mistral.ai/admin/billing-usage/usage-limits — "Free mode lets you create API keys and use + // included monthly usage within the limits shown on the Limits page"; + // https://help.mistral.ai/en/articles/698531 — "Tokens per month: overall consumption cap", "set at the + // organization level". Re-verify in the console whenever this block is touched; without a dated + // console-verified line above, this pool MUST become recurring-uncapped (0). { provider: "mistral", modelId: "mistral-large-latest", displayName: "Mistral Large 3", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, { provider: "mistral", modelId: "mistral-medium-3-5", displayName: "Mistral Medium 3.5", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, { provider: "mistral", modelId: "mistral-small-latest", displayName: "Mistral Small 4", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, { provider: "mistral", modelId: "devstral-latest", displayName: "Devstral 2", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, { provider: "mistral", modelId: "codestral-latest", displayName: "Codestral", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, + // evidence: public-page https://modelscope.cn/docs/model-service/API-Inference/limits and + // https://modelscope.cn/docs/magicube/intro (2026-09-02) — API-Inference is free; calls are paid with + // 魔粒: "注册并登录 200 魔粒/日" + "绑定阿里云账号 50 魔粒/日", 1 魔粒 per call on "主流" models ⇒ ~250 calls/day + // ⇒ 250 × 800 × 30 = 6M/month, one balance per account (single pool). + // eligibilityGate: "账号注册后需绑定阿里云账号,并且通过实名认证后才可使用" (Alibaba Cloud binding + mainland + // real-name verification). The docs also call the product "非商业化,非盈利" — hence tos: caution. + { provider: "modelscope", modelId: "Qwen/Qwen3.5-397B-A17B", displayName: "Qwen3.5 397B A17B (ModelScope)", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "modelscope-free", tos: "caution", eligibilityGate: "regional-identity" }, + { provider: "modelscope", modelId: "deepseek-ai/DeepSeek-V4-Pro", displayName: "DeepSeek V4 Pro (ModelScope)", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "modelscope-free", tos: "caution", eligibilityGate: "regional-identity" }, { provider: "monsterapi", modelId: "llama-3-8b-fuse", displayName: "Llama 3 8B Fuse", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "monsterapi", tos: "ambiguous" }, { provider: "morph", modelId: "morph-v3-large", displayName: "morph-v3-large", monthlyTokens: 400000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "morph", tos: "ok" }, { provider: "morph", modelId: "morph-v3-fast", displayName: "morph-v3-fast", monthlyTokens: 400000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "morph", tos: "ok" }, @@ -280,14 +315,17 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "nvidia", modelId: "google/gemma-4-31b-it", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "nvidia/nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super 120B A12B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "ollama-cloud", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, - { provider: "ollama-cloud", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, - { provider: "ollama-cloud", modelId: "kimi-k2.6", displayName: "Kimi K2.6", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, - { provider: "ollama-cloud", modelId: "glm-5.1", displayName: "GLM 5.1", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, - { provider: "ollama-cloud", modelId: "minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, - { provider: "ollama-cloud", modelId: "gemma4:31b", displayName: "Gemma 4 31B", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, - { provider: "ollama-cloud", modelId: "nemotron-3-super", displayName: "NVIDIA Nemotron 3 Super", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, - { provider: "ollama-cloud", modelId: "qwen3.5:397b", displayName: "Qwen 3.5 397B", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" }, + // evidence: public-page https://ollama.com/pricing (2026-09-02) — Free plan: "Starter usage credits + // included · Includes access to starter models · Add credits to unlock all models"; docs.ollama.com/cloud: + // "usage resets monthly". No token figure and no named starter-model list ⇒ recurring-uncapped. + { provider: "ollama-cloud", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" }, + { provider: "ollama-cloud", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" }, + { provider: "ollama-cloud", modelId: "kimi-k2.6", displayName: "Kimi K2.6", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" }, + { provider: "ollama-cloud", modelId: "glm-5.1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" }, + { provider: "ollama-cloud", modelId: "minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" }, + { provider: "ollama-cloud", modelId: "gemma4:31b", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" }, + { provider: "ollama-cloud", modelId: "nemotron-3-super", displayName: "NVIDIA Nemotron 3 Super", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" }, + { provider: "ollama-cloud", modelId: "qwen3.5:397b", displayName: "Qwen 3.5 397B", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" }, { provider: "opencode", modelId: "big-pickle", displayName: "Big Pickle", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "opencode", tos: "avoid" }, { provider: "opencode", modelId: "deepseek-v4-flash-free", displayName: "DeepSeek V4 Flash Free", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "opencode", tos: "avoid" }, { provider: "opencode", modelId: "minimax-m2.5-free", displayName: "MiniMax M2.5 Free", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "opencode", tos: "avoid" }, @@ -456,7 +494,16 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "routeway", modelId: "laguna-m.1:free", displayName: "Laguna M.1 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "routeway-free", tos: "caution" }, { provider: "routeway", modelId: "laguna-xs.2:free", displayName: "Laguna XS.2 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "routeway-free", tos: "caution" }, { provider: "routeway", modelId: "llama-3.2-3b-instruct:free", displayName: "Llama 3.2 3B Instruct (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "routeway-free", tos: "caution" }, - { provider: "nara", modelId: "tencent-hy3", displayName: "Tencent Hy3", monthlyTokens: 150000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, - { provider: "nara", modelId: "mistral-large", displayName: "Mistral Large", monthlyTokens: 150000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, - { provider: "nara", modelId: "mistral-medium-3-5", displayName: "Mistral Medium 3.5", monthlyTokens: 150000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, + // evidence: api-public https://router.bynara.id/api/plans (2026-09-02) — plan "free": token_cap_daily=7000000, + // rpm_limit=15, models=[agnes-2.0-flash, agnes-2.5-flash, laguna-s-2.1, minimax-m3-free, mistral-large, + // mistral-medium-3-5, qwen3.8-27b, stepfun-3.7-flash]; home: "Token Cap 7M / day · Free tokens reset daily + // at 07:00 WIB". One daily bucket per account ⇒ single pool: 7M × 30 = 210M. Key requires linking Telegram. + { provider: "nara", modelId: "agnes-2.0-flash", displayName: "Agnes 2.0 Flash", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, + { provider: "nara", modelId: "agnes-2.5-flash", displayName: "Agnes 2.5 Flash", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, + { provider: "nara", modelId: "laguna-s-2.1", displayName: "Laguna S 2.1", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, + { provider: "nara", modelId: "minimax-m3-free", displayName: "MiniMax M3 Free", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, + { provider: "nara", modelId: "mistral-large", displayName: "Mistral Large", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, + { provider: "nara", modelId: "mistral-medium-3-5", displayName: "Mistral Medium 3.5", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, + { provider: "nara", modelId: "qwen3.8-27b", displayName: "Qwen3.8 27B", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, + { provider: "nara", modelId: "stepfun-3.7-flash", displayName: "StepFun 3.7 Flash", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" }, ]; diff --git a/open-sse/config/freeModelCatalog.ts b/open-sse/config/freeModelCatalog.ts index 041e393430..aa32a36464 100644 --- a/open-sse/config/freeModelCatalog.ts +++ b/open-sse/config/freeModelCatalog.ts @@ -11,6 +11,13 @@ export type FreeModelFreeType = | "keyless" | "discontinued"; +/** + * A real, recurring quota that only opens after an identity check tied to a + * region (e.g. 实名认证 with a mainland-China ID). One member today; extend the + * union when a second kind of gate is catalogued. + */ +export type FreeEligibilityGate = "regional-identity"; + export interface FreeModelBudget { provider: string; modelId: string; @@ -40,14 +47,28 @@ export interface FreeModelBudget { * `open-sse/services/autoCombo/strictZeroCostFilter.ts`. */ hardStopGuaranteed?: boolean; + /** + * Set when the quota is real and recurring but only reachable after a + * region-bound identity verification. Affects COUNTING only: the row + * leaves the steady headline and lands in `gatedRecurringTokens`. + * Routing, `isFreeModel` and STRICT_ZERO_COST read `freeType` alone. + * Put the gate's source in a comment next to the entry. + */ + eligibilityGate?: FreeEligibilityGate; } export interface FreeModelTotals { /** Pool-deduped recurring tokens/month — the headline "steady" number. */ steadyRecurringTokens: number; - /** Steady + recurring credit grants (e.g. monthly $-credit plans). */ + /** + * Steady + recurring credit grants (e.g. monthly $-credit plans). + * Eligibility-gated rows contribute nothing, exactly like the steady headline. + */ steadyWithRecurringCreditsTokens: number; - /** Steady + recurring + one-time signup credits — first-month only. */ + /** + * Steady + recurring + one-time signup credits — first-month only. + * Eligibility-gated rows contribute nothing, exactly like the steady headline. + */ firstMonthRealisticTokens: number; /** * Extra recurring tokens/month unlocked by a one-time small deposit @@ -59,8 +80,16 @@ export interface FreeModelTotals { * Providers that are permanently free but publish NO token cap * (rate/concurrency-limited). Real access, but un-quantifiable — listed, * never summed into the headline (avoids the rate-limit×24/7 inflation). + * Eligibility-gated rows are excluded: the list reads as "open to anyone". */ uncappedProviders: string[]; + /** + * Pool-deduped tokens/month behind an eligibility gate (same rule as the + * headline). Never summed into `steadyRecurringTokens`. + */ + gatedRecurringTokens: number; + /** Providers (sorted) contributing to `gatedRecurringTokens`. */ + gatedProviders: string[]; modelCount: number; poolCount: number; perModel: FreeModelBudget[]; @@ -224,41 +253,60 @@ export function computeFreeModelTotals( (m) => !(opts.excludeTosAvoid && m.tos === "avoid") && m.enabled !== false ); + const isGated = (m: FreeModelBudget) => m.eligibilityGate !== undefined; + const steadyRecurringTokens = dedupedSum( models, (m) => m.monthlyTokens, - (m) => STEADY_MONTHLY.has(m.freeType) + (m) => STEADY_MONTHLY.has(m.freeType) && !isGated(m) ); + const gatedRecurringTokens = dedupedSum( + models, + (m) => m.monthlyTokens, + (m) => STEADY_MONTHLY.has(m.freeType) && isGated(m) + ); + const gatedProviders = [ + ...new Set( + models.filter((m) => STEADY_MONTHLY.has(m.freeType) && isGated(m)).map((m) => m.provider) + ), + ].sort(); const recurringCredits = dedupedSum( models, (m) => m.creditTokens, - (m) => RECURRING_CREDIT.has(m.freeType) + (m) => RECURRING_CREDIT.has(m.freeType) && !isGated(m) ); const oneTimeCredits = dedupedSum( models, (m) => m.creditTokens, - (m) => ONE_TIME_CREDIT.has(m.freeType) + (m) => ONE_TIME_CREDIT.has(m.freeType) && !isGated(m) ); const steadyWithRecurringCreditsTokens = steadyRecurringTokens + recurringCredits; const firstMonthRealisticTokens = steadyWithRecurringCreditsTokens + oneTimeCredits; const poolCount = new Set( - models.filter((m) => STEADY_MONTHLY.has(m.freeType) && m.poolKey).map((m) => m.poolKey) + models + .filter((m) => STEADY_MONTHLY.has(m.freeType) && m.poolKey && !isGated(m)) + .map((m) => m.poolKey) ).size; // Deposit-unlock boost: sum the FREE_TIER_BOOSTS whose pool still has a live // recurring model in the (optionally ToS-filtered) set. const livePools = new Set( - models.filter((m) => STEADY_MONTHLY.has(m.freeType) && m.poolKey).map((m) => m.poolKey) + models + .filter((m) => STEADY_MONTHLY.has(m.freeType) && m.poolKey && !isGated(m)) + .map((m) => m.poolKey) ); const boostMonthlyTokens = Object.entries(FREE_TIER_BOOSTS) .filter(([pool]) => livePools.has(pool)) .reduce((s, [, b]) => s + b.boostMonthlyTokens, 0); // Permanently-free-but-uncapped providers (real access, no published cap). + // Gated rows are excluded: the list is read as "anyone can use this, forever". const uncappedProviders = [ - ...new Set(models.filter((m) => UNCAPPED.has(m.freeType)).map((m) => m.provider)), + ...new Set( + models.filter((m) => UNCAPPED.has(m.freeType) && !isGated(m)).map((m) => m.provider) + ), ].sort(); return { @@ -267,6 +315,8 @@ export function computeFreeModelTotals( firstMonthRealisticTokens, boostMonthlyTokens, uncappedProviders, + gatedRecurringTokens, + gatedProviders, modelCount: models.length, poolCount, perModel: models.slice().sort((a, b) => b.monthlyTokens - a.monthlyTokens), diff --git a/open-sse/config/freeTierCatalog.ts b/open-sse/config/freeTierCatalog.ts index 339f01a100..320ad62cb5 100644 --- a/open-sse/config/freeTierCatalog.ts +++ b/open-sse/config/freeTierCatalog.ts @@ -6,20 +6,20 @@ * (explicit daily/monthly token cap, or documented RPD × ~800 tokens × 30). * * Deliberately EXCLUDED (rate-limit-only, no published token cap — theoretical, - * not granted): tencent, siliconflow, nvidia, baidu, publicai, sparkdesk. + * not granted): tencent, siliconflow, nvidia, baidu, publicai, sparkdesk, + * gemini (no per-model limits published since 2025-12), ollama-cloud (starter + * credits, no figure). * One-time signup credits and discontinued tiers are excluded (do not recur). */ export type TosVerdict = "ok" | "caution" | "ambiguous" | "avoid" | "unknown"; export const FREE_TIER_BUDGETS: Record = { mistral: 1_000_000_000, + nara: 210_000_000, "cloudflare-ai": 122_000_000, - gemini: 60_000_000, doubao: 60_000_000, - cerebras: 30_000_000, + groq: 30_000_000, "api-airforce": 24_000_000, - "ollama-cloud": 20_000_000, - groq: 15_000_000, bluesminds: 7_200_000, sambanova: 6_000_000, "arcee-ai": 4_800_000, diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 3bb6c85210..22d05864e7 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -255,6 +255,7 @@ export const GLMT_REQUEST_DEFAULTS = Object.freeze({ }); export const GLM_COUNT_TOKENS_TIMEOUT_MS = 3_000; +/** Module-load snapshot. Wire UA uses getClaudeCodeUserAgent("sdk-cli"). */ export const GLM_CLAUDE_CODE_USER_AGENT = getClaudeCodeUserAgent("sdk-cli"); export const GLM_ANTHROPIC_BETA = [ "claude-code-20250219", @@ -582,7 +583,7 @@ export function buildGlmBaseHeaders(apiKey: string, stream = true): Record = { "copilot-integration-id": GITHUB_COPILOT_INTEGRATION_ID, - "editor-version": GITHUB_COPILOT_EDITOR_VERSION, - "user-agent": GITHUB_COPILOT_CLI_USER_AGENT, + "editor-version": `copilot/${version}`, + "user-agent": `copilot/${version}`, "openai-intent": options.intent || GITHUB_COPILOT_OPENAI_INTENT, "x-interaction-type": GITHUB_COPILOT_INTERACTION_TYPE, "copilot-harness-id": GITHUB_COPILOT_HARNESS_ID, @@ -128,23 +155,25 @@ export function getQwenCliUserAgent(version = QWEN_CLI_VERSION): string { } export function getGitHubCopilotInternalUserHeaders(authorization: string): Record { + const version = getGitHubCopilotCliVersion(); return { Authorization: authorization, Accept: "application/json", "X-GitHub-Api-Version": GITHUB_COPILOT_API_VERSION, - "User-Agent": GITHUB_COPILOT_CHAT_USER_AGENT, - "Editor-Version": GITHUB_COPILOT_EDITOR_VERSION, - "Editor-Plugin-Version": GITHUB_COPILOT_CHAT_PLUGIN_VERSION, + "User-Agent": `GitHubCopilotChat/${version}`, + "Editor-Version": `copilot/${version}`, + "Editor-Plugin-Version": `copilot-chat/${version}`, }; } export function getGitHubCopilotRefreshHeaders(authorization: string): Record { + const version = getGitHubCopilotCliVersion(); return { Authorization: authorization, Accept: "application/json", "User-Agent": GITHUB_COPILOT_REFRESH_USER_AGENT, - "Editor-Version": GITHUB_COPILOT_EDITOR_VERSION, - "Editor-Plugin-Version": GITHUB_COPILOT_REFRESH_PLUGIN_VERSION, + "Editor-Version": `copilot/${version}`, + "Editor-Plugin-Version": `copilot/${version}`, }; } diff --git a/open-sse/config/providers/registry/claude/index.ts b/open-sse/config/providers/registry/claude/index.ts index ba3129678b..8388812300 100644 --- a/open-sse/config/providers/registry/claude/index.ts +++ b/open-sse/config/providers/registry/claude/index.ts @@ -7,7 +7,6 @@ import { ANTHROPIC_VERSION_HEADER, CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, - CLAUDE_CLI_USER_AGENT, resolvePublicCred, } from "../../shared.ts"; diff --git a/open-sse/config/providers/registry/groq/index.ts b/open-sse/config/providers/registry/groq/index.ts index 974e24e710..154ca7a574 100644 --- a/open-sse/config/providers/registry/groq/index.ts +++ b/open-sse/config/providers/registry/groq/index.ts @@ -24,6 +24,7 @@ export const groqProvider: RegistryEntry = { { id: "openai/gpt-oss-20b", name: "GPT-OSS 20B" }, { id: "qwen/qwen3-32b", name: "Qwen3 32B" }, { id: "qwen/qwen3.6-27b", name: "Qwen3.6 27B" }, + { id: "qwen/qwen3.8-27b", name: "Qwen3.8 27B" }, { id: "openai/gpt-oss-safeguard-20b", name: "GPT-OSS Safeguard 20B" }, ], }; diff --git a/open-sse/config/providers/registry/nara/index.ts b/open-sse/config/providers/registry/nara/index.ts index e2f840d4e7..177e8f39ba 100644 --- a/open-sse/config/providers/registry/nara/index.ts +++ b/open-sse/config/providers/registry/nara/index.ts @@ -4,16 +4,60 @@ import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; /** * NaraRouter — OpenAI-compatible aggregator (router.bynara.id). * - * Free key issued via their Telegram channel. The free tier is a shared - * 5M-tokens/day pool; many models are gated behind - * credit/plan, so only the free-tier models are pinned. + * Free key issued after linking a Telegram account. The free plan is one + * 7M-tokens/day bucket per account (GET /api/plans, 2026-09-02); only the + * plan's own models are pinned. Context lengths mirror the same models in + * our own registry (agnes, poolside, novita, stepfun); qwen3.8-27b has no + * published context yet, so it carries none. */ export const naraProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ id: "nara", baseUrl: "https://router.bynara.id/v1/chat/completions", models: [ - { id: "tencent-hy3", name: "Tencent Hy3", contextLength: 1000000 }, + { + id: "agnes-2.0-flash", + name: "Agnes 2.0 Flash", + contextLength: 262144, + toolCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + { + id: "agnes-2.5-flash", + name: "Agnes 2.5 Flash", + contextLength: 524288, + toolCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + { + id: "laguna-s-2.1", + name: "Laguna S 2.1", + contextLength: 262144, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "minimax-m3-free", + name: "MiniMax M3 (free)", + contextLength: 1000000, + supportsVision: true, + supportsReasoning: true, + }, { id: "mistral-large", name: "Mistral Large", contextLength: 252000, toolCalling: true }, - { id: "mistral-medium-3-5", name: "Mistral Medium 3.5", contextLength: 256000, toolCalling: true, supportsVision: true }, + { + id: "mistral-medium-3-5", + name: "Mistral Medium 3.5", + contextLength: 256000, + toolCalling: true, + supportsVision: true, + }, + { id: "qwen3.8-27b", name: "Qwen3.8 27B", toolCalling: true }, + { + id: "stepfun-3.7-flash", + name: "StepFun 3.7 Flash", + contextLength: 262144, + toolCalling: true, + }, ], }); diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 6c67c4eb5f..8b9b45bfec 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -16,6 +16,7 @@ import { CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, CLAUDE_CLI_USER_AGENT, + getClaudeCodeUserAgent, } from "../anthropicHeaders.ts"; import { getCodexDefaultHeaders } from "../codexClient.ts"; import { @@ -761,7 +762,7 @@ export function getClaudeCliHeaders(): Record { "Anthropic-Version": ANTHROPIC_VERSION_HEADER, "Anthropic-Beta": ANTHROPIC_BETA_CLAUDE_OAUTH, "Anthropic-Dangerous-Direct-Browser-Access": "true", - "User-Agent": CLAUDE_CLI_USER_AGENT, + "User-Agent": getClaudeCodeUserAgent("cli"), "X-App": "cli", "X-Stainless-Helper-Method": "stream", "X-Stainless-Retry-Count": "0", diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 11d5f9087c..b18c27e20c 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -6,8 +6,8 @@ import { type AlternateFormat, } from "../config/providers/alternateFormats.ts"; import { - CLAUDE_CLI_BILLING_VERSION, CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, + getClaudeCliBillingVersion, mergeClientAnthropicBeta, normalizeAnthropicHeaderVariants, } from "../config/anthropicHeaders.ts"; @@ -86,7 +86,7 @@ import { } from "../services/contextManager.ts"; import { randomUUID } from "node:crypto"; import { - CLAUDE_CODE_VERSION, + getClaudeCodeVersion, CLAUDE_CODE_STAINLESS_VERSION, buildUserIdJson, getSessionId, @@ -1163,7 +1163,7 @@ export class BaseExecutor { // system[0] (billing) and system[1] (sentinel) must not carry // cache_control — that belongs on upstream prompt blocks at [2..]. - const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CLI_BILLING_VERSION}; cc_entrypoint=cli; cch=00000;`; + const billingLine = `x-anthropic-billing-header: cc_version=${getClaudeCliBillingVersion()}; cc_entrypoint=cli; cch=00000;`; const SENTINEL = "You are Claude Code, Anthropic's official CLI for Claude."; const sysBlocks: Array> = Array.isArray(tb.system) @@ -1259,7 +1259,7 @@ export class BaseExecutor { ), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", - "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, + "User-Agent": `claude-cli/${getClaudeCodeVersion()} (external, cli)`, "X-Stainless-Package-Version": CLAUDE_CODE_STAINLESS_VERSION, "X-Stainless-Timeout": "600", "accept-encoding": "gzip, deflate, br, zstd", diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 5034b0da6c..77c06b5b14 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -216,9 +216,10 @@ function makeErrorResponse( extraHeaders?: Record; } ): Response { - const body = buildErrorBody(status, message, options?.details); - if (options?.type) body.error.type = options.type; - if (options?.code) body.error.code = options.code; + const body = buildErrorBody(status, message, options?.details, { + type: options?.type, + code: options?.code, + }); const headers: Record = { "Content-Type": "application/json" }; if (options?.extraHeaders) { for (const [key, value] of Object.entries(options.extraHeaders)) { diff --git a/open-sse/executors/claude-web/stream.ts b/open-sse/executors/claude-web/stream.ts index 264f8218e8..e3b6c724e9 100644 --- a/open-sse/executors/claude-web/stream.ts +++ b/open-sse/executors/claude-web/stream.ts @@ -453,9 +453,10 @@ function makeChunk( } function protocolErrorBody(): Record { - const body = buildErrorBody(502, "Claude Web stream protocol error"); - body.error.type = "upstream_protocol_error"; - body.error.code = "claude_web_protocol_error"; + const body = buildErrorBody(502, "Claude Web stream protocol error", undefined, { + type: "upstream_protocol_error", + code: "claude_web_protocol_error", + }); return body as unknown as Record; } diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index 78ee1c8b0f..92126d4a45 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -13,11 +13,15 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { CLAUDE_CODE_CLIENT_VERSION, CLAUDE_CODE_SDK_PACKAGE_VERSION, + getClaudeCodeClientVersion, } from "@/shared/constants/claudeCodeClient"; // ---------- Versions ------------------------------------------------------ export const CLAUDE_CODE_VERSION = CLAUDE_CODE_CLIENT_VERSION; +export function getClaudeCodeVersion(): string { + return getClaudeCodeClientVersion(); +} /** Bundled @anthropic-ai/sdk version for the pinned CLI release. */ export const CLAUDE_CODE_STAINLESS_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; @@ -156,7 +160,7 @@ export async function fetchClaudeBootstrap(accessToken: string): Promise = { system: "System", developer: "System", @@ -69,7 +71,35 @@ function buildSseChunk(data: unknown): string { return `data: ${JSON.stringify(data)}\n\n`; } -function buildOpenAiJsonCompletion(content: string, model: string, id: string, created: number): Response { +function parseStreamErrorMessage(data: string): string { + if (!data || data.length > MAX_STREAM_ERROR_DATA_CHARS) return STREAM_ERROR_FALLBACK; + + try { + const parsed = asRecord(JSON.parse(data)); + const directMessage = typeof parsed.message === "string" ? parsed.message.trim() : ""; + if (directMessage) return directMessage; + + if (typeof parsed.error === "string") { + const errorMessage = parsed.error.trim(); + if (errorMessage) return errorMessage; + } + + const nestedError = asRecord(parsed.error); + const nestedMessage = typeof nestedError.message === "string" ? nestedError.message.trim() : ""; + if (nestedMessage) return nestedMessage; + } catch { + // Malformed and over-complex payloads use the fixed public fallback below. + } + + return STREAM_ERROR_FALLBACK; +} + +function buildOpenAiJsonCompletion( + content: string, + model: string, + id: string, + created: number +): Response { return new Response( JSON.stringify({ id, @@ -84,7 +114,11 @@ function buildOpenAiJsonCompletion(content: string, model: string, id: string, c ); } -function toOpenAiErrorResponse(status: number, message: string, upstreamDetails?: unknown): Response { +function toOpenAiErrorResponse( + status: number, + message: string, + upstreamDetails?: unknown +): Response { return new Response(JSON.stringify(buildErrorBody(status, message, upstreamDetails)), { status, headers: { "Content-Type": "application/json" }, @@ -96,109 +130,214 @@ function toOpenAiErrorResponse(status: number, message: string, upstreamDetails? * data: {...}) from the upstream Response body and re-emit them as standard * OpenAI chat.completion.chunk SSE. */ -function translateSseStream(upstreamBody: ReadableStream, model: string, id: string, created: number): ReadableStream { +function translateSseStream( + upstreamBody: ReadableStream, + model: string, + id: string, + created: number +): ReadableStream { const decoder = new TextDecoder(); const encoder = new TextEncoder(); + const reader = upstreamBody.getReader(); + const pendingChunks: Uint8Array[] = []; + let buffer = ""; + let finished = false; + let roleEmitted = false; + let terminalError: Error | null = null; + let upstreamCancelRequested = false; + let downstreamCancelled = false; + let readInFlight = false; + let readerReleased = false; + + const releaseReader = () => { + if (readerReleased) return; + readerReleased = true; + reader.releaseLock(); + }; + + const cancelUpstream = (reason: unknown) => { + if (upstreamCancelRequested) return; + upstreamCancelRequested = true; + try { + // Upstream cleanup is provider-controlled and may never settle. The + // translated stream owns the reader lock and releases it independently. + void reader.cancel(reason).catch(() => {}); + } catch { + // Cancellation is cleanup-only; the terminal state is already fixed. + } + }; + + const queueChunk = (text: string) => { + pendingChunks.push(encoder.encode(text)); + }; + + const emitRole = () => { + if (roleEmitted) return; + roleEmitted = true; + queueChunk( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }) + ); + }; + + const finish = () => { + if (finished) return; + finished = true; + queueChunk( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }) + ); + queueChunk("data: [DONE]\n\n"); + }; + + const emitContent = (text: string) => { + if (!text) return; + emitRole(); + queueChunk( + buildSseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }) + ); + }; + + const emitError = (data: string) => { + if (finished) return; + finished = true; + cancelUpstream("1min.ai upstream stream error"); + + if (!roleEmitted) { + const message = parseStreamErrorMessage(data); + queueChunk(buildSseChunk(buildErrorBody(502, message))); + queueChunk("data: [DONE]\n\n"); + return; + } + + // A bare `{ error }` frame is dropped by the OpenAI passthrough sanitizer. + // Preserve every content delta already queued, then error the source with + // a fixed public message. pipeWithDisconnect() converts it into a native + // terminal error frame and drives usage, call-log, and fallback finalizers. + terminalError = Object.assign(new Error(STREAM_ERROR_FALLBACK), { + statusCode: 502, + }); + }; + + // SSE event framing: "event:"/"data:" lines, blank-line separated records. + const processEvent = (eventText: string) => { + let eventType = "message"; + const dataLines: string[] = []; + for (const rawLine of eventText.split("\n")) { + if (rawLine.startsWith("event:")) { + eventType = rawLine.slice(6).trim(); + } else if (rawLine.startsWith("data:")) { + dataLines.push(rawLine.slice(5).trim()); + } + } + const data = dataLines.join("\n"); + if (eventType === "content") { + try { + const parsed = asRecord(JSON.parse(data)); + if (typeof parsed.content === "string") emitContent(parsed.content); + } catch { + // Ignore malformed content events rather than surfacing partial JSON. + } + } else if (eventType === "error") { + emitError(data); + } else if (eventType === "done") { + finish(); + } + // "result" carries the final full aiRecord, redundant with the content + // events already streamed — intentionally ignored. + }; + + const processBufferedEvents = () => { + let separatorIndex = buffer.indexOf("\n\n"); + while (separatorIndex !== -1 && !finished) { + processEvent(buffer.slice(0, separatorIndex)); + buffer = buffer.slice(separatorIndex + 2); + separatorIndex = buffer.indexOf("\n\n"); + } + }; return new ReadableStream({ - async start(controller) { - controller.enqueue( - encoder.encode( - buildSseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], - }) - ) - ); + async pull(controller) { + if (downstreamCancelled) return; - const reader = upstreamBody.getReader(); - let buffer = ""; - let finished = false; - - const finish = () => { - if (finished) return; - finished = true; - controller.enqueue( - encoder.encode( - buildSseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - }) - ) - ); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - controller.close(); - }; - - const emitContent = (text: string) => { - if (!text) return; - controller.enqueue( - encoder.encode( - buildSseChunk({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { content: text }, finish_reason: null }], - }) - ) - ); - }; - - // SSE event framing: "event:"/"data:" lines, blank-line separated records. - const processEvent = (eventText: string) => { - let eventType = "message"; - const dataLines: string[] = []; - for (const rawLine of eventText.split("\n")) { - if (rawLine.startsWith("event:")) { - eventType = rawLine.slice(6).trim(); - } else if (rawLine.startsWith("data:")) { - dataLines.push(rawLine.slice(5).trim()); - } - } - const data = dataLines.join("\n"); - if (eventType === "content") { - try { - const parsed = asRecord(JSON.parse(data)); - if (typeof parsed.content === "string") emitContent(parsed.content); - } catch { - // Ignore malformed content events rather than surfacing partial JSON. - } - } else if (eventType === "error") { - emitContent(`\n[1min.ai error: ${data}]`); - finish(); - } else if (eventType === "done") { - finish(); - } - // "result" carries the final full aiRecord, redundant with the content - // events already streamed — intentionally ignored. - }; - - try { - while (!finished) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let separatorIndex = buffer.indexOf("\n\n"); - while (separatorIndex !== -1) { - processEvent(buffer.slice(0, separatorIndex)); - buffer = buffer.slice(separatorIndex + 2); - separatorIndex = buffer.indexOf("\n\n"); - } - } - if (!finished && buffer.trim()) processEvent(buffer); - finish(); - } catch (error) { - controller.error(error); - } finally { - reader.releaseLock(); + if (pendingChunks.length > 0) { + controller.enqueue(pendingChunks.shift()!); + return; } + + if (terminalError) { + releaseReader(); + controller.error(terminalError); + return; + } + + if (finished) { + releaseReader(); + controller.close(); + return; + } + + readInFlight = true; + try { + while (pendingChunks.length === 0 && !finished && !downstreamCancelled) { + const { done, value } = await reader.read(); + if (downstreamCancelled) return; + if (done) { + buffer += decoder.decode(); + if (buffer.trim()) processEvent(buffer); + finish(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + // Process the complete upstream chunk, even after it queues output. + // One network read may contain multiple content events followed by + // an error; the internal queue preserves all of them in order. + processBufferedEvents(); + } + + if (downstreamCancelled) return; + if (pendingChunks.length > 0) { + controller.enqueue(pendingChunks.shift()!); + } else if (terminalError) { + releaseReader(); + controller.error(terminalError); + } else if (finished) { + releaseReader(); + controller.close(); + } + } catch (error) { + releaseReader(); + if (!downstreamCancelled) controller.error(error); + } finally { + readInFlight = false; + if (downstreamCancelled) releaseReader(); + } + }, + cancel(reason) { + downstreamCancelled = true; + pendingChunks.length = 0; + // A client disconnect must release the upstream reader even when its + // next pull never settles. Do not await provider cleanup here: the + // downstream cancellation contract must remain bounded. + cancelUpstream(reason ?? "1min.ai downstream cancelled"); + if (!readInFlight) releaseReader(); }, }); } @@ -290,7 +429,9 @@ export class OneMinAiExecutor extends BaseExecutor { const aiRecord = asRecord(json.aiRecord); const detail = asRecord(aiRecord.aiRecordDetail); const resultObject = Array.isArray(detail.resultObject) ? detail.resultObject : []; - const content = resultObject.filter((part): part is string => typeof part === "string").join(""); + const content = resultObject + .filter((part): part is string => typeof part === "string") + .join(""); return { response: buildOpenAiJsonCompletion(content, model, id, created), diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 65a986d186..622e934084 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -5,7 +5,8 @@ import { import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts"; import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts"; import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts"; -import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts"; +import { buildFailureUsageRecord, projectFailureUsageErrorCode } from "./chatCore/failureUsage.ts"; +import { createTranslationFailureResult } from "./chatCore/translationFailure.ts"; import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts"; import { extractSystemRoleMessages, @@ -2513,35 +2514,11 @@ export async function handleChatCore({ : HTTP_STATUS.SERVER_ERROR; const message = error?.message || "Invalid request"; const errorType = typeof error?.errorType === "string" ? error.errorType : null; - - log?.warn?.("TRANSLATE", `Request translation failed: ${message}`); - - if (errorType) { - trackPendingRequest(model, provider, connectionId, false); - return { - success: false, - status: statusCode, - error: message, - response: new Response( - JSON.stringify({ - error: { - message, - type: errorType, - code: errorType, - }, - }), - { - status: statusCode, - headers: { - "Content-Type": "application/json", - }, - } - ), - }; - } + const result = createTranslationFailureResult(statusCode, message, errorType); + log?.warn?.("TRANSLATE", `Request translation failed: ${result.error}`); trackPendingRequest(model, provider, connectionId, false); - return createErrorResult(statusCode, message); + return result; } // The latest OmniGlyph release has protocol-native OpenAI transforms. Run @@ -3924,10 +3901,14 @@ export async function handleChatCore({ streamController.handleError(error); return createErrorResult(499, "Request aborted"); } - persistFailureUsage( - failureStatus, - upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error") - ); + const persistentErrorCode = projectFailureUsageErrorCode({ + statusCode: failureStatus, + message: failureMessage, + errorCode: + upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error"), + errorType: upstreamErrorType, + }); + persistFailureUsage(failureStatus, persistentErrorCode); console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`); if (stream && upstreamErrorCode) { const result = createStreamingErrorResult( @@ -4253,6 +4234,9 @@ export async function handleChatCore({ `${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})` ); } + // Classifiers and recovery paths above consume the raw provider wording. + // Project a separate value only at persistent connection-state boundaries. + const persistentMessage = sanitizeErrorMessage(message) || "Provider request failed"; const errorConnectionId = getCurrentConnectionId(); if (errorConnectionId && errorType) { try { @@ -4264,7 +4248,7 @@ export async function handleChatCore({ { testStatus: "banned", isActive: false, - lastError: message, + lastError: persistentMessage, lastErrorType: errorType, errorCode: String(statusCode), }, @@ -4295,7 +4279,7 @@ export async function handleChatCore({ ) { await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, - lastError: message, + lastError: persistentMessage, errorCode: statusCode, }); console.warn( @@ -4308,7 +4292,7 @@ export async function handleChatCore({ { testStatus: "deactivated", isActive: false, - lastError: message, + lastError: persistentMessage, lastErrorType: errorType, errorCode: String(statusCode), }, @@ -4332,7 +4316,7 @@ export async function handleChatCore({ errorConnectionId, { testStatus: "credits_exhausted", - lastError: message, + lastError: persistentMessage, lastErrorType: errorType, errorCode: String(statusCode), }, @@ -4418,7 +4402,7 @@ export async function handleChatCore({ rateLimitedUntil: kimiRateLimitResetAt, backoffLevel: 0, lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED, - lastError: message, + lastError: persistentMessage, errorCode: statusCode, }); console.warn( @@ -4447,7 +4431,7 @@ export async function handleChatCore({ errorConnectionId, { testStatus: "credits_exhausted", - lastError: message, + lastError: persistentMessage, lastErrorType: errorType, errorCode: String(statusCode), }, @@ -4463,14 +4447,14 @@ export async function handleChatCore({ // Normal 401 (token/session auth issue): keep account active for refresh/re-auth. await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, - lastError: message, + lastError: persistentMessage, errorCode: statusCode, }); } else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) { // OAuth 401 with invalid credentials - token refresh can recover await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, - lastError: message, + lastError: persistentMessage, errorCode: statusCode, }); console.warn( @@ -4480,7 +4464,7 @@ export async function handleChatCore({ // Cloud Code 403 with stale project: not a ban, keep account active. await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, - lastError: message, + lastError: persistentMessage, errorCode: statusCode, }); console.warn( @@ -4496,7 +4480,7 @@ export async function handleChatCore({ const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000; await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, - lastError: message, + lastError: persistentMessage, errorCode: statusCode, }); // T-PROBE: the 24h exclusion is a routing mutation — a probe must @@ -4521,7 +4505,7 @@ export async function handleChatCore({ const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000; await updateProviderConnection(errorConnectionId, { lastErrorType: errorType, - lastError: message, + lastError: persistentMessage, errorCode: statusCode, }); try { @@ -5305,9 +5289,12 @@ export async function handleChatCore({ }).catch(() => {}); const malformed = describeMalformedNonStream(translatedResponse, malformedTranslatedReason); const malformedMessage = `[${provider}/${model}] ${malformed.message}`; - const malformedClientBody = buildErrorBody(HTTP_STATUS.BAD_GATEWAY, malformedMessage); - malformedClientBody.error.code = malformed.code; - malformedClientBody.error.type = malformed.type; + const malformedClientBody = buildErrorBody( + HTTP_STATUS.BAD_GATEWAY, + malformedMessage, + undefined, + { code: malformed.code, type: malformed.type } + ); persistAttemptLogs({ status: HTTP_STATUS.BAD_GATEWAY, tokens: usage, diff --git a/open-sse/handlers/chatCore/failureUsage.ts b/open-sse/handlers/chatCore/failureUsage.ts index 52f70fbfee..d9fff0ae33 100644 --- a/open-sse/handlers/chatCore/failureUsage.ts +++ b/open-sse/handlers/chatCore/failureUsage.ts @@ -8,6 +8,21 @@ * `latencyMs` (Date.now() - startTime) and fires the fire-and-forget saveRequestUsage(...).catch(). */ +import { buildErrorBody } from "../../utils/error.ts"; + +export function projectFailureUsageErrorCode(opts: { + statusCode: number; + message: string; + errorCode?: string | null; + errorType?: string | null; +}): string { + const errorBody = buildErrorBody(opts.statusCode, opts.message, undefined, { + code: opts.errorCode || undefined, + type: opts.errorType || undefined, + }); + return errorBody.error.code || String(opts.statusCode); +} + export function buildFailureUsageRecord(opts: { provider: string | null | undefined; model: string | null | undefined; diff --git a/open-sse/handlers/chatCore/streamErrorResult.ts b/open-sse/handlers/chatCore/streamErrorResult.ts index 77244b611d..04e041e55c 100644 --- a/open-sse/handlers/chatCore/streamErrorResult.ts +++ b/open-sse/handlers/chatCore/streamErrorResult.ts @@ -25,13 +25,7 @@ export function createStreamingErrorResult( code?: string, type?: string ) { - const errorBody = buildErrorBody(statusCode, message); - if (code) { - errorBody.error.code = code; - } - if (type) { - errorBody.error.type = type; - } + const errorBody = buildErrorBody(statusCode, message, undefined, { code, type }); const body = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`; diff --git a/open-sse/handlers/chatCore/translationFailure.ts b/open-sse/handlers/chatCore/translationFailure.ts new file mode 100644 index 0000000000..622b5c8c47 --- /dev/null +++ b/open-sse/handlers/chatCore/translationFailure.ts @@ -0,0 +1,24 @@ +import { buildErrorBody, createErrorResult } from "../../utils/error.ts"; + +export function createTranslationFailureResult( + status: number, + message: string, + errorType: string | null +) { + if (!errorType) return createErrorResult(status, message); + const body = buildErrorBody( + status, + message, + undefined, + { type: errorType, code: errorType } + ); + return { + success: false as const, + status, + error: body.error.message, + response: new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + }; +} diff --git a/open-sse/handlers/moderations.ts b/open-sse/handlers/moderations.ts index c153e10eea..fa5cb72a16 100644 --- a/open-sse/handlers/moderations.ts +++ b/open-sse/handlers/moderations.ts @@ -6,7 +6,8 @@ import { CORS_HEADERS } from "../utils/cors.ts"; */ import { getModerationProvider, parseModerationModel } from "../config/moderationRegistry.ts"; -import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts"; +import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; +import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; @@ -57,14 +58,11 @@ export async function handleModeration({ body, credentials }) { if (!res.ok) { const errText = await res.text(); - // secret-leak hardening: redact any credential the upstream echoed back - // before relaying the error body to the client (structure-preserving). - return new Response(redactSensitiveErrorText(errText), { + return buildSanitizedUpstreamErrorResponse({ status: res.status, - headers: { - "Content-Type": "application/json", - ...CORS_HEADERS, - }, + rawBody: errText, + fallbackMessage: `Moderation provider returned HTTP ${res.status}`, + headers: CORS_HEADERS, }); } @@ -79,6 +77,10 @@ export async function handleModeration({ body, credentials }) { }); return new Response(JSON.stringify(data), { status: 200, headers }); } catch (err) { - return errorResponse(500, `Moderation request failed: ${err.message}`); + const safeDetail = + sanitizeErrorMessage(err) + .replace(/^[A-Za-z]*Error:\s*/, "") + .trim() || "unknown upstream failure"; + return errorResponse(500, `Moderation request failed: ${safeDetail}`); } } diff --git a/open-sse/handlers/ocr.ts b/open-sse/handlers/ocr.ts index f5d52f0106..16538e08cc 100644 --- a/open-sse/handlers/ocr.ts +++ b/open-sse/handlers/ocr.ts @@ -11,7 +11,8 @@ import { parseOcrModel, OCR_PROVIDERS, } from "../config/ocrRegistry.ts"; -import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts"; +import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; +import { buildSanitizedUpstreamErrorResponse } from "../utils/upstreamErrorResponse.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; import { @@ -151,15 +152,11 @@ export async function handleOcr({ if (!res.ok) { const errText = await res.text(); - // secret-leak hardening: an upstream OCR provider can echo the offending - // request (Authorization header / api key) inside its error text. Redact - // secret patterns (structure-preserving) before relaying to the client. - return new Response(redactSensitiveErrorText(errText), { + return buildSanitizedUpstreamErrorResponse({ status: res.status, - headers: { - "Content-Type": "application/json", - ...CORS_HEADERS, - }, + rawBody: errText, + fallbackMessage: `OCR provider returned HTTP ${res.status}`, + headers: CORS_HEADERS, }); } @@ -184,7 +181,8 @@ export async function handleOcr({ }); return new Response(JSON.stringify(parsed), { status: 200, headers }); } catch (err) { - console.error("[OCR]", err); + const safeErrorMessage = sanitizeErrorMessage(err).trim() || "OCR request failed"; + console.error("[OCR]", safeErrorMessage); return errorResponse(500, "OCR request failed"); } } diff --git a/open-sse/mcp-server/errorMessage.ts b/open-sse/mcp-server/errorMessage.ts new file mode 100644 index 0000000000..f90c570166 --- /dev/null +++ b/open-sse/mcp-server/errorMessage.ts @@ -0,0 +1,13 @@ +import { sanitizeErrorMessage } from "../utils/error.ts"; + +export function toSafeMcpErrorMessage( + value: unknown, + fallback = "MCP tool execution failed" +): string { + try { + const raw = value instanceof Error ? value.message : value; + return sanitizeErrorMessage(raw) || fallback; + } catch { + return fallback; + } +} diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 73e3a387dc..4c30e608f9 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -93,7 +93,7 @@ import { import { getDbInstance, ensureDbInitialized } from "../../src/lib/db/core.ts"; import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; +import { toSafeMcpErrorMessage } from "./errorMessage.ts"; import { mcpFetchTimeoutSignal } from "./fetchTimeout.ts"; import { getMcpModelsCatalog } from "./catalog.ts"; import { registerRadarCatalogTool } from "./radarCatalog.ts"; @@ -328,9 +328,7 @@ async function handleGetHealth() { .filter(({ settled }) => settled.status === "rejected") .map(({ source, settled }) => ({ source, - error: sanitizeErrorMessage( - settled.status === "rejected" ? (settled as PromiseRejectedResult).reason : undefined - ), + error: toSafeMcpErrorMessage((settled as PromiseRejectedResult).reason, ""), })); const result = { @@ -378,7 +376,7 @@ async function handleGetHealth() { await logToolCall("omniroute_get_health", {}, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_get_health", {}, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -420,7 +418,7 @@ async function handleListCombos(args: { includeMetrics?: boolean }) { await logToolCall("omniroute_list_combos", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_list_combos", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -435,7 +433,7 @@ async function handleGetComboMetrics(args: { comboId: string }) { await logToolCall("omniroute_get_combo_metrics", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_get_combo_metrics", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -451,7 +449,7 @@ async function handleSwitchCombo(args: { comboId: string; active: boolean }) { await logToolCall("omniroute_switch_combo", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_switch_combo", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -472,7 +470,7 @@ async function handleCreateCombo(args: { await logToolCall("omniroute_create_combo", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_create_combo", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -493,7 +491,7 @@ async function handleCheckQuota(args: { provider?: string; connectionId?: string await logToolCall("omniroute_check_quota", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_check_quota", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -562,7 +560,7 @@ async function handleRouteRequest(args: { ); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall( "omniroute_route_request", { model: args.model }, @@ -611,7 +609,7 @@ async function handleCostReport(args: { period?: string }) { await logToolCall("omniroute_cost_report", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_cost_report", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -631,7 +629,7 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s ); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_list_models_catalog", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -660,7 +658,7 @@ async function handleWebSearch(args: { await logToolCall("omniroute_web_search", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_web_search", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -686,7 +684,7 @@ async function handleXSearch(args: { await logToolCall("omniroute_x_search", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_x_search", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -726,7 +724,7 @@ async function handleWebFetch(args: { await logToolCall("omniroute_web_fetch", args, result, Date.now() - start, true); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err); await logToolCall("omniroute_web_fetch", args, null, Date.now() - start, false, msg); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } @@ -1182,7 +1180,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Memory tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1209,7 +1207,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Skill tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1234,7 +1232,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Agent skill tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }) @@ -1259,7 +1257,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "GitHub skill tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1286,7 +1284,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Plugin tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1313,7 +1311,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Compression tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1350,7 +1348,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Pool tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1378,7 +1376,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Gamification tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1405,7 +1403,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Notion tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1432,8 +1430,9 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (error) { + const msg = toSafeMcpErrorMessage(error, "Local corpus tool execution failed"); return { - content: [{ type: "text" as const, text: `Error: ${sanitizeErrorMessage(error)}` }], + content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true, }; } @@ -1461,7 +1460,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Obsidian tool execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } }, @@ -1502,7 +1501,7 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer { ], }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = toSafeMcpErrorMessage(err, "Skill execution failed"); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true, diff --git a/open-sse/services/__tests__/tierResolver.test.ts b/open-sse/services/__tests__/tierResolver.test.ts index fac0b24404..132b6e8561 100644 --- a/open-sse/services/__tests__/tierResolver.test.ts +++ b/open-sse/services/__tests__/tierResolver.test.ts @@ -60,10 +60,10 @@ describe("TierResolver", () => { expect(result.hasFreeTier).toBe(true); }); - it("classifies Cerebras as free", () => { + it("classifies Cerebras as not free after the no-card trial ended (#11773)", () => { const result = classifyTier("cerebras", "llama-3.1-70b"); - expect(result.tier).toBe(PROVIDER_TIER.FREE); - expect(result.hasFreeTier).toBe(true); + expect(result.tier).not.toBe(PROVIDER_TIER.FREE); + expect(result.hasFreeTier).toBe(false); }); it("classifies Groq as free", () => { @@ -228,7 +228,6 @@ describe("TierResolver", () => { "longcat", "cloudflare-ai", "nvidia-nim", - "cerebras", "groq", ]) { expect(LEGACY_FREE_PROVIDERS.includes(id), `expected ${id} in LEGACY_FREE_PROVIDERS`).toBe( diff --git a/open-sse/services/antigravityQuotaFamily.ts b/open-sse/services/antigravityQuotaFamily.ts index 94016c1109..07e0012b2b 100644 --- a/open-sse/services/antigravityQuotaFamily.ts +++ b/open-sse/services/antigravityQuotaFamily.ts @@ -55,6 +55,14 @@ export function getQuotaScopeLabelForProvider( return getAntigravityQuotaFamily(model) === "other" ? "model" : "family"; } +export function getQuotaFetchScope( + provider: string | null | undefined, + model: string | null | undefined +): string { + if (provider !== "antigravity" && provider !== "agy") return "*"; + return getQuotaScopedModelForProvider(provider, model) ?? "*"; +} + export function isAntigravityQuotaProvider(provider: string | null | undefined): boolean { return provider === "antigravity" || provider === "agy"; } diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts index ff5da0fa7f..4938c03f04 100644 --- a/open-sse/services/ccBridgeTransforms.ts +++ b/open-sse/services/ccBridgeTransforms.ts @@ -24,6 +24,7 @@ import { createHash } from "node:crypto"; import { CLAUDE_CODE_CLIENT_BUILD_REVISION, CLAUDE_CODE_CLIENT_VERSION, + getClaudeCodeClientVersion, } from "@/shared/constants/claudeCodeClient"; // ──────────────────────────────────────────────────────────────────────────── @@ -122,6 +123,9 @@ export const CCH_SALT = "59cf53e54c78"; export const CCH_POSITIONS = [4, 7, 20] as const; /** Default `cc_version=` value embedded in the billing header. */ export const DEFAULT_CLAUDE_CODE_VERSION = CLAUDE_CODE_CLIENT_VERSION; +export function getDefaultClaudeCodeVersion(): string { + return getClaudeCodeClientVersion(); +} /** Identity sentinel prepended for Claude Agent SDK callers. */ export const CLAUDE_AGENT_SDK_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; @@ -292,7 +296,7 @@ export function buildBillingHeaderValue( messages: Message[], options: BuildBillingHeaderOptions ): string { - const version = options.version || DEFAULT_CLAUDE_CODE_VERSION; + const version = options.version || getDefaultClaudeCodeVersion(); const firstUserText = extractFirstUserMessageText(messages); const suffix = diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 12480187e2..c9a66c6380 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -5,7 +5,7 @@ import { ANTHROPIC_VERSION_HEADER } from "../config/anthropicHeaders.ts"; import { CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION, CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION, - CLAUDE_CODE_COMPATIBLE_USER_AGENT, + getClaudeCodeUserAgent, } from "../config/claudeCodeCompatibleIdentity.ts"; import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts"; @@ -183,7 +183,7 @@ export function buildClaudeCodeCompatibleHeaders( }), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", - "User-Agent": CLAUDE_CODE_COMPATIBLE_USER_AGENT, + "User-Agent": getClaudeCodeUserAgent("sdk-cli"), "X-Stainless-Retry-Count": "0", "X-Stainless-Timeout": String(CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS), "X-Stainless-Lang": "js", diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index be662c600f..870a470749 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -69,6 +69,7 @@ import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLocko import { fetchCodexQuota } from "./codexQuotaFetcher.ts"; import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; import { resolveProviderId } from "../../src/shared/constants/providers.ts"; +import { getQuotaFetchScope } from "./antigravityQuotaFamily.ts"; import * as semaphore from "./rateLimitSemaphore.ts"; import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker"; import { parseModel } from "./model.ts"; @@ -178,6 +179,7 @@ import { } from "./combo/validateQuality.ts"; import { resolveComboCooldownWaitDecision, + resolveCircuitOpenWaitDecision, ResolveComboCooldownDecisionResult, } from "./combo/comboCooldownRetry.ts"; import { @@ -595,14 +597,17 @@ export async function buildAutoCandidates( statusPenaltyReason = connectionStatusReason; } if (fetcher && target.connectionId) { - const quotaKey = `${provider}:${target.connectionId}`; + const quotaScope = getQuotaFetchScope(provider, target.modelStr); + const quotaKey = `${provider}:${target.connectionId}:${quotaScope}`; if (!quotaPromises.has(quotaKey)) { quotaPromises.set( quotaKey, fetchResetAwareQuotaWithCache({ provider, connectionId: target.connectionId, - connection, + connection: connection + ? { ...connection, requestedModel: target.modelStr } + : connection, fetcher, config: resetWindowConfig, log: {}, @@ -1133,6 +1138,8 @@ async function handleComboChatInner({ let lastError: string | null = null; let earliestRetryAfter: ComboRetryAfter | null = null; let lastStatus: number | null = null; + let skippedForCircuitOpen = false; + let earliestCircuitOpenRetryMs = 0; // #11804: the loop-safety timer is armed per setTry iteration but must be // cleared on EVERY exit path, not just the happy one. Hoisted to function // scope so the `finally` at the end of this function always reaches it — @@ -1151,6 +1158,8 @@ async function handleComboChatInner({ const exhaustedProviders = new Set(); const exhaustedConnections = new Set(); const transientRateLimitedProviders = new Set(); + skippedForCircuitOpen = false; + earliestCircuitOpenRetryMs = 0; if (setTry > 0) { log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`); await new Promise((resolve) => { @@ -1272,7 +1281,15 @@ async function handleComboChatInner({ }; const cb = getCircuitBreaker(provider); - if (cb.getStatus().state === "OPEN") { + const cbStatus = cb.getStatus(); + if (cbStatus.state === "OPEN") { + skippedForCircuitOpen = true; + if ( + cbStatus.retryAfterMs > 0 && + (earliestCircuitOpenRetryMs === 0 || cbStatus.retryAfterMs < earliestCircuitOpenRetryMs) + ) { + earliestCircuitOpenRetryMs = cbStatus.retryAfterMs; + } log.info("COMBO", `Skipping ${modelStr} — circuit breaker OPEN for ${provider}`); recordComboDecision(traceInvocationId, { step: target.executionKey, @@ -1380,7 +1397,8 @@ async function handleComboChatInner({ resilienceSettings, quotaCutoffResetWindowConfig, combo.name, - log, modelStr + log, + modelStr ); if (quotaCutoff.blocked) { log.info( @@ -2762,6 +2780,32 @@ async function handleComboChatInner({ // Retry the entire set if more attempts remain if (setTry < maxSetRetries) continue; + if (!lastStatus && recordedAttempts === 0 && comboCooldownWaitEnabled) { + const circuitOpenWait = resolveCircuitOpenWaitDecision({ + skippedForCircuitOpen, + retryAfterMs: earliestCircuitOpenRetryMs, + attempt: comboCooldownAttempt, + budgetLeftMs: comboCooldownBudgetLeftMs, + settings: resilienceSettings.comboCooldownWait, + }); + if (circuitOpenWait.wait) { + log.info( + "COMBO", + `${strategy} circuit-open wait: waiting ${Math.ceil(circuitOpenWait.waitMs / 1000)}s (reason=${circuitOpenWait.reason ?? "circuit_open"}) then retrying (attempt ${comboCooldownAttempt + 1}/${resilienceSettings.comboCooldownWait.maxAttempts})` + ); + const completed = await waitForCooldownAwareRetry(circuitOpenWait.waitMs, signal); + if (!completed) { + return errorResponse(499, "Request aborted"); + } + comboCooldownAttempt += 1; + comboCooldownBudgetLeftMs = Math.max( + 0, + comboCooldownBudgetLeftMs - circuitOpenWait.waitMs + ); + return dispatchWithCooldownRetry(); + } + } + // All set retries exhausted — return the final error // #10681: finalize the decision trace (all targets failed or skipped). finalizeComboTrace(traceInvocationId, orderedTargets); diff --git a/open-sse/services/combo/comboCooldownRetry.ts b/open-sse/services/combo/comboCooldownRetry.ts index 4b2b56609a..4b96552b72 100644 --- a/open-sse/services/combo/comboCooldownRetry.ts +++ b/open-sse/services/combo/comboCooldownRetry.ts @@ -56,6 +56,7 @@ export const COMBO_COOLDOWN_RETRYABLE_REASONS: ReadonlySet = new Set([ "transient", "overloaded", "server_error", + "circuit_open", ]); export interface ComboCooldownWaitSettings { @@ -256,3 +257,36 @@ export function resolveComboCooldownWaitDecision( reason: typeof best.reason === "string" ? best.reason : null, }; } + +export interface ResolveCircuitOpenWaitInput { + skippedForCircuitOpen: unknown; + retryAfterMs: unknown; + attempt: number; + budgetLeftMs: number; + settings: ComboCooldownWaitSettings; +} + +/** + * When every combo target was pre-skipped because the whole-provider breaker is + * OPEN, wait out a SHORT reset instead of crystallizing ALL_TARGETS_SKIPPED. + * Same ceilings as model-lockout waits. Live incident 2026-09-03: offical-fable + * (single claude target) returned 43ms 503 while the breaker reset was 60s. + */ +export function resolveCircuitOpenWaitDecision( + input: ResolveCircuitOpenWaitInput +): ResolveComboCooldownDecisionResult { + if (input.settings.enabled !== true || input.skippedForCircuitOpen !== true) { + return { wait: false, waitMs: 0, reason: null }; + } + const retryAfterMs = toFiniteWaitMs(input.retryAfterMs); + if (retryAfterMs <= 0) return { wait: false, waitMs: 0, reason: null }; + const waitMs = retryAfterMs + COMBO_COOLDOWN_WAIT_MARGIN_MS; + const decision = shouldWaitForComboCooldown({ + reason: "circuit_open", + waitMs, + attempt: input.attempt, + budgetLeftMs: input.budgetLeftMs, + settings: input.settings, + }); + return { ...decision, reason: "circuit_open" }; +} diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index 972abc5765..6359607cac 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -11,7 +11,11 @@ import { remainingPercentFromQuotaWindows } from "../antigravityQuotaFamily.ts"; import { errorResponse } from "../../utils/error.ts"; import { parseModel } from "../model.ts"; import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts"; -import { isLocalStreamLifecycleError, isLocalExecutionError } from "@/shared/utils/circuitBreaker"; +import { + isLocalStreamLifecycleError, + isLocalExecutionError, + isModelCapacityOverloadError, +} from "@/shared/utils/circuitBreaker"; import { CONTEXT_OVERFLOW_PATTERNS, MODEL_ACCESS_DENIED_PATTERNS } from "../accountFallback.ts"; import { isResourceNotFoundResponse } from "../errorClassifier.ts"; import { getTrustedLocalRateLimitResponse } from "../rateLimitManager/errors.ts"; @@ -213,6 +217,12 @@ export function shouldRecordProviderBreakerFailure(args: { }): boolean { return ( (!args.isStreamReadinessFailure || args.isStreamEarlyEof === true) && + // Overloaded 502 (STREAM_EARLY_EOF wrapping "Overloaded") must not trip + // the whole-provider breaker. The status=529 check is defense in depth: + // 529 is not in PROVIDER_BREAKER_FAILURE_STATUSES today, but a later + // addition of 529 to that set must still stay off the breaker. + !isModelCapacityOverloadError(args.error) && + !isModelCapacityOverloadError(args.status) && PROVIDER_BREAKER_FAILURE_STATUSES.has(args.status) && (!args.sameProviderNext || args.isProxyUnreachable === true) && !args.skipProviderBreaker && @@ -441,10 +451,7 @@ export function quotaRemainingPercentFromQuota( const windows = record.windows; if (windows && typeof windows === "object" && !Array.isArray(windows)) { - const fromWindows = remainingPercentFromQuotaWindows( - windows as Record, - scope - ); + const fromWindows = remainingPercentFromQuotaWindows(windows as Record, scope); if (fromWindows !== null) return fromWindows; } diff --git a/open-sse/services/combo/quotaExhaustionCutoff.ts b/open-sse/services/combo/quotaExhaustionCutoff.ts index a73d3628f4..ede1424105 100644 --- a/open-sse/services/combo/quotaExhaustionCutoff.ts +++ b/open-sse/services/combo/quotaExhaustionCutoff.ts @@ -119,7 +119,7 @@ export async function resolveQuotaExhaustionCutoffForTarget( const quota = await fetchResetAwareQuotaWithCache({ provider, connectionId, - connection, + connection: connection ? { ...connection, requestedModel } : connection, fetcher, config: resetWindowConfig, log, diff --git a/open-sse/services/combo/quotaStrategies.ts b/open-sse/services/combo/quotaStrategies.ts index c4cb4c5b52..88401c0f43 100644 --- a/open-sse/services/combo/quotaStrategies.ts +++ b/open-sse/services/combo/quotaStrategies.ts @@ -46,6 +46,7 @@ import { } from "./quotaScoring.ts"; import { rankByHeadroom, type HeadroomSaturation } from "./headroomRanking.ts"; import { preferAntigravityConnectionsWithStoredProject } from "../antigravityProjectPersist.ts"; +import { getQuotaFetchScope } from "../antigravityQuotaFamily.ts"; import { isQuotaExhaustedForRequest } from "../../../src/domain/quotaCache.ts"; const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000; @@ -269,14 +270,17 @@ async function scoreQuotaAwareTargets({ const provider = getResetAwareProvider(target); const fetcher = provider ? getQuotaFetcher(provider) : null; if (fetcher && provider && target.connectionId) { - const quotaKey = `${provider}:${target.connectionId}`; + const quotaKey = `${provider}:${target.connectionId}:${getQuotaFetchScope(provider, target.modelStr)}`; if (!quotaPromises.has(quotaKey)) { + const connection = connectionById.get(target.connectionId); quotaPromises.set( quotaKey, fetchResetAwareQuotaWithCache({ provider, connectionId: target.connectionId, - connection: connectionById.get(target.connectionId), + connection: connection + ? { ...connection, requestedModel: target.modelStr } + : connection, fetcher, config, log, @@ -354,7 +358,10 @@ export async function fetchResetAwareQuotaWithCache({ log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }; comboName: string; }): Promise { - const cacheKey = `${provider}:${connectionId}`; + const requestedModel = + typeof connection?.requestedModel === "string" ? connection.requestedModel : null; + const cacheScope = getQuotaFetchScope(provider, requestedModel); + const cacheKey = `${provider}:${connectionId}:${cacheScope}`; const ttlMs = config.quotaCacheTtlMs; const maxStaleMs = config.quotaCacheMaxStaleMs; const now = Date.now(); diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index 0325b64b97..11e8efa654 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -56,6 +56,25 @@ function isEmptyContentFailure(status: number, errorText: string): boolean { return status === 502 && (/empty content/i.test(errorText) || /empty response/i.test(errorText)); } +/** #12441 — quota/credits bodies must not take the 401/403 auth-skip path. */ +export function isQuotaOrCreditsError( + errorText: string, + structuredError?: { code?: string; type?: string; message?: string } +): boolean { + const blobs = [ + errorText, + structuredError?.type, + structuredError?.message, + structuredError?.code, + ].filter((value): value is string => Boolean(value)); + const joined = blobs.join(" "); + if (/credits exhausted/i.test(joined)) return true; + if (/quota exhausted/i.test(joined) && !/authentication expired/i.test(joined)) return true; + // Classify each candidate independently. A non-quota structuredError.code must + // not hide quota wording in errorText or structuredError.message. + return blobs.some((blob) => classifyErrorText(blob) === RateLimitReason.QUOTA_EXHAUSTED); +} + export type ComboExhaustionSets = { exhaustedProviders: Set; exhaustedConnections: Set; @@ -173,12 +192,14 @@ export function applyComboTargetExhaustion( .filter(Boolean) .join(" ") ); + const quotaMisclassifiedAsAuth = isQuotaOrCreditsError(errorText, structuredError); if ( AUTH_LEVEL_ERROR_STATUSES.includes(result.status) && // Cloudflare 1010 is a 403-ONLY fingerprint rejection. A 401 that merely happens to // mention "1010" or "fingerprint_rejection" in a port/count/model token must NOT skip // auth-level exhaustion — only a 403 carrying the Cloudflare fingerprint signal does. !(result.status === 403 && (fingerprintToken || fingerprintText)) && + !quotaMisclassifiedAsAuth && provider && provider !== "unknown" ) { diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 2bdbbbc8c2..5e60017662 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -256,7 +256,7 @@ export function classifyProviderError( const oauthInvalid = isOAuthInvalidToken(bodyStr); const preserveQuota429 = shouldPreserveQuotaSignalsFor429(provider); - if ((creditsExhausted || subscriptionQuotaExhausted) && [400, 402, 403].includes(statusCode)) { + if ((creditsExhausted || subscriptionQuotaExhausted) && [400, 401, 402, 403].includes(statusCode)) { return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; } diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts index 81419d5a6b..ac89aae30a 100644 --- a/open-sse/services/genericQuotaFetcher.ts +++ b/open-sse/services/genericQuotaFetcher.ts @@ -24,6 +24,10 @@ import { type QuotaFetcher, type QuotaInfo, } from "./quotaPreflight.ts"; +import { + getAntigravityQuotaFamily, + getQuotaFetchScope, +} from "./antigravityQuotaFamily.ts"; type UsageFetcher = ( connection: Parameters[0], @@ -54,7 +58,7 @@ export function __agePendingForceRefreshForTests( connectionId: string, ageMs: number ): void { - pendingForceRefresh.set(cacheKey(provider, connectionId), Date.now() - ageMs); + pendingForceRefresh.set(connectionKey(provider, connectionId), Date.now() - ageMs); } /** Test-only: backdate a convert-null miss so the 60s hammer-guard is unit-testable. */ @@ -63,7 +67,7 @@ export function __agePendingForceRefreshMissForTests( connectionId: string, ageMs: number ): void { - pendingForceRefreshMiss.set(cacheKey(provider, connectionId), Date.now() - ageMs); + pendingForceRefreshMiss.set(connectionKey(provider, connectionId), Date.now() - ageMs); } /** Test-only: drop all wrapper/flag maps so tests cannot leak across ids. */ @@ -80,10 +84,25 @@ interface CacheEntry { const cache = new Map(); -function cacheKey(provider: string, connectionId: string): string { +function connectionKey(provider: string, connectionId: string): string { return `${provider.trim()}::${connectionId.trim()}`; } +function quotaCacheScope( + provider: string, + requestedModel?: string | null +): string { + return getQuotaFetchScope(provider, requestedModel); +} + +function cacheKey( + provider: string, + connectionId: string, + requestedModel?: string | null +): string { + return `${connectionKey(provider, connectionId)}::${quotaCacheScope(provider, requestedModel)}`; +} + function dropExpiredPendingForceRefresh(key: string, now: number): boolean { const stampedAt = pendingForceRefresh.get(key); if (stampedAt === undefined) return true; @@ -216,7 +235,15 @@ interface ConnectionInputs { * / shape-unknown / missing). Exported for unit testing — the production path * is `fetchGenericQuota`, which adds caching + the upstream call. */ -export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null { +type UsageToQuotaContext = { + requestedModel?: string | null; + provider?: string | null; +}; + +export function convertUsageToQuotaInfo( + usage: unknown, + context: UsageToQuotaContext = {} +): QuotaInfo | null { if (!usage || typeof usage !== "object") return null; const usageRecord = usage as Record; if ( @@ -235,31 +262,51 @@ export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null { } const windows: Record = {}; - let worstPercent = 0; - let worstResetAt: string | null = null; for (const [name, entry] of Object.entries(quotasObj as Record)) { const percentUsed = percentUsedForQuota(entry); if (percentUsed === null) continue; - const resetAt = resetAtForQuota(entry); - windows[name] = { percentUsed, resetAt }; - if (percentUsed > worstPercent) { - worstPercent = percentUsed; - worstResetAt = resetAt; - } + windows[name] = { percentUsed, resetAt: resetAtForQuota(entry) }; } if (Object.keys(windows).length === 0) return null; - const normalized = normalizeQuotaWindows(windows); + const requestedFamily = + isAntigravityProvider(context.provider) && context.requestedModel + ? getAntigravityQuotaFamily(context.requestedModel) + : null; + const providerScopedWindows = + requestedFamily === "gemini" || requestedFamily === "claude" + ? Object.fromEntries( + Object.entries(windows).filter(([key]) => { + if (key.endsWith("_weekly")) { + return antigravityWeeklyWindowMatchesFamily(key, requestedFamily); + } + return getAntigravityQuotaFamily(key) === requestedFamily; + }) + ) + : windows; + if (Object.keys(providerScopedWindows).length === 0) return null; + + const normalized = normalizeQuotaWindows(providerScopedWindows, context); + const scopedEntries = Object.values(providerScopedWindows); + const percentUsed = scopedEntries.reduce( + (worst, entry) => Math.max(worst, entry.percentUsed), + 0 + ); + const resetAt = + scopedEntries.reduce<{ percentUsed: number; resetAt: string | null } | null>( + (worst, entry) => (!worst || entry.percentUsed > worst.percentUsed ? entry : worst), + null + )?.resetAt ?? null; return { used: 0, total: 0, - percentUsed: worstPercent, - resetAt: worstResetAt, - windows, + percentUsed, + resetAt, + windows: providerScopedWindows, ...normalized, - limitReached: worstPercent >= 1 - 1e-9, + limitReached: percentUsed >= 1 - 1e-9, }; } @@ -269,12 +316,29 @@ export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null { * naming convention. * * - Claude: "session (5h)" → window5h, "weekly (7d)" → window7d - * - Antigravity: worst per-model quota → window5h; worst *_weekly quota → window7d + * - Antigravity: requested-family model quota → window5h; matching family weekly quota → window7d */ +function isAntigravityProvider(provider: string | null | undefined): boolean { + return provider === "antigravity" || provider === "agy"; +} + +function antigravityWeeklyWindowMatchesFamily( + key: string, + family: "gemini" | "claude" +): boolean { + if (!key.endsWith("_weekly")) return false; + return family === "gemini" ? key === "gemini_weekly" : key === "claude_gpt_weekly"; +} + function normalizeQuotaWindows( - windows: Record + windows: Record, + context: UsageToQuotaContext ): Record { const normalized: Record = {}; + const requestedFamily = + isAntigravityProvider(context.provider) && context.requestedModel + ? getAntigravityQuotaFamily(context.requestedModel) + : null; // Claude-style explicit time windows. if (windows["session (5h)"] && !normalized.window5h) { @@ -284,22 +348,31 @@ function normalizeQuotaWindows( normalized.window7d = windows["weekly (7d)"]; } - // Antigravity-style per-model 5h windows: pick the worst (most used) model quota. + // Antigravity-style per-model windows: pick worst only inside requested family. const modelWindows = Object.entries(windows).filter( ([key]) => key !== "credits" && !key.endsWith("_weekly") && !key.startsWith("window") && !key.includes("(5h)") && - !key.includes("(7d)") + !key.includes("(7d)") && + (requestedFamily === null || + requestedFamily === "other" || + getAntigravityQuotaFamily(key) === requestedFamily) ); if (modelWindows.length > 0 && !normalized.window5h) { const worst = modelWindows.reduce((a, b) => (a[1].percentUsed > b[1].percentUsed ? a : b)); normalized.window5h = worst[1]; } - // Antigravity-style weekly family buckets: pick the worst *_weekly quota. - const weeklyWindows = Object.entries(windows).filter(([key]) => key.endsWith("_weekly")); + // Antigravity-style weekly buckets: pick worst only inside requested family. + const weeklyWindows = Object.entries(windows).filter(([key]) => { + const hasFamilyScope = requestedFamily === "gemini" || requestedFamily === "claude"; + return ( + key.endsWith("_weekly") && + (!hasFamilyScope || antigravityWeeklyWindowMatchesFamily(key, requestedFamily)) + ); + }); if (weeklyWindows.length > 0 && !normalized.window7d) { const worst = weeklyWindows.reduce((a, b) => (a[1].percentUsed > b[1].percentUsed ? a : b)); normalized.window7d = worst[1]; @@ -320,18 +393,21 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) const provider = typeof conn.provider === "string" ? conn.provider.trim() : ""; if (!provider) return null; - const key = cacheKey(provider, connectionId); + const requestedModel = + typeof connection.requestedModel === "string" ? connection.requestedModel : undefined; + const key = cacheKey(provider, connectionId, requestedModel); + const forceKey = connectionKey(provider, connectionId); const now = Date.now(); - const forceRefresh = isPendingForceRefresh(key, now); + const forceRefresh = isPendingForceRefresh(forceKey, now); const hit = cachedQuotaIfFresh(key, forceRefresh, now); if (hit) return hit; // convert-null / throw keep the force-refresh flag (agy inner caches are // still stale) but must not hammer those endpoints on every routing tick. - if (isForceRefreshMissCooling(key, forceRefresh, now)) return null; + if (isForceRefreshMissCooling(forceKey, forceRefresh, now)) return null; // Capture before await: a 429 during fetchUsage re-stamps this; writing // the pre-429 snapshot would wipe that flag and recache stale quota. - const refreshStamp = pendingForceRefresh.get(key); + const refreshStamp = pendingForceRefresh.get(forceKey); let usage: unknown; try { @@ -340,28 +416,29 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) ...(forceRefresh ? { forceRefresh: true } : {}), }); } catch { - markPendingForceRefreshMiss(key); + markPendingForceRefreshMiss(forceKey); return null; } - const quota = convertUsageToQuotaInfo(usage); + const quota = convertUsageToQuotaInfo(usage, { provider, requestedModel }); if (!quota) { - markPendingForceRefreshMiss(key); + markPendingForceRefreshMiss(forceKey); return null; } // Concurrent 429 re-stamped a still-live flag — do not recache the // pre-429 snapshot. A vanished or expired stamp is not a 429. - if (isConcurrentForceRefresh(key, refreshStamp)) { + if (isConcurrentForceRefresh(forceKey, refreshStamp)) { return quota; } - pendingForceRefresh.delete(key); - pendingForceRefreshMiss.delete(key); + pendingForceRefresh.delete(forceKey); + pendingForceRefreshMiss.delete(forceKey); - // Refresh the static window catalog so the dashboard can render the right - // modal inputs without waiting for the user to open the page. - registerQuotaWindows(provider, Object.keys(quota.windows || {})); + // Refresh the static window catalog from the unscoped usage payload so a + // family-scoped request cannot hide sibling-family dashboard controls. + const unscopedQuota = convertUsageToQuotaInfo(usage, { provider }); + registerQuotaWindows(provider, Object.keys(unscopedQuota?.windows || quota.windows || {})); cache.set(key, { quota, fetchedAt: Date.now() }); return quota; @@ -373,13 +450,16 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) * fresh data instead of a 60s stale window. */ export function invalidateGenericQuotaCache(provider: string, connectionId: string): void { - const key = cacheKey(provider, connectionId); - cache.delete(key); + const forceKey = connectionKey(provider, connectionId); + const prefix = `${forceKey}::`; + for (const key of cache.keys()) { + if (key.startsWith(prefix)) cache.delete(key); + } // Next fetch must bypass provider-inner usage caches (agy retrieveUserQuota / // weekly are 60s–5min). Without this, dropping the 60s wrapper recaches stale. // TTL matches those inner caches: after 5min the flag is a no-op. - pendingForceRefresh.set(key, Date.now()); - pendingForceRefreshMiss.delete(key); + pendingForceRefresh.set(forceKey, Date.now()); + pendingForceRefreshMiss.delete(forceKey); } /** @@ -415,3 +495,15 @@ export function registerGenericQuotaFetchers(): void { registerQuotaFetcher(provider, fetchGenericQuota); } } + +export const __testing = { + setUsageFetcher(fetcher: UsageFetcher): void { + usageFetcherOverride = fetcher; + }, + resetUsageFetcher(): void { + usageFetcherOverride = null; + }, + clearCache(): void { + cache.clear(); + }, +}; diff --git a/open-sse/services/githubCopilotModels.ts b/open-sse/services/githubCopilotModels.ts index b7a87ffbf2..39d112f014 100644 --- a/open-sse/services/githubCopilotModels.ts +++ b/open-sse/services/githubCopilotModels.ts @@ -92,8 +92,15 @@ function toNonEmptyString(value: unknown): string | null { // (rename-robust) rather than an id allowlist: any model the account is entitled // to whose capabilities.type is "chat" (or that carries a chat-shaped // supported_endpoints) is kept, so a newly-entitled model shows up with no code -// change. Only explicitly non-chat rows (embeddings / completion) are dropped. +// change. Also filters out rows when policy.state is set and != "enabled", or +// when model_picker_enabled=false. Explicitly non-chat rows (embeddings / +// completion) are dropped as well. function isRoutableChatModel(item: RawRecord): boolean { + const policy = asRecord(item.policy); + const policyState = toNonEmptyString(policy.state); + if (policyState && policyState !== "enabled") return false; + if (item.model_picker_enabled === false) return false; + const capabilities = asRecord(item.capabilities); const capType = toNonEmptyString(capabilities.type); if (capType) return capType === "chat"; diff --git a/open-sse/services/tierConfig.ts b/open-sse/services/tierConfig.ts index 2119029e13..b02234a6b0 100644 --- a/open-sse/services/tierConfig.ts +++ b/open-sse/services/tierConfig.ts @@ -52,7 +52,6 @@ export const LEGACY_FREE_PROVIDERS: readonly string[] = [ "longcat", "cloudflare-ai", "nvidia-nim", - "cerebras", "groq", ]; diff --git a/open-sse/services/tierDefaults.json b/open-sse/services/tierDefaults.json index 5e1e4a23cf..1e74212f3b 100644 --- a/open-sse/services/tierDefaults.json +++ b/open-sse/services/tierDefaults.json @@ -19,7 +19,6 @@ "longcat", "cloudflare-ai", "nvidia-nim", - "cerebras", "groq" ] } diff --git a/open-sse/services/usage/claude.ts b/open-sse/services/usage/claude.ts index e205b473ea..ffa53c0f20 100644 --- a/open-sse/services/usage/claude.ts +++ b/open-sse/services/usage/claude.ts @@ -10,7 +10,7 @@ */ import { safePercentage } from "@/shared/utils/formatting"; -import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../../executors/claudeIdentity.ts"; +import { getClaudeCodeVersion, fetchClaudeBootstrap } from "../../executors/claudeIdentity.ts"; import { isClaudeOauthUsageCoolingDown, markClaudeOauthUsage429 } from "../claudeUsageCooldown.ts"; import { toRecord } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; @@ -71,7 +71,7 @@ export async function getClaudeUsage(accessToken?: string) { "Accept-Encoding": "gzip, compress, deflate, br", Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", - "User-Agent": `claude-code/${CLAUDE_CODE_VERSION}`, + "User-Agent": `claude-code/${getClaudeCodeVersion()}`, "anthropic-beta": "oauth-2025-04-20", }, signal: ctrl.signal, diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 8a67b07bc5..95fea6dcea 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -327,6 +327,123 @@ function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } +// Maps of schemas — the container itself is not a bare property map (#12269). +const SCHEMA_MAP_KEYS = new Set([ + "properties", + "$defs", + "definitions", + "patternProperties", + "dependentSchemas", +]); + +const SCHEMA_NODE_KEYS = new Set([ + "additionalItems", + "additionalProperties", + "contentSchema", + "contains", + "default", + "dependencies", + "dependentRequired", + "dependentSchemas", + "discriminator", + "else", + "example", + "examples", + "externalDocs", + "if", + "patternProperties", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + "xml", +]); + +function isSchemaNode(record: JsonRecord): boolean { + if (Object.keys(record).some((key) => key.startsWith("x-") || SCHEMA_NODE_KEYS.has(key))) { + return true; + } + if (typeof record.type === "string" || Array.isArray(record.type)) return true; + if (record.properties !== undefined || Array.isArray(record.required)) return true; + if (record.items !== undefined || record.prefixItems !== undefined) return true; + if (record.anyOf !== undefined || record.oneOf !== undefined || record.allOf !== undefined) { + return true; + } + if (record.not !== undefined || record.$ref !== undefined || record.enum !== undefined) { + return true; + } + return record.const !== undefined; +} + +function isBarePropertyMap(record: JsonRecord): boolean { + const keys = Object.keys(record); + if (keys.length === 0 || isSchemaNode(record)) return false; + return keys.every((key) => { + const value = record[key]; + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + }); +} + +function promoteBooleanRequired(record: JsonRecord): void { + const properties = toRecord(record.properties); + if (Object.keys(properties).length === 0) return; + + const required = Array.isArray(record.required) + ? record.required.filter((field): field is string => typeof field === "string") + : []; + + for (const [name, schema] of Object.entries(properties)) { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) continue; + const child = schema as JsonRecord; + if (child.required === true) { + if (!required.includes(name)) required.push(name); + } + if ("required" in child && !Array.isArray(child.required)) { + delete child.required; + } + } + + if (required.length > 0) { + record.required = required; + } else if (!Array.isArray(record.required)) { + delete record.required; + } +} + +// Pre-pass for Cloud Code (#12269): boolean `required` on a property and nested +// bare property maps both survive the later phases and 400 Gemini's proto. +// Mirrors CLIProxyAPI normalizeMalformedSchemaObjects. +function normalizeMalformedSchemaObjects(obj: unknown, parentKey?: string): void { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (const item of obj) { + normalizeMalformedSchemaObjects(item, parentKey); + } + return; + } + + const record = obj as JsonRecord; + if (parentKey === undefined || !SCHEMA_MAP_KEYS.has(parentKey)) { + if (isBarePropertyMap(record)) { + const props = { ...record }; + for (const key of Object.keys(record)) { + delete record[key]; + } + record.type = "object"; + record.properties = props; + } + } + + promoteBooleanRequired(record); + + for (const [key, value] of Object.entries(record)) { + if (value && typeof value === "object") { + normalizeMalformedSchemaObjects(value, key); + } + } +} + function decodeJsonPointerSegment(segment: unknown): string { return String(segment).replace(/~1/g, "/").replace(/~0/g, "~"); } @@ -627,6 +744,9 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { const root = cloneSchemaValue(schema); let cleaned = inlineLocalSchemaRefs(root, root); + // Phase 0: #12269 malformed skill/tool schemas (boolean required, bare maps). + normalizeMalformedSchemaObjects(cleaned); + // Phase 1: Convert and prepare convertConstToEnum(cleaned); convertEnumValuesToStrings(cleaned); diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index a2244f01f5..43453c0b28 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -5,6 +5,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; +import { projectCompletedStreamError } from "../../utils/streamErrorFormat.ts"; import { fallbackToolCallId } from "../helpers/toolCallHelper.ts"; import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts"; import { getReadableReasoningValue } from "../../utils/reasoningFields.ts"; @@ -746,6 +747,7 @@ function sendCompleted(state, emit) { // translator or the OpenAI-Responses translator itself when the upstream // SSE stream emits a JSON error object after partial content. const upstreamErr = state.upstreamError; + const publicUpstreamError = projectCompletedStreamError(upstreamErr); const response: Record = { id: state.responseId, @@ -753,9 +755,7 @@ function sendCompleted(state, emit) { created_at: state.created, status: upstreamErr ? "failed" : "completed", background: false, - error: upstreamErr - ? { code: String(upstreamErr.status ?? ""), message: upstreamErr.message ?? "" } - : null, + error: publicUpstreamError, output, }; diff --git a/open-sse/utils/credentialPatterns.ts b/open-sse/utils/credentialPatterns.ts new file mode 100644 index 0000000000..02784a4ae5 --- /dev/null +++ b/open-sse/utils/credentialPatterns.ts @@ -0,0 +1,79 @@ +/** Pure credential signatures shared by guardrails and public error sanitization. */ +export interface CredentialPattern { + name: string; + regex: RegExp; + replacement: string; +} + +export const CREDENTIAL_PATTERNS: CredentialPattern[] = [ + { name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" }, + { name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" }, + { + name: "anthropic", + regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g, + replacement: "[REDACTED:anthropic]", + }, + { + name: "anthropic_alt", + regex: /sk-ant-[A-Za-z0-9_-]{20,}/g, + replacement: "[REDACTED:anthropic]", + }, + { name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" }, + { name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" }, + { name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" }, + { name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" }, + { name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" }, + { name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" }, + { name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" }, + { name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" }, + { + name: "postman", + regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g, + replacement: "[REDACTED:postman]", + }, + { + name: "discord", + regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g, + replacement: "[REDACTED:discord]", + }, + { + name: "stripe", + regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g, + replacement: "[REDACTED:stripe]", + }, + { + name: "square", + regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g, + replacement: "[REDACTED:square]", + }, + { name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" }, + { name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" }, + { + name: "sendgrid", + regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, + replacement: "[REDACTED:sendgrid]", + }, + { name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" }, + { + name: "private_key", + regex: + /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g, + replacement: "[REDACTED:private_key]", + }, + { + name: "jwt", + regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, + replacement: "[REDACTED:jwt]", + }, + { + name: "connection_string", + regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g, + replacement: "[REDACTED:connection_string]", + }, + { + name: "auth_header", + regex: + /((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi, + replacement: "$1[REDACTED:auth_header]", + }, +]; diff --git a/open-sse/utils/diagnostics.ts b/open-sse/utils/diagnostics.ts index 06f7a0a0b6..ebee72e1e8 100644 --- a/open-sse/utils/diagnostics.ts +++ b/open-sse/utils/diagnostics.ts @@ -323,8 +323,14 @@ export function describeMalformedNonStream( ): { message: string; code: string; type: string } { const body = resp && typeof resp === "object" ? (resp as Record) : null; if (body?.object === "response" && body.status === "failed") { + const err = body.error && typeof body.error === "object" ? (body.error as Record) : null; + const rawMessage = + typeof err?.message === "string" && err.message.trim().length > 0 ? err.message.trim() : null; return { - message: "upstream reported a failed response without usable output", + // Trim only here; buildErrorBody (chatCore) does the single sanitization pass. + message: rawMessage + ? `upstream reported a failed response: ${rawMessage}` + : "upstream reported a failed response without usable output", code: "upstream_response_failed", type: "upstream_response_error", }; diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 66563c618f..5d7357eb1c 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1,15 +1,18 @@ import { CORS_HEADERS } from "./cors.ts"; import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts"; +import { + redactSensitiveErrorText, + sanitizeErrorMessage, + sanitizeUpstreamDetails, +} from "./errorSanitization.ts"; import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts"; -/** - * Sanitize an error message to prevent stack trace exposure in API responses. - * Strips stack traces, file paths, and absolute Windows/POSIX paths from - * error messages before they reach the client. - */ +export { redactSensitiveErrorText, sanitizeErrorMessage, sanitizeUpstreamDetails }; + +/** Client-visible error shape; dynamic fields are projected through canonical boundaries. */ interface ErrorResponseBody { error: { message: string; @@ -20,119 +23,6 @@ interface ErrorResponseBody { upstream_details?: Record | null; // sanitized upstream provider body } -// Length cap protects against pathological inputs even before tokenization. -const MAX_ERROR_LEN = 4096; -const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] as const; - -function looksLikeAbsolutePath(tok: string): boolean { - // POSIX: "/<...>.ts" (optionally followed by :line[:col]). - // Windows: "C:\<...>.ts" or "C:/<...>.ts". - if (tok.length < 4 || tok.length > 2048) return false; - const isPosix = tok.charCodeAt(0) === 0x2f; // '/' - const isWindows = tok.length > 2 && tok.charCodeAt(1) === 0x3a && /[A-Za-z]/.test(tok[0]); - if (!isPosix && !isWindows) return false; - const dot = tok.lastIndexOf("."); - if (dot <= 0 || dot === tok.length - 1) return false; - const ext = tok - .slice(dot + 1) - .split(":", 1)[0] - .toLowerCase(); - return (SOURCE_EXT as readonly string[]).includes(ext); -} - -/** - * Raw credential shapes that carry no `key=` label to key off — the token IS the - * whole match, so the only way to redact them is to recognize the shape. - * - * GHSA-qv45-56jc-4wmj: `upstreamErrorPassthrough.ts` already recognized `sk-` - * and refused verbatim passthrough for bodies containing it, then handed those - * bodies to THIS sanitizer — which had no such pattern, so the key came back to - * the caller anyway. The passthrough file's comment claimed to "mirror the - * vocabulary of redactSensitiveErrorText"; the mirror had drifted. It now - * imports this array instead of keeping a second copy, so the two cannot drift - * again. - * - * Quantifiers are upper-bounded (AGENTS.md → PII learnings §1, ReDoS): these run - * over untrusted upstream error bodies. - */ -export const RAW_CREDENTIAL_PATTERNS: ReadonlyArray = [ - // OpenAI/Anthropic/Stripe-style secret keys: sk-…, sk-ant-…, sk_live_… - /\bsk[-_][A-Za-z0-9._-]{8,200}/g, - // Google API keys - /\bAIza[A-Za-z0-9_-]{20,200}/g, - // JWTs (three base64url segments) - /\beyJ[A-Za-z0-9_-]{8,400}\.[A-Za-z0-9_-]{8,800}\.[A-Za-z0-9_-]{8,800}/g, -]; - -export function redactSensitiveErrorText(value: string): string { - let out = value; - for (const pattern of RAW_CREDENTIAL_PATTERNS) { - out = out.replace(pattern, "[REDACTED_CREDENTIAL]"); - } - return out - .replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]") - .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") - .replace( - /(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["'])[^"']*(["'])/gi, - "$1[REDACTED]$2" - ) - .replace( - /(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*)[^"',\s}]+/gi, - "$1[REDACTED]" - ); -} - -/** - * Strip stack-trace tail and absolute source paths from error messages. - * - * Implemented via simple whitespace tokenization (linear time) instead of a - * single complex regex, so CodeQL `js/polynomial-redos` stays clean even when - * the runtime error message is attacker-controlled. - */ -export function sanitizeErrorMessage(message: unknown): string { - let str = typeof message === "string" ? message : String(message ?? ""); - if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN); - const nl = str.indexOf("\n"); - const firstLine = nl >= 0 ? str.slice(0, nl) : str; - // Preserve original whitespace by splitting on captured separator. - const parts = firstLine.split(/(\s+)/); - for (let i = 0; i < parts.length; i++) { - if (looksLikeAbsolutePath(parts[i])) parts[i] = ""; - } - return redactSensitiveErrorText(parts.join("")); -} - -const BLOCKED_KEYS = - /stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie/i; -const MAX_DEPTH = 4; - -/** - * Recursively sanitize an arbitrary JSON value from an upstream provider body. - * - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths). - * - Keys matching BLOCKED_KEYS are dropped (credential/path guards). - * - Depth capped at MAX_DEPTH to prevent pathological nesting. - * - Arrays capped at 32 elements. - * - Returns null for null/undefined/non-JSON-serializable values. - */ -export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown { - if (depth > MAX_DEPTH) return "[truncated]"; - if (value === null || value === undefined) return null; - if (typeof value === "string") return sanitizeErrorMessage(value); - if (typeof value === "number" || typeof value === "boolean") return value; - if (Array.isArray(value)) { - return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1)); - } - if (typeof value === "object") { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - if (BLOCKED_KEYS.test(k)) continue; - out[k] = sanitizeUpstreamDetails(v, depth + 1); - } - return out; - } - return null; -} - /** Optional caller classification; when set, wins over status-derived defaults. */ export type ErrorBodyClassification = { type?: string; @@ -140,6 +30,279 @@ export type ErrorBodyClassification = { reason?: string; }; +const PUBLIC_ERROR_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SAFE_PUBLIC_ERROR_IDENTIFIERS = new Set([ + "abort", + "aborted", + "account_semaphore_capacity", + "acp_cancelled", + "acp_early_exit", + "acp_error", + "acp_output_too_large", + "acp_session_mismatch", + "acp_timeout", + "admission_aborted", + "admission_deadline", + "admission_lane_evicted", + "admission_oversized", + "admission_queue_full", + "admission_shutdown", + "admission_unavailable", + "all_accounts_inactive", + "all_targets_skipped", + "antigravity_pre_response_timeout", + "api_error", + "authentication_error", + "authentication_required", + "auth_error", + "bad_gateway", + "bad_request", + "bedrock_stream_error", + "billing_error", + "blackbox_auth_required", + "blackbox_rate_limit", + "blackbox_subscription_required", + "body_exceeds_budget", + "browser_stream_inconsistent", + "capability_mismatch", + "cf_mitigated_challenge", + "chat_admission_busy", + "chat_history_too_large", + "chatgpt_web_codex_error", + "chatgpt_web_codex_turn_failed", + "chatgpt_session_expired", + "chatgpt_submission_ambiguous", + "chatgpt_submitted_turn_failed", + "chatgpt_subscription_unavailable", + "client_cancelled", + "client_closed_request", + "client_disconnected", + "cli_not_found", + "cloudflare_challenge", + "cloudflare_or_bot", + "codex_app_server_unconfigured", + "codex_app_server_turn_failed", + "combo_target_timeout", + "combo_timeout", + "compaction_control_unavailable", + "compaction_handoff_failed", + "connector_error", + "connector_not_found", + "connection_error", + "context_length_exceeded", + "context_window", + "chipotle_error", + "devin_agentic_error", + "devin_cli_error", + "devin_desktop_error", + "devin_internal_tool_execution", + "duplicate_tool_use_id", + "direct_response_start_timeout", + "eai_again", + "econnrefused", + "econnreset", + "empty_acp_output", + "empty_content", + "empty_messages", + "empty_response", + "executor_contract_violation", + "error", + "etimedout", + "executor_error", + "feature_disabled", + "gateway_timeout", + "gemini_tpm_exhausted", + "gcp_project_required", + "grok_error", + "insufficient_quota", + "incompatible_reasoning_effort", + "internal_server_error", + "invalid_acp_frame", + "invalid_acp_upstream", + "invalid_api_key", + "invalid_kiro_tool_call", + "invalid_request", + "invalid_request_error", + "invalid_previous_response_binding", + "invalid_tool_arguments", + "invalid_tool_choice", + "invalid_tool_json", + "invalid_tool_name", + "invalid_tools", + "invalid_trailer", + "lease_action_invalid", + "lease_api_key_invalid", + "lease_authentication_required", + "lease_authorization_mismatch", + "lease_capacity_unavailable", + "lease_connection_mismatch", + "lease_content_type_required", + "lease_context_invalid", + "lease_context_required", + "lease_error", + "lease_fence_stale", + "lease_key_configuration_invalid", + "lease_key_policy_invalid", + "lease_model_invalid", + "lease_no_eligible_connection", + "lmarena_error", + "lease_required", + "lease_scope_required", + "lease_service_unavailable", + "lease_eligibility_unavailable", + "lease_unsupported_route", + "lease_unsupported_transport", + "message_limit", + "missing_credits", + "meta_ai_empty_response", + "meta_ai_mode_switch_failed", + "meta_ai_warmup_failed", + "meta_ai_ws_error", + "missing_tool_name", + "missing_tool_use_id", + "mixed_tool_narrative", + "missing_authorization", + "missing_cookie", + "missing_project_id", + "missing_credentials", + "missing_session_id", + "model_not_found", + "model_not_supported", + "model_shutdown", + "multipart_protocol_violation", + "multiple_tool_requests", + "native_codex_pinned_model_unavailable", + "network_error", + "no_free_eligible_connection", + "not_found", + "oauth_missing_project_id", + "orphan_tool_result", + "payload_too_large", + "payment_required", + "permission_error", + "premium_model_requires_key", + "prompt_attachment_integrity", + "provider_error", + "provider_retired", + "provider_unavailable", + "pplx_error", + "proxy_unavailable", + "proxy_family_unavailable", + "proxy_request_failed", + "proxy_unreachable", + "quota_exhausted", + "quota_not_allocated", + "quota_only", + "rate_limit_error", + "rate_limit_execution_timeout", + "rate_limit_exceeded", + "rate_limit_queue_full", + "rate_limit_queue_timeout", + "rate_limit_queue_wedged", + "rate_limit_longer_reached", + "rate_limit_reached", + "rate_limited", + "reached_limit", + "relay_timeout", + "resource_pressure", + "resource_exhausted", + "request_failed", + "risk_session_stale", + "server_error", + "semaphore_queue_full", + "semaphore_timeout", + "service_unavailable", + "service_not_running", + "session_expired", + "session_pool_exhausted", + "spawn_failed", + "stream_error", + "stream_disconnected", + "stream_early_eof", + "stream_idle_timeout", + "stream_pipeline_error", + "stream_readiness_timeout", + "stream_terminated", + "stream_timeout", + "storage_encryption_stale", + "structure_limit", + "structured_output", + "structured_output_validation_failed", + "timeout_error", + "timeout", + "token_limit_exceeded", + "token_required", + "tls_client_unavailable", + "tls_circuit_open", + "tls_fingerprint_failed", + "tls_session_capacity", + "tool_calling_not_supported", + "tools", + "undeclared_historical_tool", + "und_err_body_timeout", + "und_err_connect_timeout", + "und_err_headers_timeout", + "und_err_socket", + "unexpected_acp_response", + "unexecuted_tool_intent", + "unavailable", + "unknown_devin_model", + "unknown_tool", + "unverified_codex_client", + "unsafe_devin_home", + "unsupported_acp_version", + "unsupported_content_block", + "unsupported_control_for_provider", + "unsupported_endpoint", + "unsupported_image_block", + "unsupported_role", + "unsupported_system_block", + "upstream_error", + "upstream_access_denied", + "upstream_auth_error", + "upstream_empty_response", + "upstream_response_failed", + "upstream_response_error", + "upstream_server_error", + "upstream_protocol_error", + "upstream_timeout", + "upstream_websocket_connect_failed", + "upstream_websocket_error", + "usage_limit_reached", + "unsupported_feature", + "unsupported_runtime", + "video_artifact_content_type_invalid", + "video_artifact_download_failed", + "video_artifact_not_ready", + "video_artifact_signature_invalid", + "video_artifact_too_large", + "video_artifact_unavailable", + "video_artifact_url_blocked", + "video_artifact_url_invalid", + "vision", + "claude_web_protocol_error", + "wreq_unavailable", +]); + +function isSafePublicErrorIdentifier(value: string): boolean { + if (!PUBLIC_ERROR_IDENTIFIER.test(value)) return false; + if (/^[1-5]\d{2}$/.test(value)) return true; + if (/^HTTP_[1-5]\d{2}$/i.test(value)) return true; + return SAFE_PUBLIC_ERROR_IDENTIFIERS.has(value.toLowerCase()); +} + +/** Project an internal classification onto the bounded client-visible identifier vocabulary. */ +export function projectPublicErrorIdentifier(value: unknown, fallback: unknown): string { + const safeFallback = + fallback === "" + ? "" + : typeof fallback === "string" && isSafePublicErrorIdentifier(fallback) + ? fallback + : "error"; + if (typeof value !== "string") return safeFallback; + return isSafePublicErrorIdentifier(value) ? value : safeFallback; +} + /** * Build OpenAI-compatible error response body. Message is always sanitized * so callers do not need to remember to strip stack traces themselves. @@ -156,13 +319,17 @@ export function buildErrorBody( ): ErrorResponseBody { const errorInfo = getErrorInfo(statusCode); const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode); + const safeReason = + typeof classification?.reason === "string" && isSafePublicErrorIdentifier(classification.reason) + ? classification.reason + : undefined; const body: ErrorResponseBody = { error: { message: safeMessage, - type: classification?.type ?? errorInfo.type, - code: classification?.code ?? errorInfo.code, - reason: classification?.reason, + type: projectPublicErrorIdentifier(classification?.type, errorInfo.type), + code: projectPublicErrorIdentifier(classification?.code, errorInfo.code), + reason: safeReason, }, }; @@ -211,7 +378,7 @@ export interface ComboRecoveryHint { action: ComboRecoveryAction; /** Seconds the client should wait before retrying. Only meaningful when action="wait". */ retry_after_seconds?: number; - /** Human-readable next step — included verbatim in the error body for non-MCP clients. */ + /** Human-readable next step — sanitized and length-capped for non-MCP clients. */ next_step: string; } @@ -231,21 +398,36 @@ export interface ComboDiagnostics { } function clampDiagStr(v: unknown, max = 128): string { - return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : ""; + return typeof v === "string" ? sanitizeErrorMessage(v).slice(0, max) : ""; +} + +const RECOVERY_ROUTE_PLACEHOLDERS = [ + ["/dashboard/providers", "OMNIROUTE_SAFE_DASHBOARD_PROVIDERS_ROUTE"], +] as const; + +function clampRecoveryStr(value: unknown, max: number): string { + if (typeof value !== "string") return ""; + let projected = value; + for (const [route, placeholder] of RECOVERY_ROUTE_PLACEHOLDERS) { + projected = projected.replaceAll(route, placeholder); + } + projected = sanitizeErrorMessage(projected); + for (const [route, placeholder] of RECOVERY_ROUTE_PLACEHOLDERS) { + projected = projected.replaceAll(placeholder, route); + } + return projected.slice(0, max); } /** - * HTTP header values must be Latin1/ByteString (undici throws a TypeError - * otherwise — see #6612). Replace any codepoint outside the Latin1 range - * (0-255) with "?" so header construction never throws. Only used for the - * literal header value; the JSON body keeps the original, unsanitized - * readable text via `sanitizeComboDiagnostics`. + * HTTP header values must exclude controls and remain ByteString-compatible + * (undici throws a TypeError otherwise — see #6612). Replace every codepoint + * outside printable ASCII with "?" so header construction never throws. */ function toHeaderSafeAscii(v: string): string { let out = ""; for (let i = 0; i < v.length; i++) { const code = v.charCodeAt(i); - out += code > 255 ? "?" : v[i]; + out += code < 0x20 || code > 0x7e ? "?" : v[i]; } return out; } @@ -270,7 +452,7 @@ export function sanitizeRecoveryHint( if (!action || !RECOVERY_ACTIONS.has(action)) return undefined; // Reject empty OR whitespace-only next_step — the value must render usefully as a // header and as a body field. A whitespace-only string would print as a blank hint. - const next_step = clampDiagStr(r.next_step, 200).trim(); + const next_step = clampRecoveryStr(r.next_step, 200).trim(); if (!next_step) return undefined; const hint: ComboRecoveryHint = { action, next_step }; if (typeof r.retry_after_seconds === "number" && Number.isFinite(r.retry_after_seconds)) { @@ -321,12 +503,10 @@ export function errorResponseWithComboDiagnostics( opts: { code?: string; type?: string } = {} ): Response { const safe = sanitizeComboDiagnostics(diagnostics); - const body = buildErrorBody(statusCode, message) as ErrorResponseBody & { + const body = buildErrorBody(statusCode, message, undefined, opts) as ErrorResponseBody & { diagnostics?: ComboDiagnostics; recovery_hint?: ComboRecoveryHint; }; - if (opts.code) body.error.code = opts.code; - if (opts.type) body.error.type = opts.type; body.diagnostics = safe; if (safe.recovery) body.recovery_hint = safe.recovery; const excludedHeader = toHeaderSafeAscii( @@ -427,6 +607,29 @@ function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null): return 1; } +const MAX_PUBLIC_CONTEXT_LABEL_LENGTH = 256; + +function projectPublicContextLabel(value: unknown): string | null { + if (typeof value !== "string") return null; + const label = value.trim(); + if ( + label.length === 0 || + label.length > MAX_PUBLIC_CONTEXT_LABEL_LENGTH || + /[\u0000-\u001f\u007f]/.test(label) + ) { + return null; + } + return sanitizeErrorMessage(label) === label ? label : null; +} + +function projectPublicRetryTimestamp(value: unknown): string | null { + if (typeof value !== "string") return null; + const timestamp = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp)) return null; + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) && new Date(parsed).toISOString() === timestamp ? timestamp : null; +} + /** * Parse Antigravity error message to extract retry time * Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s." @@ -470,7 +673,7 @@ export function parseAntigravityRetryTime(message: unknown): number | null { * @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null, responseBody: unknown}>} */ export async function parseUpstreamError(response: Response, provider: string | null = null) { - let message: unknown = ""; + let message = ""; let retryAfterMs: number | null = null; let responseBody: unknown = null; let errorCode: unknown = undefined; @@ -490,9 +693,15 @@ export async function parseUpstreamError(response: Response, provider: string | // stack) — still routed through sanitizeErrorMessage/buildErrorBody by // every consumer below (Rule #12). const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider); - message = clinepassEnvError + const extractedMessage = clinepassEnvError ? clinepassEnvError.message - : json.error?.message || json.message || json.error || text; + : json.error?.message || + json.message || + (typeof json.error === "string" ? json.error : null); + message = + typeof extractedMessage === "string" + ? extractedMessage + : `Upstream error: ${response.status}`; errorCode = json.error?.code || json.code; errorType = json.error?.type || json.type; } catch { @@ -503,7 +712,7 @@ export async function parseUpstreamError(response: Response, provider: string | responseBody = { _rawText: message }; } - const messageStr = typeof message === "string" ? message : JSON.stringify(message); + const messageStr = message; const retryAfterHeader = response.headers?.get?.("retry-after"); if (retryAfterHeader && !retryAfterMs) { @@ -573,13 +782,10 @@ export function createErrorResult( upstreamDetails?: unknown, opts?: { passthrough?: boolean } ) { - const body = buildErrorBody(statusCode, message, upstreamDetails); - if (errorCode) { - body.error.code = errorCode; - } - if (errorType) { - body.error.type = errorType; - } + const body = buildErrorBody(statusCode, message, upstreamDetails, { + code: errorCode, + type: errorType, + }); const result: { success: false; @@ -619,8 +825,8 @@ export function createErrorResult( result.retryAfterMs = retryAfterMs; } - // Opt-in relay of the verbatim upstream error body (Claude Code auto-recover - // contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`; + // Opt-in relay of the recursively sanitized upstream JSON shape (Claude Code + // auto-recover contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`; // `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so // server-side classification (checkFallbackError, combo retry logic, etc.) // never sees a different value depending on this flag. @@ -653,7 +859,9 @@ export function unavailableResponse( retryAfterHuman?: string ) { const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); - const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message; + const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode); + const safeRetryAfterHuman = retryAfterHuman ? sanitizeErrorMessage(retryAfterHuman) : ""; + const msg = safeRetryAfterHuman ? `${safeMessage} (${safeRetryAfterHuman})` : safeMessage; return new Response(JSON.stringify({ error: { message: msg } }), { status: statusCode, headers: { @@ -668,13 +876,14 @@ export function providerCircuitOpenResponse( retryAfter?: string | number | Date | null ) { const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); + const safeProvider = projectPublicContextLabel(provider) ?? "unknown"; return new Response( JSON.stringify({ error: { - message: `Provider ${provider} circuit breaker is open`, + message: `Provider ${safeProvider} circuit breaker is open`, type: "server_error", code: "provider_circuit_open", - provider, + provider: safeProvider, retry_after: retryAfterSec, }, }), @@ -700,9 +909,10 @@ export function buildModelCooldownBody({ retryAfterAt?: string | null; credentialsCoolingCount?: number | null; }): ModelCooldownErrorPayload { - const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null; - const resolvedRetryAfterAt = - typeof retryAfterAt === "string" && retryAfterAt.length > 0 ? retryAfterAt : null; + const resolvedModel = projectPublicContextLabel(model); + const resolvedRetryAfterAt = projectPublicRetryTimestamp(retryAfterAt); + const resolvedResetSeconds = + Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? Math.max(Math.ceil(retryAfterSec), 1) : 1; const resolvedCoolingCount = typeof credentialsCoolingCount === "number" && Number.isFinite(credentialsCoolingCount) && @@ -718,7 +928,7 @@ export function buildModelCooldownBody({ type: "rate_limit_error", code: "model_cooldown", ...(resolvedModel ? { model: resolvedModel } : {}), - reset_seconds: Math.max(Math.ceil(retryAfterSec), 1), + reset_seconds: resolvedResetSeconds, ...(resolvedRetryAfterAt ? { retry_after: resolvedRetryAfterAt } : {}), ...(resolvedCoolingCount ? { credentials_cooling: resolvedCoolingCount } : {}), }, diff --git a/open-sse/utils/errorPathRedaction.ts b/open-sse/utils/errorPathRedaction.ts new file mode 100644 index 0000000000..2b372af583 --- /dev/null +++ b/open-sse/utils/errorPathRedaction.ts @@ -0,0 +1,905 @@ +const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"] as const; +const NATIVE_EXT = ["node", "so", "dylib", "dll"] as const; +const LEADING_PATH_PUNCTUATION = "'\"`([{<"; +const TRAILING_PATH_PUNCTUATION = "'\"`)]}>.,;:!?"; +const PATH_SPAN_END_PUNCTUATION = "'\"`)]}>.,;:!?"; +const FILE_URI_PREFIX = "file://"; +const HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + "HEAD", + "CONNECT", + "TRACE", +] as const; +const CLEAR_PROSE_BOUNDARIES = [ + "after", + "because", + "before", + "but", + "crashed", + "denied", + "eacces", + "enoent", + "expired", + "failed", + "rejected", + "retry", + "then", + "when", + "while", +] as const; +const POSIX_FILESYSTEM_ROOTS = [ + "/Users", + "/app", + "/boot", + "/data", + "/dev", + "/etc", + "/home", + "/media", + "/mnt", + "/nix", + "/opt", + "/private", + "/proc", + "/root", + "/run", + "/srv", + "/sys", + "/tmp", + "/usr", + "/var", + "/workspace", +] as const; +const WINDOWS_ROOT_RELATIVE_ROOTS = new Set([ + "program files", + "programdata", + "temp", + "users", + "windows", +]); + +function isWindowsAbsolutePathAt(value: string, start: number): boolean { + const remaining = value.length - start; + if (remaining > 2) { + const first = value.charCodeAt(start); + const second = value.charCodeAt(start + 1); + if ((first === 0x5c && second === 0x5c) || (first === 0x2f && second === 0x2f)) { + return true; + } + } + if (remaining < 3 || value.charCodeAt(start + 1) !== 0x3a) return false; + const driveLetter = value.charCodeAt(start); + const isAsciiLetter = + (driveLetter >= 0x41 && driveLetter <= 0x5a) || (driveLetter >= 0x61 && driveLetter <= 0x7a); + return ( + isAsciiLetter && (value.charCodeAt(start + 2) === 0x2f || value.charCodeAt(start + 2) === 0x5c) + ); +} + +function isWindowsAbsolutePath(value: string): boolean { + return isWindowsAbsolutePathAt(value, 0); +} + +function isWindowsRootRelativePathAt(value: string, start: number): boolean { + if ( + value.charCodeAt(start) !== 0x5c || + value.charCodeAt(start + 1) === 0x5c || + isWhitespace(value[start + 1]) + ) { + return false; + } + + const tokenEnd = findTokenEnd(value, start); + let firstSeparator = start + 1; + while (firstSeparator < tokenEnd && value.charCodeAt(firstSeparator) !== 0x5c) { + firstSeparator++; + } + const root = value.slice(start + 1, firstSeparator).toLowerCase(); + if (WINDOWS_ROOT_RELATIVE_ROOTS.has(root)) return true; + return ( + firstSeparator < tokenEnd - 1 || tokenContainsPathExtensionEvidence(value, start + 1, tokenEnd) + ); +} + +function hasAbsoluteFileUriAt(value: string, start: number): boolean { + const prefixEnd = start + FILE_URI_PREFIX.length; + return ( + value.length > prefixEnd && + value.slice(start, prefixEnd).toLowerCase() === FILE_URI_PREFIX && + !isWhitespace(value[prefixEnd]) + ); +} + +function hasAbsoluteFileUri(value: string): boolean { + return hasAbsoluteFileUriAt(value, 0); +} + +function isSyntacticallyAbsolutePathAt(value: string, start: number): boolean { + return ( + value.charCodeAt(start) === 0x2f || + isWindowsAbsolutePathAt(value, start) || + isWindowsRootRelativePathAt(value, start) || + hasAbsoluteFileUriAt(value, start) + ); +} + +function isAsciiDigit(code: number): boolean { + return code >= 0x30 && code <= 0x39; +} + +function isAsciiLetter(code: number): boolean { + return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); +} + +function isAsciiAlphaNumeric(code: number): boolean { + return isAsciiDigit(code) || isAsciiLetter(code); +} + +function hasHttpUrlSchemeBefore(value: string, slashIndex: number): boolean { + for (const scheme of ["http:", "https:"]) { + const schemeStart = slashIndex - scheme.length; + if (schemeStart < 0 || value.slice(schemeStart, slashIndex).toLowerCase() !== scheme) continue; + if (schemeStart === 0 || !isAsciiAlphaNumeric(value.charCodeAt(schemeStart - 1))) return true; + } + return false; +} + +function isWhitespace(value: string): boolean { + return /\s/.test(value); +} + +function isRouteContextWord(value: string): boolean { + return value === "Route" || (HTTP_METHODS as readonly string[]).includes(value); +} + +function hasRouteContextBefore(value: string, candidateIndex: number): boolean { + let index = candidateIndex - 1; + while ( + index >= 0 && + (isWhitespace(value[index]) || + value.charCodeAt(index) === 0x28 || + value.charCodeAt(index) === 0x3a) + ) { + index--; + } + + const contextEnd = index + 1; + while (index >= 0 && isAsciiAlphaNumeric(value.charCodeAt(index))) index--; + return isRouteContextWord(value.slice(index + 1, contextEnd)); +} + +function isRouteContextToken(value: string): boolean { + let end = value.length; + while (end > 0 && !isAsciiAlphaNumeric(value.charCodeAt(end - 1))) end--; + let start = end; + while (start > 0 && isAsciiAlphaNumeric(value.charCodeAt(start - 1))) start--; + return isRouteContextWord(value.slice(start, end)); +} + +function matchesPosixFilesystemRootAt(value: string, start: number, root: string): boolean { + if (!value.startsWith(root, start)) return false; + const rootEnd = start + root.length; + return ( + rootEnd === value.length || + value.charCodeAt(rootEnd) === 0x2f || + PATH_SPAN_END_PUNCTUATION.includes(value[rootEnd]) + ); +} + +function isKnownPosixFilesystemPathAt(value: string, start: number): boolean { + return POSIX_FILESYSTEM_ROOTS.some((root) => matchesPosixFilesystemRootAt(value, start, root)); +} + +function isKnownPosixFilesystemPath(value: string): boolean { + return isKnownPosixFilesystemPathAt(value, 0); +} + +function looksLikeAbsolutePath(token: string): boolean { + // POSIX: common filesystem roots, with or without a source extension. + // Windows: drive-letter, UNC, or extended-length absolute paths. + // Source-file paths rooted elsewhere remain covered by SOURCE_EXT below. + if (token.length < 4 || token.length > 2048) return false; + const isPosix = token.charCodeAt(0) === 0x2f; + const isWindows = isWindowsAbsolutePath(token) || isWindowsRootRelativePathAt(token, 0); + if (!isPosix && !isWindows) return false; + if (isWindows) return true; + if (isKnownPosixFilesystemPath(token)) return true; + const dot = token.lastIndexOf("."); + if (dot <= 0 || dot === token.length - 1) return false; + const extension = token + .slice(dot + 1) + .split(":", 1)[0] + .toLowerCase(); + return ( + (SOURCE_EXT as readonly string[]).includes(extension) || + (NATIVE_EXT as readonly string[]).includes(extension) + ); +} + +function redactAbsolutePathToken(token: string, followsRouteContext: boolean): string { + let start = 0; + let end = token.length; + + while (start < end && LEADING_PATH_PUNCTUATION.includes(token[start])) start++; + while (end > start && TRAILING_PATH_PUNCTUATION.includes(token[end - 1])) end--; + + const candidate = token.slice(start, end); + const isFileUri = hasAbsoluteFileUri(candidate); + const pathCandidate = isFileUri ? candidate.slice(FILE_URI_PREFIX.length) : candidate; + + if ( + !isFileUri && + !isWindowsAbsolutePath(pathCandidate) && + !isWindowsRootRelativePathAt(pathCandidate, 0) && + pathCandidate.charCodeAt(0) === 0x2f && + followsRouteContext + ) { + return token; + } + if (!isFileUri && !looksLikeAbsolutePath(pathCandidate)) return token; + return `${token.slice(0, start)}${token.slice(end)}`; +} + +function findPathQuote(value: string, start: number, quote: string, takeFirst: boolean): number { + let candidate = value.indexOf(quote, start); + if (takeFirst || candidate < 0) return candidate < 0 ? value.length : candidate; + + while (candidate < value.length) { + const nextQuote = value.indexOf(quote, candidate + 1); + if (nextQuote < 0) return candidate; + // Two separately quoted absolute paths are unambiguous. Close the first + // candidate so the second one is scanned on its own; otherwise keep + // consuming quotes fail-closed because POSIX filenames may contain them. + if (isSyntacticallyAbsolutePathAt(value, nextQuote + 1)) return candidate; + candidate = nextQuote; + } + return value.length; +} + +function redactQuotedAbsolutePaths(value: string): string { + const parts: string[] = []; + let copyStart = 0; + let index = 0; + + while (index < value.length) { + const quote = value[index]; + if (quote !== "'" && quote !== '"' && quote !== "`") { + index++; + continue; + } + const candidateStart = index + 1; + if (!isSyntacticallyAbsolutePathAt(value, candidateStart)) { + index++; + continue; + } + + const isShieldedRoute = + value.charCodeAt(candidateStart) === 0x2f && + !isWindowsAbsolutePathAt(value, candidateStart) && + hasRouteContextBefore(value, index); + // Route/API contexts use their first closing quote so a later quoted + // filesystem path is still scanned independently. Filesystem candidates + // take the last matching quote on the line: POSIX filenames may themselves + // contain quote characters, whitespace, and punctuation, so earlier + // matches are ambiguous and must fail closed rather than expose a suffix. + const closingQuote = findPathQuote(value, candidateStart, quote, isShieldedRoute); + if (isShieldedRoute) { + if (closingQuote >= value.length) break; + index = closingQuote + 1; + continue; + } + parts.push(value.slice(copyStart, candidateStart), ""); + copyStart = closingQuote; + + if (closingQuote >= value.length) break; + index = closingQuote + 1; + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +function findPathExtensionEnd(value: string, dot: number): number { + let end = dot + 1; + const maxExtensionEnd = Math.min(value.length, end + 16); + while (end < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end))) end++; + if (end === dot + 1 || (end === maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(end)))) { + return -1; + } + let hasLetter = false; + for (let index = dot + 1; index < end; index++) { + if (isAsciiLetter(value.charCodeAt(index))) hasLetter = true; + } + if (!hasLetter) return -1; + + while (value.charCodeAt(end) === 0x3a) { + let coordinateEnd = end + 1; + if (!isAsciiDigit(value.charCodeAt(coordinateEnd))) break; + while (coordinateEnd < value.length && isAsciiDigit(value.charCodeAt(coordinateEnd))) { + coordinateEnd++; + } + end = coordinateEnd; + } + + if ( + end === value.length || + isWhitespace(value[end]) || + PATH_SPAN_END_PUNCTUATION.includes(value[end]) + ) { + return end; + } + return -1; +} + +function findTokenEnd(value: string, start: number): number { + let end = start; + while (end < value.length && !isWhitespace(value[end])) end++; + return end; +} + +function findExtensionEndInToken(value: string, start: number, end: number): number { + let lastExtensionEnd = -1; + for (let index = start; index < end; index++) { + const code = value.charCodeAt(index); + if (code === 0x2f || code === 0x5c) { + lastExtensionEnd = -1; + continue; + } + if (code !== 0x2e) continue; + const extensionEnd = findPathExtensionEnd(value, index); + if (extensionEnd >= 0 && extensionEnd <= end) lastExtensionEnd = extensionEnd; + } + return lastExtensionEnd; +} + +function tokenContainsPathExtensionEvidence(value: string, start: number, end: number): boolean { + for (let dot = start; dot < end; dot++) { + if (value.charCodeAt(dot) !== 0x2e) continue; + let extensionEnd = dot + 1; + const maxExtensionEnd = Math.min(end, extensionEnd + 16); + let hasLetter = false; + while (extensionEnd < maxExtensionEnd && isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) { + if (isAsciiLetter(value.charCodeAt(extensionEnd))) hasLetter = true; + extensionEnd++; + } + if ( + extensionEnd === dot + 1 || + !hasLetter || + (extensionEnd === maxExtensionEnd && + extensionEnd < end && + isAsciiAlphaNumeric(value.charCodeAt(extensionEnd))) + ) { + continue; + } + if ( + extensionEnd === end || + value.charCodeAt(extensionEnd) === 0x2f || + value.charCodeAt(extensionEnd) === 0x5c || + PATH_SPAN_END_PUNCTUATION.includes(value[extensionEnd]) + ) { + return true; + } + } + return false; +} + +function tokenContainsPathSeparator(value: string, start: number, end: number): boolean { + for (let index = start; index < end; index++) { + const code = value.charCodeAt(index); + if (code === 0x2f || code === 0x5c) return true; + } + return false; +} + +function remainderContainsFilesystemSeparator(value: string, start: number): boolean { + let tokenStart = start; + let previousToken = ""; + while (tokenStart < value.length) { + while (tokenStart < value.length && isWhitespace(value[tokenStart])) tokenStart++; + if (tokenStart >= value.length) return false; + + const tokenEnd = findTokenEnd(value, tokenStart); + const token = value.slice(tokenStart, tokenEnd).toLowerCase(); + const isHttpUrl = token.includes("http://") || token.includes("https://"); + let separatorIndex = tokenStart; + while ( + separatorIndex < tokenEnd && + value.charCodeAt(separatorIndex) !== 0x2f && + value.charCodeAt(separatorIndex) !== 0x5c + ) { + separatorIndex++; + } + const precedingSeparatorCode = + separatorIndex > tokenStart ? value.charCodeAt(separatorIndex - 1) : -1; + const contextIndex = + precedingSeparatorCode === 0x27 || + precedingSeparatorCode === 0x22 || + precedingSeparatorCode === 0x60 + ? separatorIndex - 1 + : separatorIndex; + const isShieldedRoute = + separatorIndex < tokenEnd && + value.charCodeAt(separatorIndex) === 0x2f && + !isWindowsAbsolutePathAt(value, separatorIndex) && + (isRouteContextToken(previousToken) || hasRouteContextBefore(value, contextIndex)); + if (!isHttpUrl && separatorIndex < tokenEnd && !isShieldedRoute) return true; + previousToken = value.slice(tokenStart, tokenEnd); + tokenStart = tokenEnd; + } + return false; +} + +function trimPathSpanEnd(value: string, start: number, end: number): number { + while (end > start && PATH_SPAN_END_PUNCTUATION.includes(value[end - 1])) end--; + return end; +} + +function isClearProseBoundaryToken(value: string, start: number, end: number): boolean { + while (start < end && LEADING_PATH_PUNCTUATION.includes(value[start])) start++; + end = trimPathSpanEnd(value, start, end); + return (CLEAR_PROSE_BOUNDARIES as readonly string[]).includes( + value.slice(start, end).toLowerCase() + ); +} + +function findUnquotedPathEnd( + value: string, + start: number, + acceptFirstTokenPunctuation: boolean, + acceptEndpointBeforeAnotherAbsolute: boolean, + failClosedAmbiguity: boolean +): number { + let tokenStart = start; + let isFirstToken = true; + let firstTokenEnd = -1; + let firstTrimmedTokenEnd = -1; + let lastPathTokenEnd = -1; + let resolvedExtensionEnd = -1; + let hasFilesystemEvidence = false; + let hasUnresolvedFragments = false; + + const resolveEndpoint = (): number => { + if (hasUnresolvedFragments) { + return failClosedAmbiguity || hasFilesystemEvidence ? value.length : -1; + } + if (resolvedExtensionEnd >= 0) return resolvedExtensionEnd; + if (hasFilesystemEvidence && lastPathTokenEnd >= 0) return lastPathTokenEnd; + if ( + acceptFirstTokenPunctuation && + firstTrimmedTokenEnd >= 0 && + firstTrimmedTokenEnd < firstTokenEnd + ) { + return firstTrimmedTokenEnd; + } + return -1; + }; + + while (tokenStart < value.length) { + const tokenEnd = findTokenEnd(value, tokenStart); + const extensionEnd = findExtensionEndInToken(value, tokenStart, tokenEnd); + const trimmedTokenEnd = trimPathSpanEnd(value, tokenStart, tokenEnd); + + if (isFirstToken) { + firstTokenEnd = tokenEnd; + firstTrimmedTokenEnd = trimmedTokenEnd; + lastPathTokenEnd = trimmedTokenEnd; + // A prose-looking token may itself be a directory name. It is a safe + // boundary only when no later token carries path-separator evidence; + // otherwise keep scanning so a filesystem suffix cannot survive. + } else if ( + isClearProseBoundaryToken(value, tokenStart, tokenEnd) && + (!remainderContainsFilesystemSeparator(value, tokenEnd) || + (!failClosedAmbiguity && !hasFilesystemEvidence)) + ) { + return resolveEndpoint(); + } + + const containsSeparator = tokenContainsPathSeparator(value, tokenStart, tokenEnd); + const containsExtensionEvidence = tokenContainsPathExtensionEvidence( + value, + tokenStart, + tokenEnd + ); + if (containsSeparator) { + lastPathTokenEnd = trimmedTokenEnd; + hasFilesystemEvidence = true; + hasUnresolvedFragments = false; + resolvedExtensionEnd = extensionEnd >= 0 ? extensionEnd : -1; + if (extensionEnd < 0 && containsExtensionEvidence) { + resolvedExtensionEnd = trimmedTokenEnd; + } + } else if (extensionEnd >= 0) { + resolvedExtensionEnd = extensionEnd; + hasFilesystemEvidence = true; + hasUnresolvedFragments = false; + } else if (containsExtensionEvidence) { + resolvedExtensionEnd = trimmedTokenEnd; + hasFilesystemEvidence = true; + hasUnresolvedFragments = false; + } else if (!isFirstToken) { + hasUnresolvedFragments = true; + } + + let nextTokenStart = tokenEnd; + while (nextTokenStart < value.length && isWhitespace(value[nextTokenStart])) nextTokenStart++; + if (nextTokenStart >= value.length) return resolveEndpoint(); + if (isSyntacticallyAbsolutePathAt(value, nextTokenStart)) { + const endpoint = resolveEndpoint(); + if (endpoint >= 0) return endpoint; + return acceptEndpointBeforeAnotherAbsolute ? lastPathTokenEnd : -1; + } + + tokenStart = nextTokenStart; + isFirstToken = false; + } + return resolveEndpoint(); +} + +function isUnquotedPosixSpanCandidateAt(value: string, start: number): boolean { + const tokenEnd = findTokenEnd(value, start); + const token = value.slice(start, tokenEnd); + if (isKnownPosixFilesystemPath(token)) return true; + if ( + findExtensionEndInToken(value, start, tokenEnd) >= 0 || + tokenContainsPathExtensionEvidence(value, start, tokenEnd) + ) { + return true; + } + + let slashCount = 0; + for (let index = start; index < tokenEnd; index++) { + if (value.charCodeAt(index) === 0x2f) slashCount++; + } + // Any boundary-delimited absolute POSIX token is filesystem-sensitive by + // default. Explicit Route/HTTP context is shielded by the caller before this + // candidate check, so `/vault` is redacted while `Route /vault` is retained. + return slashCount >= 1 && token.length > 1; +} + +function redactUnquotedAbsolutePathSpans(value: string): string { + const parts: string[] = []; + let copyStart = 0; + let index = 0; + + while (index < value.length) { + const previous = index > 0 ? value[index - 1] : ""; + const followsQuote = previous === "'" || previous === '"' || previous === "`"; + const hasCommonBoundary = + index === 0 || + isWhitespace(previous) || + LEADING_PATH_PUNCTUATION.includes(previous) || + previous === "=" || + previous === ":" || + previous === "," || + previous === ";" || + previous === "." || + previous === ">" || + previous === "|"; + const startsForwardSlashUnc = + value.charCodeAt(index) === 0x2f && value.charCodeAt(index + 1) === 0x2f; + const startsHttpUrl = + startsForwardSlashUnc && previous === ":" && hasHttpUrlSchemeBefore(value, index); + const isWindowsPath = + !followsQuote && + (isWindowsAbsolutePathAt(value, index) || isWindowsRootRelativePathAt(value, index)) && + !startsHttpUrl; + const isFileUriPath = !followsQuote && hasAbsoluteFileUriAt(value, index); + const isPosixPath = + !followsQuote && + value.charCodeAt(index) === 0x2f && + value.charCodeAt(index + 1) !== 0x2f && + !hasRouteContextBefore(value, index) && + isUnquotedPosixSpanCandidateAt(value, index); + const hasBoundary = hasCommonBoundary || (isWindowsPath && previous === ":"); + if (!hasBoundary || (!isWindowsPath && !isFileUriPath && !isPosixPath)) { + index++; + continue; + } + + // Whitespace makes an unquoted path ambiguous. Extend through adjacent + // separator-bearing tokens or to a deterministic filename extension. + // Unequivocal Windows, file-URI, and known-root candidates fail closed; + // arbitrary extensionless POSIX text falls back to token-level handling so + // ordinary `/x/y` route text is not redacted indiscriminately. + const isKnownPosixPath = isKnownPosixFilesystemPathAt(value, index); + const pathEnd = findUnquotedPathEnd( + value, + index, + isWindowsPath || isFileUriPath || isKnownPosixPath, + isWindowsPath || isFileUriPath || isKnownPosixPath, + isWindowsPath || isFileUriPath || isKnownPosixPath + ); + if (pathEnd < 0) { + const mustFailClosed = isWindowsPath || isFileUriPath || isKnownPosixPath; + if (mustFailClosed) { + // An unequivocal filesystem prefix with an unknowable endpoint must + // fail closed over the rest of the first line rather than expose a + // suffix such as `Files\\secret` or `My Project`. + parts.push(value.slice(copyStart, index), ""); + copyStart = value.length; + index = value.length; + break; + } + index++; + continue; + } + parts.push(value.slice(copyStart, index), ""); + copyStart = pathEnd; + index = pathEnd; + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +function isPhysicalLineSeparator(code: number): boolean { + return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029; +} + +function serializedLineSeparatorLengthAt(value: string, start: number): number { + if (value.charCodeAt(start) !== 0x5c) return 0; + const marker = value[start + 1]?.toLowerCase(); + if (marker === "n" || marker === "r") return 2; + const unicodeMarker = value.slice(start + 1, start + 6).toLowerCase(); + return unicodeMarker === "u000a" || + unicodeMarker === "u000d" || + unicodeMarker === "u2028" || + unicodeMarker === "u2029" + ? 6 + : 0; +} + +function looksLikeRelativeStackLocation(token: string): boolean { + if (token.length < 6 || token.length > 2048) return false; + + const lastForwardSlash = token.lastIndexOf("/"); + const lastBackslash = token.lastIndexOf("\\"); + const lastSeparator = Math.max(lastForwardSlash, lastBackslash); + if (lastSeparator === token.length - 1) return false; + + const columnSeparator = token.lastIndexOf(":"); + const lineSeparator = token.lastIndexOf(":", columnSeparator - 1); + if (lineSeparator < 0 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false; + const queryIndex = token.indexOf("?", lastSeparator + 1); + const fragmentIndex = token.indexOf("#", lastSeparator + 1); + const metadataIndexes = [queryIndex, fragmentIndex].filter( + (index) => index >= 0 && index < lineSeparator + ); + const extensionEnd = metadataIndexes.length > 0 ? Math.min(...metadataIndexes) : lineSeparator; + const dot = token.lastIndexOf(".", extensionEnd - 1); + if (dot <= lastSeparator || dot === extensionEnd - 1) return false; + const extension = token.slice(dot + 1, extensionEnd).toLowerCase(); + if (!(SOURCE_EXT as readonly string[]).includes(extension)) return false; + return true; +} + +function looksLikeUrlStackLocation(token: string): boolean { + if (token.length < 12 || token.length > 2048) return false; + const lower = token.toLowerCase(); + if (!lower.startsWith("http://") && !lower.startsWith("https://")) return false; + const columnSeparator = token.lastIndexOf(":"); + const lineSeparator = token.lastIndexOf(":", columnSeparator - 1); + return lineSeparator > 0 && hasNumericLineColumnSuffix(token, lineSeparator); +} + +function hasNumericLineColumnSuffix(value: string, separator: number): boolean { + if (value.charCodeAt(separator) !== 0x3a) return false; + let index = separator + 1; + if (!isAsciiDigit(value.charCodeAt(index))) return false; + while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++; + if (value.charCodeAt(index) !== 0x3a) return false; + + index++; + if (!isAsciiDigit(value.charCodeAt(index))) return false; + while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++; + return index === value.length; +} + +function isNodeModulePathCode(code: number): boolean { + return ( + isAsciiAlphaNumeric(code) || code === 0x2e || code === 0x2f || code === 0x5f || code === 0x2d + ); +} + +function looksLikeNodeStackLocation(token: string): boolean { + if (token.length < 10 || token.length > 2048 || !token.startsWith("node:")) return false; + const columnSeparator = token.lastIndexOf(":"); + const lineSeparator = token.lastIndexOf(":", columnSeparator - 1); + if (lineSeparator <= 5 || !hasNumericLineColumnSuffix(token, lineSeparator)) return false; + for (let index = 5; index < lineSeparator; index++) { + if (!isNodeModulePathCode(token.charCodeAt(index))) return false; + } + return true; +} + +function looksLikeEvalStackLocation(token: string): boolean { + return token.length <= 64 && token.startsWith("[eval]") && hasNumericLineColumnSuffix(token, 6); +} + +function isRecognizedStackPathAt(value: string, start: number): boolean { + if (hasAbsoluteFileUriAt(value, start)) return true; + const tokenEnd = trimPathSpanEnd(value, start, findTokenEnd(value, start)); + const token = value.slice(start, tokenEnd); + return ( + looksLikeAbsolutePath(token) || + looksLikeRelativeStackLocation(token) || + looksLikeUrlStackLocation(token) || + looksLikeNodeStackLocation(token) || + looksLikeEvalStackLocation(token) + ); +} + +function isStackFrameLabel(value: string, start: number, end: number): boolean { + const label = value.slice(start, end).trim(); + if (label.length === 0 || label.length > 256) return false; + if (!/^[A-Za-z_$<]/.test(label) || /[^A-Za-z0-9_$.[\]<>:/ -]/.test(label)) return false; + if (!/\s/.test(label)) return true; + return /^(?:async|new)\s+\S+$/.test(label) || /^\S+\s+\[as\s+\S+\]$/.test(label); +} + +function skipAsyncStackPrefix(value: string, start: number): number { + if (value.slice(start, start + 5) !== "async" || !isWhitespace(value[start + 5])) return start; + let locationStart = start + 6; + while (locationStart < value.length && isWhitespace(value[locationStart])) locationStart++; + return locationStart; +} + +function isAggregateIndexLocationAt(value: string, start: number): boolean { + if (value.slice(start, start + 5) !== "index" || !isWhitespace(value[start + 5])) return false; + let index = start + 6; + while (index < value.length && isWhitespace(value[index])) index++; + if (!isAsciiDigit(value.charCodeAt(index))) return false; + while (index < value.length && isAsciiDigit(value.charCodeAt(index))) index++; + while (index < value.length && isWhitespace(value[index])) index++; + return value.charCodeAt(index) === 0x29; +} + +function looksLikeStackFrameAt(value: string, atIndex: number, allowDirectPath: boolean): boolean { + if (value.slice(atIndex, atIndex + 2).toLowerCase() !== "at") return false; + let labelStart = atIndex + 2; + if (!isWhitespace(value[labelStart])) return false; + while (labelStart < value.length && isWhitespace(value[labelStart])) labelStart++; + labelStart = skipAsyncStackPrefix(value, labelStart); + if (allowDirectPath && isRecognizedStackPathAt(value, labelStart)) return true; + + const openParen = value.indexOf("(", labelStart); + if (openParen < 0 || openParen - labelStart > 256) return false; + let pathStart = openParen + 1; + while (pathStart < value.length && isWhitespace(value[pathStart])) pathStart++; + return ( + isStackFrameLabel(value, labelStart, openParen) && + (isRecognizedStackPathAt(value, pathStart) || + (allowDirectPath && isAggregateIndexLocationAt(value, pathStart))) + ); +} + +function looksLikeAtSignStackFrameAt(value: string, frameStart: number): boolean { + const tokenEnd = trimPathSpanEnd(value, frameStart, findTokenEnd(value, frameStart)); + const atSign = value.indexOf("@", frameStart); + if (atSign <= frameStart || atSign >= tokenEnd || atSign - frameStart > 256) return false; + return isStackFrameLabel(value, frameStart, atSign) && isRecognizedStackPathAt(value, atSign + 1); +} + +function findSerializedStackFrameStart(value: string): number { + for (let index = 0; index < value.length; index++) { + const separatorLength = serializedLineSeparatorLengthAt(value, index); + if (separatorLength === 0) continue; + let frameStart = index + separatorLength; + while (frameStart < value.length) { + while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++; + const adjacentSeparatorLength = serializedLineSeparatorLengthAt(value, frameStart); + if (adjacentSeparatorLength === 0) break; + frameStart += adjacentSeparatorLength; + } + if ( + looksLikeStackFrameAt(value, frameStart, true) || + looksLikeAtSignStackFrameAt(value, frameStart) + ) { + let separatorStart = index; + while (separatorStart > 0 && value.charCodeAt(separatorStart - 1) === 0x5c) { + separatorStart--; + } + return separatorStart; + } + } + return -1; +} + +function findInlineStackFrameStart(value: string): number { + let marker = value.indexOf(" at "); + while (marker >= 0) { + if (looksLikeStackFrameAt(value, marker + 1, false)) return marker; + marker = value.indexOf(" at ", marker + 4); + } + return -1; +} + +function findInlineAtSignStackFrameStart(value: string): number { + let frameStart = 0; + while (frameStart < value.length) { + if (looksLikeAtSignStackFrameAt(value, frameStart)) { + return frameStart > 0 && isWhitespace(value[frameStart - 1]) ? frameStart - 1 : frameStart; + } + const tokenEnd = findTokenEnd(value, frameStart); + frameStart = tokenEnd; + while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++; + } + return -1; +} + +function physicalLineSeparatorLengthAt(value: string, start: number): number { + const code = value.charCodeAt(start); + if (!isPhysicalLineSeparator(code)) return 0; + return code === 0x0d && value.charCodeAt(start + 1) === 0x0a ? 2 : 1; +} + +function findPhysicalStackFrameStart(value: string): number { + for (let index = 0; index < value.length; index++) { + const separatorLength = physicalLineSeparatorLengthAt(value, index); + if (separatorLength === 0) continue; + let frameStart = index + separatorLength; + while (frameStart < value.length && isWhitespace(value[frameStart])) frameStart++; + if ( + looksLikeStackFrameAt(value, frameStart, true) || + looksLikeAtSignStackFrameAt(value, frameStart) + ) { + return index; + } + index += separatorLength - 1; + } + return -1; +} + +/** Strip only recognized physical, serialized, and inline JavaScript stack-frame tails. */ +export function stripRecognizedErrorStackTail(value: string): string { + const candidates = [ + findPhysicalStackFrameStart(value), + findSerializedStackFrameStart(value), + findInlineStackFrameStart(value), + findInlineAtSignStackFrameStart(value), + ].filter((candidate) => candidate >= 0); + if (candidates.length === 0) return value; + return value.slice(0, Math.min(...candidates)); +} + +/** + * Public exception messages remain fail-closed at the first physical line. + * Provider passthroughs that require multiline capability wording use the + * narrower recognized-frame helper above instead. + */ +export function stripErrorStackTail(value: string): string { + let firstLineEnd = value.length; + for (let index = 0; index < value.length; index++) { + if (isPhysicalLineSeparator(value.charCodeAt(index))) { + firstLineEnd = index; + break; + } + } + return stripRecognizedErrorStackTail(value.slice(0, firstLineEnd)); +} + +/** + * Redact absolute filesystem paths while preserving URLs, explicitly marked + * API routes, and punctuation around determinable endpoints. Unequivocal + * filesystem prefixes fail closed when an unquoted endpoint is ambiguous. + */ +export function redactErrorPaths(value: string): string { + const quotedPathsRedacted = redactQuotedAbsolutePaths(value); + const pathSpansRedacted = redactUnquotedAbsolutePathSpans(quotedPathsRedacted); + const parts = pathSpansRedacted.split(/(\s+)/); + let previousToken = ""; + for (let index = 0; index < parts.length; index++) { + const token = parts[index]; + if (isWhitespace(token)) continue; + parts[index] = redactAbsolutePathToken(token, isRouteContextToken(previousToken)); + previousToken = token; + } + return parts.join(""); +} diff --git a/open-sse/utils/errorSanitization.ts b/open-sse/utils/errorSanitization.ts new file mode 100644 index 0000000000..1b7601d4ee --- /dev/null +++ b/open-sse/utils/errorSanitization.ts @@ -0,0 +1,895 @@ +import { + redactErrorPaths, + stripErrorStackTail, + stripRecognizedErrorStackTail, +} from "./errorPathRedaction.ts"; +import { CREDENTIAL_PATTERNS } from "./credentialPatterns.ts"; + +// Length cap protects against pathological inputs even before tokenization. +const MAX_ERROR_LEN = 4096; +const MAX_ERROR_SCAN_HEADROOM = 512; +const MAX_SECURITY_ESCAPE_LAYERS = 3; +const STRONG_CREDENTIAL_TOKEN_SOURCE = + "(?:eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|" + + "github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|" + + "xox[a-z]-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|" + + "(?= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) + ); +} + +function asciiHexValue(code: number): number { + if (code >= 0x30 && code <= 0x39) return code - 0x30; + if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10; + if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10; + return -1; +} + +function unicodeEscapeCodeAt(value: string, start: number): number | null { + if ( + value.charCodeAt(start) !== 0x5c || + (value[start + 1] !== "u" && value[start + 1] !== "U") || + start + 5 >= value.length + ) { + return null; + } + + let decoded = 0; + for (let digit = start + 2; digit <= start + 5; digit++) { + const nibble = asciiHexValue(value.charCodeAt(digit)); + if (nibble < 0) return null; + decoded = decoded * 16 + nibble; + } + return decoded; +} + +function isPrintableAscii(code: number | null): code is number { + return code !== null && code >= 0x20 && code <= 0x7e; +} + +function isSecurityWhitespaceCode(code: number | null): boolean { + return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d; +} + +function isEscapeTokenBoundary(code: number): boolean { + return !isAsciiAlphaNumericCode(code) && code !== 0x2e && code !== 0x5f && code !== 0x2d; +} + +function shouldPreserveUnicodeUncEvidence( + value: string, + runStart: number, + runEnd: number, + decoded: number +): boolean { + if ( + runEnd - runStart < 2 || + decoded === 0x2f || + decoded === 0x5c || + decoded === 0x3a || + (runStart > 0 && !isEscapeTokenBoundary(value.charCodeAt(runStart - 1))) + ) { + return false; + } + + const afterEscape = runEnd + 5; + let tokenEnd = afterEscape; + while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++; + if (value.slice(afterEscape, tokenEnd).includes("=")) return false; + return afterEscape < tokenEnd; +} + +function decodeSecurityEscapesOnce( + value: string, + decodeQuotes: boolean, + maxLength: number +): string { + const output: string[] = []; + let changed = false; + + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) !== 0x5c) { + output.push(value[index]); + continue; + } + + const runStart = index; + while (index < value.length && value.charCodeAt(index) === 0x5c) index++; + const runEnd = index; + if (runEnd >= value.length) { + output.push(value.slice(runStart)); + break; + } + + const escaped = value[runEnd]; + if (escaped === "u" || escaped === "U") { + const decoded = unicodeEscapeCodeAt(value, runEnd - 1); + const isQuote = decoded === 0x22 || decoded === 0x27; + if (isSecurityWhitespaceCode(decoded)) { + output.push(" "); + index = runEnd + 4; + changed = true; + continue; + } + if ( + isPrintableAscii(decoded) && + (decodeQuotes || !isQuote) && + !shouldPreserveUnicodeUncEvidence(value, runStart, runEnd, decoded) + ) { + output.push(String.fromCharCode(decoded)); + index = runEnd + 4; + changed = true; + continue; + } + output.push(value.slice(runStart, runEnd + 5)); + index = runEnd + 4; + continue; + } + + if ( + escaped === "b" || + escaped === "f" || + escaped === "n" || + escaped === "r" || + escaped === "t" + ) { + output.push(" "); + index = runEnd; + changed = true; + continue; + } + + if (escaped === "/" || (decodeQuotes && (escaped === '"' || escaped === "'"))) { + output.push(escaped); + index = runEnd; + changed = true; + continue; + } + + output.push(value.slice(runStart, runEnd)); + index = runEnd - 1; + } + + return changed ? output.join("").slice(0, maxLength) : value; +} + +function hasResidualSecurityEscape(value: string): boolean { + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) !== 0x5c) continue; + while (index < value.length && value.charCodeAt(index) === 0x5c) index++; + if (index >= value.length) return false; + const escaped = value[index]; + if ( + escaped === "b" || + escaped === "f" || + escaped === "n" || + escaped === "r" || + escaped === "t" + ) { + return true; + } + if (escaped === "/" || escaped === '"' || escaped === "'") return true; + if (escaped === "u" || escaped === "U") { + const decoded = unicodeEscapeCodeAt(value, index - 1); + if (isPrintableAscii(decoded) || isSecurityWhitespaceCode(decoded)) return true; + } + } + return false; +} + +/** Decode bounded security ASCII/JSON escapes while never materializing arbitrary Unicode. */ +function normalizeSecurityEscapes( + value: string, + decodeQuotes: boolean, + maxLength = MAX_ERROR_LEN +): string { + let normalized = value.slice(0, maxLength); + for (let layer = 0; layer < MAX_SECURITY_ESCAPE_LAYERS; layer++) { + const decoded = decodeSecurityEscapesOnce(normalized, decodeQuotes, maxLength); + if (decoded === normalized) break; + normalized = decoded.slice(0, maxLength); + } + return normalized; +} + +function isCredentialLabelBoundary(code: number): boolean { + return !isAsciiAlphaNumericCode(code) && code !== 0x5f && code !== 0x2d; +} + +function matchCredentialAssignmentAt(value: string, start: number): CredentialAssignment | null { + const keyQuote = value[start] === '"' || value[start] === "'" ? value[start] : ""; + const labelStart = start + (keyQuote ? 1 : 0); + const cliFlag = + !keyQuote && + labelStart >= 2 && + value.slice(labelStart - 2, labelStart) === "--" && + (labelStart === 2 || isCredentialLabelBoundary(value.charCodeAt(labelStart - 3))); + + for (const [label, failClosed] of CREDENTIAL_LABELS) { + const labelEnd = labelStart + label.length; + if (value.slice(labelStart, labelEnd).toLowerCase() !== label) continue; + let index = labelEnd; + if ( + (label === "arena-auth-prod-v1" || label === "__secure-next-auth.session-token") && + value[index] === "." + ) { + const chunkStart = ++index; + while (index < value.length && /\d/.test(value[index])) index++; + if (index === chunkStart) continue; + } + if (keyQuote) { + if (value[index] !== keyQuote) continue; + index++; + } else if (!isCredentialLabelBoundary(value.charCodeAt(index))) { + continue; + } else if (value[index] === '"' || value[index] === "'") { + index++; + } + const separatorStart = index; + while (/\s/.test(value[index])) index++; + if (value[index] === ":" || value[index] === "=") { + index++; + while (/\s/.test(value[index])) index++; + } else if (!(cliFlag && index > separatorStart)) { + continue; + } + return { valueStart: index, failClosed }; + } + return null; +} + +function findQuotedCredentialEnd(value: string, start: number, quote: string): number { + let index = start + 1; + while (index < value.length) { + if (value.charCodeAt(index) === 0x5c) { + index += 2; + continue; + } + if (value[index] === quote) return index; + index++; + } + return -1; +} + +function findUnquotedCredentialEnd(value: string, start: number): number { + let end = start; + while (end < value.length) { + const char = value[end]; + if (/\s/.test(char) || char === '"' || char === "'" || char === "," || char === "}") break; + end++; + } + return end; +} + +function redactLabeledCredentialAssignments(value: string): string { + const parts: string[] = []; + let copyStart = 0; + let index = 0; + + while (index < value.length) { + const assignment = matchCredentialAssignmentAt(value, index); + if (!assignment) { + index++; + continue; + } + + const { valueStart, failClosed } = assignment; + const quote = value[valueStart] === '"' || value[valueStart] === "'" ? value[valueStart] : ""; + if (quote) { + const closingQuote = findQuotedCredentialEnd(value, valueStart, quote); + parts.push(value.slice(copyStart, valueStart + 1), "[REDACTED]"); + if (closingQuote < 0) { + copyStart = value.length; + index = value.length; + } else { + parts.push(quote); + copyStart = closingQuote + 1; + index = copyStart; + } + continue; + } + + // A leading backslash may be a serialized quote or another encoded + // delimiter. Do not redact only that prefix and leave the value behind. + const valueEnd = + failClosed || value.charCodeAt(valueStart) === 0x5c + ? value.length + : findUnquotedCredentialEnd(value, valueStart); + parts.push(value.slice(copyStart, valueStart), "[REDACTED]"); + copyStart = valueEnd; + index = Math.max(valueEnd, valueStart + 1); + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +function redactPrivateKeyPemBlocks(value: string): string { + // ASCII-only fold keeps offsets aligned even when the surrounding message + // contains Unicode characters whose full uppercase form expands in length. + const upperValue = value.replace(/[a-z]/g, (char) => char.toUpperCase()); + const beginPrefix = "-----BEGIN "; + const parts: string[] = []; + let copyStart = 0; + let searchStart = 0; + + while (searchStart < value.length) { + const blockStart = upperValue.indexOf(beginPrefix, searchStart); + if (blockStart < 0) break; + const labelStart = blockStart + beginPrefix.length; + const headerEnd = upperValue.indexOf("-----", labelStart); + if (headerEnd < 0) break; + const label = upperValue.slice(labelStart, headerEnd).trim(); + if (!/^(?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?$/.test(label)) { + searchStart = headerEnd + 5; + continue; + } + + const endMarker = `-----END ${label}-----`; + const closingStart = upperValue.indexOf(endMarker, headerEnd + 5); + const blockEnd = closingStart < 0 ? value.length : closingStart + endMarker.length; + parts.push(value.slice(copyStart, blockStart), "[REDACTED]"); + copyStart = blockEnd; + searchStart = blockEnd; + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +const DATA_URL_PREFIX = "data:"; +const BASE64_DATA_URL_MARKER = ";base64"; +const REDACTED_DATA_URL = "[REDACTED_DATA_URL]"; + +function matchesAsciiCaseInsensitiveAt(value: string, start: number, expected: string): boolean { + if (start < 0 || start + expected.length > value.length) return false; + for (let offset = 0; offset < expected.length; offset++) { + const code = value.charCodeAt(start + offset); + const foldedCode = code >= 0x41 && code <= 0x5a ? code + 0x20 : code; + if (foldedCode !== expected.charCodeAt(offset)) return false; + } + return true; +} + +function isBase64DataUrlPayloadCode(code: number): boolean { + return ( + isAsciiAlphaNumericCode(code) || + code === 0x2b || + code === 0x2f || + code === 0x3d || + code === 0x5f || + code === 0x2d + ); +} + +function isEcmaScriptWhitespaceCode(code: number): boolean { + return ( + (code >= 0x09 && code <= 0x0d) || + code === 0x20 || + code === 0xa0 || + code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || + code === 0x2029 || + code === 0x202f || + code === 0x205f || + code === 0x3000 || + code === 0xfeff + ); +} + +/** Redact base64 data URLs in one pass, including input with many repeated `data:` prefixes. */ +function redactBase64DataUrls(value: string): string { + const parts: string[] = []; + let copyStart = 0; + let index = 0; + + while (index < value.length) { + if (!matchesAsciiCaseInsensitiveAt(value, index, DATA_URL_PREFIX)) { + index++; + continue; + } + + const dataUrlStart = index; + const mediaTypeStart = dataUrlStart + DATA_URL_PREFIX.length; + let delimiter = mediaTypeStart; + while ( + delimiter < value.length && + value[delimiter] !== "," && + !isEcmaScriptWhitespaceCode(value.charCodeAt(delimiter)) + ) { + delimiter++; + } + + const markerStart = delimiter - BASE64_DATA_URL_MARKER.length; + const hasBase64Marker = + delimiter < value.length && + value[delimiter] === "," && + markerStart >= mediaTypeStart && + matchesAsciiCaseInsensitiveAt(value, markerStart, BASE64_DATA_URL_MARKER); + if (!hasBase64Marker) { + index = delimiter < value.length ? delimiter + 1 : value.length; + continue; + } + + let payloadEnd = delimiter + 1; + while (payloadEnd < value.length && isBase64DataUrlPayloadCode(value.charCodeAt(payloadEnd))) { + payloadEnd++; + } + if (payloadEnd === delimiter + 1) { + index = delimiter + 1; + continue; + } + + parts.push(value.slice(copyStart, dataUrlStart), REDACTED_DATA_URL); + copyStart = payloadEnd; + index = payloadEnd; + } + + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +const HTTP_URL_RE = /https?:\/\//gi; +const URL_QUERY_PARAM_RE = /([?&])([^=&#]+)=([^&#]*)/g; + +function isUrlTerminator(char: string): boolean { + return ( + /\s/.test(char) || + char === '"' || + char === "'" || + char === "`" || + char === "<" || + char === ">" || + char === ")" || + char === "]" || + char === "}" || + char === "," || + char === ";" + ); +} + +function normalizeUrlQueryKey(key: string): string { + let decoded = key.replace(/\+/g, " "); + try { + decoded = decodeURIComponent(decoded); + } catch { + // Malformed percent escapes stay visible to the conservative ASCII fold. + } + return decoded.replace(/[^A-Za-z0-9]/g, "").toLowerCase(); +} + +function isSensitiveUrlQueryKey(key: string): boolean { + const normalized = normalizeUrlQueryKey(key); + return ( + normalized === "sig" || + normalized === "signature" || + normalized === "key" || + normalized === "apikey" || + normalized === "token" || + normalized === "accesstoken" || + normalized === "refreshtoken" || + normalized === "credential" || + normalized === "password" || + normalized === "secret" || + normalized === "awsaccesskeyid" || + normalized === "googleaccessid" || + normalized === "xamzcredential" || + normalized === "xamzsignature" || + normalized === "xamzsecuritytoken" || + normalized === "xgoogcredential" || + normalized === "xgoogsignature" + ); +} + +function redactUrlSegment(segment: string): string { + const schemeEnd = segment.indexOf("//") + 2; + let authorityEnd = segment.length; + for (const delimiter of ["/", "?", "#"]) { + const candidate = segment.indexOf(delimiter, schemeEnd); + if (candidate >= 0) authorityEnd = Math.min(authorityEnd, candidate); + } + + let redacted = segment; + const userInfoEnd = segment.lastIndexOf("@", authorityEnd); + if (userInfoEnd >= schemeEnd) { + redacted = `${segment.slice(0, schemeEnd)}[REDACTED]@${segment.slice(userInfoEnd + 1)}`; + } + + URL_QUERY_PARAM_RE.lastIndex = 0; + return redacted.replace(URL_QUERY_PARAM_RE, (match, separator: string, key: string) => + isSensitiveUrlQueryKey(key) ? `${separator}redacted=[REDACTED]` : match + ); +} + +function redactSensitiveUrlCredentials(value: string): string { + HTTP_URL_RE.lastIndex = 0; + const parts: string[] = []; + let copyStart = 0; + let match = HTTP_URL_RE.exec(value); + while (match) { + const start = match.index; + let end = HTTP_URL_RE.lastIndex; + while (end < value.length && !isUrlTerminator(value[end])) end++; + const segment = value.slice(start, end); + const redacted = redactUrlSegment(segment); + if (redacted !== segment) { + parts.push(value.slice(copyStart, start), redacted); + copyStart = end; + } + HTTP_URL_RE.lastIndex = Math.max(end, HTTP_URL_RE.lastIndex); + match = HTTP_URL_RE.exec(value); + } + if (parts.length === 0) return value; + parts.push(value.slice(copyStart)); + return parts.join(""); +} + +function redactKnownCredentialPatterns(value: string): string { + let redacted = value; + for (const pattern of CREDENTIAL_PATTERNS) { + if (pattern.name === "auth_header") continue; + pattern.regex.lastIndex = 0; + redacted = redacted.replace(pattern.regex, "[REDACTED]"); + } + return redacted; +} + +export function redactSensitiveErrorText(value: string): string { + const normalized = normalizeSecurityEscapes( + value, + false, + MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM + ); + const catalogRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized)); + const commonCredentialsRedacted = redactBase64DataUrls(redactPrivateKeyPemBlocks(catalogRedacted)) + .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") + .replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]"); + return redactLabeledCredentialAssignments(commonCredentialsRedacted); +} + +export function containsSensitiveErrorCredential(value: string): boolean { + const normalized = normalizeSecurityEscapes( + value, + false, + MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM + ); + const directRedacted = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(normalized)) + .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]") + .replace(STRONG_CREDENTIAL_TOKEN_GLOBAL, "[REDACTED]"); + if (directRedacted !== normalized) return true; + if ( + /(?:^|\s)--(?:api[-_]?key|token|password|secret)\s+(?:"[^"]*"|'[^']*'|\S+)/i.test(normalized) + ) { + return true; + } + return /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["']?[^"'\\,\s}]{6,}/i.test( + normalized + ); +} + +function coerceErrorText(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return String(value); + } catch { + // Fail closed when an attacker-controlled toString/valueOf accessor throws. + return ""; + } +} + +function truncateSanitizedErrorText(value: string): string { + if (value.length <= MAX_ERROR_LEN) return value; + const markerStart = value.lastIndexOf("[REDACTED", MAX_ERROR_LEN); + const markerEnd = markerStart >= 0 ? value.indexOf("]", markerStart) : -1; + if ( + markerStart >= 0 && + markerStart < MAX_ERROR_LEN && + markerEnd >= MAX_ERROR_LEN && + markerEnd - markerStart <= 128 + ) { + const marker = value.slice(markerStart, markerEnd + 1); + return `${value.slice(0, MAX_ERROR_LEN - marker.length)}${marker}`; + } + return value.slice(0, MAX_ERROR_LEN); +} + +/** + * Strip stack-trace tails, credentials, and absolute source paths from a + * client-visible error message. + */ +function sanitizeErrorMessageWithStackPolicy( + message: unknown, + stripStackTail: (value: string) => string +): string { + let str = coerceErrorText(message); + if (str.length > MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM) { + str = str.slice(0, MAX_ERROR_LEN + MAX_ERROR_SCAN_HEADROOM); + } + // Preserve quote provenance until hidden labels/delimiters have been + // exposed and redacted, then decode safe quote escapes in the clean text. + // Raw URI credentials must be projected before the path tokenizer consumes + // the URI tail; Windows path evidence still stays intact until after this + // credential-only pass and is redacted before escape normalization. + str = redactKnownCredentialPatterns(redactSensitiveUrlCredentials(stripStackTail(str))); + str = redactErrorPaths(str); + str = redactSensitiveErrorText(str); + str = truncateSanitizedErrorText(str); + str = normalizeSecurityEscapes(str, false); + str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str))); + str = normalizeSecurityEscapes(str, true); + str = redactSensitiveErrorText(redactErrorPaths(stripStackTail(str))); + return hasResidualSecurityEscape(str) ? "[REDACTED]" : str.trimEnd(); +} + +export function sanitizeErrorMessage(message: unknown): string { + return sanitizeErrorMessageWithStackPolicy(message, stripErrorStackTail); +} + +function sanitizePassthroughErrorMessage(message: unknown): string { + return sanitizeErrorMessageWithStackPolicy(message, stripRecognizedErrorStackTail); +} + +const BLOCKED_KEYS = + /stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie|credential|session(?!_?(?:count|status)$)/i; +const BLOCKED_CREDENTIAL_ALIAS_KEYS = + /^(?:cf_clearance|__cf_bm|_cfuvid|_puid|sso|sso-rw|arena-auth-prod-v1(?:\.\d+)?)$/i; +const PROTOTYPE_CONTROL_KEYS = new Set(["__proto__", "constructor", "prototype"]); +const MAX_DEPTH = 4; +const MAX_UPSTREAM_KEY_LEN = 256; +type UpstreamClassificationKey = "code" | "reason" | "status" | "type"; +const SAFE_UPSTREAM_STATUS_IDENTIFIERS = new Set([ + "ABORTED", + "ALREADY_EXISTS", + "CANCELLED", + "DATA_LOSS", + "DEADLINE_EXCEEDED", + "FAILED_PRECONDITION", + "INTERNAL", + "INVALID_ARGUMENT", + "NOT_FOUND", + "OK", + "OUT_OF_RANGE", + "PERMISSION_DENIED", + "RESOURCE_EXHAUSTED", + "UNAUTHENTICATED", + "UNAVAILABLE", + "UNIMPLEMENTED", + "UNKNOWN", +]); +const SAFE_UPSTREAM_ERROR_IDENTIFIERS = new Set([ + "api_error", + "auth_error", + "authentication_error", + "bad_gateway", + "bad_request", + "billing_error", + "context_length_exceeded", + "error", + "gateway_timeout", + "insufficient_quota", + "invalid_api_key", + "invalid_request", + "invalid_request_error", + "model_not_found", + "not_found", + "payment_required", + "permission_error", + "provider_error", + "quota_exhausted", + "rate_limit_error", + "rate_limit_exceeded", + "server_error", + "upstream_error", + "upstream_timeout", +]); + +function describeOpaqueBinaryDetail(value: ArrayBuffer | ArrayBufferView): string { + return `[binary ${value.byteLength} bytes]`; +} + +function normalizeUpstreamClassificationKey(key: string): UpstreamClassificationKey | null { + const normalized = key.replace(/[-_]/g, "").toLowerCase(); + if (normalized === "code" || normalized === "errorcode") return "code"; + if (normalized === "reason" || normalized === "errorreason") return "reason"; + if ( + normalized === "status" || + normalized === "statuscode" || + normalized === "errorstatus" || + normalized === "errorstatuscode" + ) { + return "status"; + } + if (normalized === "type" || normalized === "errortype" || normalized === "subtype") { + return "type"; + } + return null; +} + +function projectUpstreamErrorIdentifier(key: UpstreamClassificationKey, value: unknown): unknown { + if (typeof value === "number") { + if (!Number.isInteger(value)) return undefined; + if (key === "code" && value >= 0 && value <= 16) return value; + return (key === "code" || key === "status") && value >= 100 && value <= 599 ? value : undefined; + } + if (typeof value !== "string") return undefined; + if (key === "status" && SAFE_UPSTREAM_STATUS_IDENTIFIERS.has(value.toUpperCase())) { + return value; + } + if ( + /^[1-5]\d{2}$/.test(value) || + /^HTTP_[1-5]\d{2}$/i.test(value) || + SAFE_UPSTREAM_ERROR_IDENTIFIERS.has(value.toLowerCase()) + ) { + return value; + } + if (key === "type") return "upstream_error"; + if (key === "code") return ""; + return undefined; +} + +function isSafeUpstreamDetailKey(key: string): boolean { + if ( + key.length === 0 || + key.length > MAX_UPSTREAM_KEY_LEN || + BLOCKED_KEYS.test(key) || + BLOCKED_CREDENTIAL_ALIAS_KEYS.test(key) || + PROTOTYPE_CONTROL_KEYS.has(key.toLowerCase()) + ) { + return false; + } + return sanitizeErrorMessage(key) === key; +} + +/** + * Recursively sanitize an arbitrary JSON value from an upstream provider body. + * Unsafe keys are dropped rather than renamed so sanitized-key collisions + * cannot restore a secret under a public placeholder. + */ +function sanitizeUpstreamDetailsInternal( + value: unknown, + depth: number, + preserveSafeMultiline: boolean, + projectClassification: boolean +): unknown { + if (depth > MAX_DEPTH) return "[truncated]"; + if (value === null || value === undefined) return null; + if (typeof value === "string") { + return preserveSafeMultiline + ? sanitizePassthroughErrorMessage(value) + : sanitizeErrorMessage(value); + } + if (typeof value === "number" || typeof value === "boolean") return value; + if (typeof value === "object") { + try { + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + return describeOpaqueBinaryDetail(value); + } + if (Array.isArray(value)) { + return value + .slice(0, 32) + .map((entry) => + sanitizeUpstreamDetailsInternal( + entry, + depth + 1, + preserveSafeMultiline, + projectClassification + ) + ); + } + const out = Object.create(null) as Record; + for (const [key, entryValue] of Object.entries(value as Record)) { + if (!isSafeUpstreamDetailKey(key)) continue; + const normalizedKey = key.toLowerCase(); + const classificationKey = normalizeUpstreamClassificationKey(normalizedKey); + if (projectClassification && classificationKey) { + const projected = projectUpstreamErrorIdentifier(classificationKey, entryValue); + if (projected !== undefined) out[key] = projected; + continue; + } + const childProjectsClassification = + normalizedKey === "error" || + normalizedKey === "errors" || + normalizedKey === "warning" || + normalizedKey === "warnings"; + out[key] = sanitizeUpstreamDetailsInternal( + entryValue, + depth + 1, + preserveSafeMultiline, + childProjectsClassification + ); + } + return out; + } catch { + return null; + } + } + return null; +} + +export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown { + return sanitizeUpstreamDetailsInternal(value, depth, false, depth === 0); +} + +/** Provider-only projection that preserves safe multiline capability wording. */ +export function sanitizePassthroughUpstreamDetails(value: unknown, depth = 0): unknown { + return sanitizeUpstreamDetailsInternal(value, depth, true, depth === 0); +} diff --git a/open-sse/utils/generationThroughput.ts b/open-sse/utils/generationThroughput.ts new file mode 100644 index 0000000000..54d48ad1e3 --- /dev/null +++ b/open-sse/utils/generationThroughput.ts @@ -0,0 +1,44 @@ +/** + * Gateway-measured generation throughput (#12616). + * + * tok/s MUST exclude TTFT. `output_tokens / total_latency` includes queueing and + * first-token wait and is not generation speed. When TTFT is unknown (typical + * non-streaming JSON), omit the field rather than guessing. + */ +export function generationDurationMs( + totalMs: number, + ttftMs: number | null | undefined +): number | null { + if (!Number.isFinite(totalMs) || totalMs <= 0) return null; + if (ttftMs == null || !Number.isFinite(ttftMs) || ttftMs < 0) return null; + const generationMs = totalMs - ttftMs; + return generationMs > 0 ? generationMs : null; +} + +export function tokensPerSecond( + outputTokens: number, + generationMs: number | null | undefined +): number | null { + if (generationMs == null || !Number.isFinite(generationMs) || generationMs <= 0) return null; + if (!Number.isFinite(outputTokens) || outputTokens <= 0) return null; + return outputTokens / (generationMs / 1000); +} + +function outputTokenCount(usage: Record): number { + const raw = + usage.completion_tokens ?? + usage.output_tokens ?? + usage.candidatesTokenCount ?? + usage.outputTokens ?? + usage.completionTokens; + const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN; + return Number.isFinite(n) ? n : 0; +} + +/** Attach `tokens_per_second` when generation duration (excluding TTFT) is known. */ +export function attachTokensPerSecond(usage: T, generationMs: number | null | undefined): T { + if (!usage || typeof usage !== "object" || Array.isArray(usage)) return usage; + const tps = tokensPerSecond(outputTokenCount(usage as Record), generationMs); + if (tps == null) return usage; + return { ...(usage as Record), tokens_per_second: Number(tps.toFixed(3)) } as T; +} diff --git a/open-sse/utils/passthroughTailProcessor.ts b/open-sse/utils/passthroughTailProcessor.ts index ab45fb5474..3fa75e58c6 100644 --- a/open-sse/utils/passthroughTailProcessor.ts +++ b/open-sse/utils/passthroughTailProcessor.ts @@ -9,6 +9,7 @@ import { stripResponsesLifecycleEcho, } from "./responsesStreamHelpers.ts"; import { getAnyReasoningValue } from "./reasoningFields.ts"; +import { projectStreamFailureEvent, type StreamFailurePayload } from "./streamErrorFormat.ts"; type JsonRecord = Record; @@ -47,6 +48,7 @@ export type PassthroughTailProcessorContext = { hasPassthroughToolCalls: () => boolean; toResponsesCompletedWithToolCalls: (parsed: JsonRecord) => JsonRecord; restoreOpenAIToolNames: (parsed: JsonRecord) => boolean; + abortFailure: (failure: StreamFailurePayload, publicMessage: string) => void; }; function asRecord(value: unknown): JsonRecord { @@ -284,7 +286,13 @@ export function processBufferedPassthroughLine( context.updateClaudeEmptyResponseLifecycle(parsedPassthroughData); } - const parsed = parsedPassthroughData as JsonRecord; + const projectedFailure = projectStreamFailureEvent(parsedPassthroughData); + const parsed = projectedFailure + ? projectedFailure.publicPayload + : (parsedPassthroughData as JsonRecord); + if (projectedFailure) { + output = `data: ${JSON.stringify(parsed)}\n\n`; + } if (context.sanitizeUsagePayload(parsed)) { output = `data: ${JSON.stringify(parsed)}\n\n`; } @@ -301,6 +309,14 @@ export function processBufferedPassthroughLine( } context.pushClientPayload(parsed); + + output = context.passthroughEventPrefix.prefixData(output, line); + context.emitConvertedOutput(output); + if (projectedFailure) { + context.abortFailure(projectedFailure.internalFailure, projectedFailure.publicMessage); + return true; + } + return false; } output = context.passthroughEventPrefix.prefixData(output, line); diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index 553fc219e4..f6722ac674 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -28,7 +28,12 @@ export type RequestPipelinePayloads = { type RequestLogger = { sessionPath: null; - logClientRawRequest: (endpoint: unknown, body: unknown, headers?: HeaderInput) => void; + logClientRawRequest: ( + endpoint: unknown, + body: unknown, + headers?: HeaderInput, + effectiveInput?: unknown + ) => void; logRouteDecision: (decision: unknown) => void; logOpenAIRequest: (body: unknown) => void; logTargetRequest: (url: unknown, headers: HeaderInput, body: unknown) => void; @@ -392,12 +397,26 @@ export async function createRequestLogger( return { sessionPath: null, - logClientRawRequest(endpoint, body, headers = {}) { + logClientRawRequest(endpoint, body, headers = {}, effectiveInput) { payloads.clientRawRequest = { timestamp: new Date().toISOString(), endpoint, headers: maskSensitiveHeaders(headers), body: cloneBoundedForLog(body), + // The actual `input` this request dispatched with, captured AFTER + // OmniRoute's own previous_response_id reconstruction (see + // src/sse/handlers/chat.ts) -- `body` above is deliberately the + // pre-reconstruction raw client bytes (captureDeferredClientRawBody's + // whole point) and is NOT what got sent for a continued turn. + // resolvePreviousResponseState must chain off this field, not + // `body.input`: reading the raw pre-reconstruction input for a + // request that was itself a continuation compounds into progressively + // truncated history a few hops deep (live incident 2026-09-03, + // manifested as a malformed request with no leading system/user + // message rejected by the upstream provider). + ...(effectiveInput !== undefined + ? { effectiveInput: cloneBoundedForLog(effectiveInput) } + : {}), }; }, diff --git a/open-sse/utils/responsesFailureOutput.ts b/open-sse/utils/responsesFailureOutput.ts new file mode 100644 index 0000000000..e085ba3b69 --- /dev/null +++ b/open-sse/utils/responsesFailureOutput.ts @@ -0,0 +1,70 @@ +type JsonRecord = Record; + +export type ResponsesFailureOutputStringField = "id" | "text" | "refusal"; + +export type ResponsesFailureOutputStringProjector = ( + field: ResponsesFailureOutputStringField, + value: string +) => string; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +/** + * Retain only public assistant text/refusal output from a failed Responses payload. + * Failure envelopes may contain reasoning, tool arguments, annotations, commentary, + * or provider diagnostics, so every retained field is reconstructed explicitly. + */ +export function projectResponsesFailureOutput( + value: unknown, + projectString: ResponsesFailureOutputStringProjector +): JsonRecord[] { + if (!Array.isArray(value)) return []; + + const output: JsonRecord[] = []; + for (const item of value) { + const record = asRecord(item); + if (record.type !== "message" || record.role !== "assistant" || record.phase === "commentary") { + continue; + } + + const content: JsonRecord[] = []; + if (Array.isArray(record.content)) { + for (const part of record.content) { + const contentPart = asRecord(part); + if (contentPart.phase === "commentary") continue; + if (contentPart.type === "output_text" && typeof contentPart.text === "string") { + content.push({ + type: "output_text", + text: projectString("text", contentPart.text), + // Preserve the required Responses schema without forwarding any + // untrusted citation/file metadata supplied by the provider. + annotations: [], + }); + } else if (contentPart.type === "refusal" && typeof contentPart.refusal === "string") { + content.push({ + type: "refusal", + refusal: projectString("refusal", contentPart.refusal), + }); + } + } + } + + const projected: JsonRecord = { + type: "message", + role: "assistant", + content, + }; + if (typeof record.id === "string") projected.id = projectString("id", record.id); + if ( + record.status === "in_progress" || + record.status === "completed" || + record.status === "incomplete" + ) { + projected.status = record.status; + } + output.push(projected); + } + return output; +} diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index d33cc8a526..6b3c44cfcc 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -50,9 +50,11 @@ import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./te import { stripObfuscationZeroWidth } from "./zeroWidth.ts"; import { formatTranslatedStreamError, - normalizeStreamFailurePayload, + prepareTranslatedStreamFailure, + projectStreamFailureEvent, type StreamFailurePayload, } from "./streamErrorFormat.ts"; +import { createStreamFailureAborter } from "./streamFailureBoundary.ts"; import { recordToolLatency } from "../services/toolLatencyTracker.ts"; import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts"; import { @@ -178,8 +180,6 @@ type StreamOptions = { * codex-compatible `namespace` + `name` fields. */ requestToolIdentityMap?: Map | null; - /** High water mark for the TransformStream internal buffer (default: 16384) */ - highWaterMark?: number; }; type TranslateState = ReturnType & { @@ -1036,11 +1036,11 @@ export function createSSEStream(options: StreamOptions = {}) { totalContentLength > 0 ) { const estimated = estimateUsage(body, totalContentLength, sourceFormat); - itemSanitized.usage = filterUsageForFormat(estimated, sourceFormat); + itemSanitized.usage = timing.withTps(filterUsageForFormat(estimated, sourceFormat)); state.usage = estimated; } else if (state?.finishReason && isFinishChunk && state.usage) { const buffered = addBufferToUsage(state.usage); - itemSanitized.usage = filterUsageForFormat(buffered, sourceFormat); + itemSanitized.usage = timing.withTps(filterUsageForFormat(buffered, sourceFormat)); } if ( @@ -1079,8 +1079,8 @@ export function createSSEStream(options: StreamOptions = {}) { model, cacheHit: false, latencyMs: Date.now() - streamStartedAt, - usage: finalUsage, - costUsd, + usage: timing.withTps(finalUsage), + costUsd, ttftMs: timing.ttftMs(), }); if (!comment) return; reqLogger?.appendConvertedChunk?.(comment); @@ -1175,7 +1175,39 @@ export function createSSEStream(options: StreamOptions = {}) { } }; - const highWaterMark = options.highWaterMark ?? 16384; + const abortStreamFailure = createStreamFailureAborter({ + onFailure, + onComplete, + getUsage: () => state?.usage, + timing, + buildProviderPayload: () => + providerPayloadCollector.build(providerPayloadCollector.getSummary(), { + includeEvents: false, + }), + buildClientPayload: (body) => clientPayloadCollector.build(body, { includeEvents: false }), + clearIdleTimer, + clearPendingRequest: clearPendingRequestFromStream, + markPendingRequestCleared, + model, + }); + + const emitTranslatedFailureAndAbort = ( + controller: TransformStreamDefaultController, + payload: unknown + ): boolean => { + const failure = prepareTranslatedStreamFailure(payload); + if (!failure) return false; + providerPayloadCollector.push(failure.providerPayload); + const output = formatTranslatedStreamError(failure.record, sourceFormat); + reqLogger?.appendConvertedChunk?.(output); + forward(controller, encoder.encode(output)); + upstreamErrorForwarded = true; + doneSent = true; + abortStreamFailure(controller, failure.internalFailure, failure.publicMessage, { + notifyComplete: true, + }); + return true; + }; return new TransformStream( { @@ -1241,6 +1273,7 @@ export function createSSEStream(options: StreamOptions = {}) { let injectedUsage = false; let clientPayload: unknown = null; let failurePayload: StreamFailurePayload | null = null; + let publicFailureMessage: string | null = null; if (skipPassthroughEvent) { if (!trimmed) { @@ -1328,6 +1361,14 @@ export function createSSEStream(options: StreamOptions = {}) { if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") { try { let parsed = parsedPassthroughData ?? JSON.parse(trimmed.slice(5).trim()); + const projectedFailure = projectStreamFailureEvent(parsed); + if (projectedFailure) { + parsed = projectedFailure.publicPayload; + failurePayload = projectedFailure.internalFailure; + publicFailureMessage = projectedFailure.publicMessage; + output = `data: ${JSON.stringify(parsed)}\n\n`; + injectedUsage = true; + } // Some upstream Responses-compatible providers leak an initial Chat Completions // bootstrap chunk (assistant role + empty content) before emitting proper @@ -1484,9 +1525,6 @@ export function createSSEStream(options: StreamOptions = {}) { ); } } - if (parsed.type === "response.failed") { - failurePayload = normalizeStreamFailurePayload(parsed); - } if ( parsed.type === "response.reasoning_summary_text.delta" || parsed.type === "response.reasoning_summary_text.done" || @@ -1810,20 +1848,22 @@ export function createSSEStream(options: StreamOptions = {}) { const rawDelta = parsed.choices?.[0]?.delta; const hadReasoningAlias = hasUnsupportedReasoningSignal(rawDelta); - parsed = sanitizeStreamingChunk(parsed); - if ( - parsed && - typeof parsed === "object" && - !Array.isArray(parsed) && - (parsed as Record)[OMIT_STREAMING_CHUNK_MARKER] === true - ) { - continue; + if (!projectedFailure) { + parsed = sanitizeStreamingChunk(parsed); + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + (parsed as Record)[OMIT_STREAMING_CHUNK_MARKER] === true + ) { + continue; + } } const restoredOpenAIToolName = restoreOpenAIToolNames(parsed, toolNameMap); const idFixed = hadNonStringTopLevelId ? false : fixInvalidId(parsed); - if (!hasValuableContent(parsed, FORMATS.OPENAI)) { + if (!projectedFailure && !hasValuableContent(parsed, FORMATS.OPENAI)) { continue; } @@ -2006,7 +2046,7 @@ export function createSSEStream(options: StreamOptions = {}) { // estimate is now emitted in flush(), only when the upstream stayed silent. if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) { const buffered = addBufferToUsage(usage); - parsed.usage = filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI); + parsed.usage = timing.withTps(filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI)); output = `data: ${JSON.stringify(parsed)}\n\n`; passthroughForwardedUsage = true; injectedUsage = true; @@ -2052,20 +2092,10 @@ export function createSSEStream(options: StreamOptions = {}) { reqLogger?.appendConvertedChunk?.(output); forward(controller, encoder.encode(output)); if (failurePayload) { - let failureHandled = false; - if (onFailure) { - try { - failureHandled = onFailure(failurePayload) === true; - } catch (e) { - console.debug(`[STREAM] onFailure callback error:`, e); - } - } - clearIdleTimer(); - if (!failureHandled) { - clearPendingRequestFromStream(); - } - controller.error( - markPendingRequestCleared(new Error(failurePayload.message || "Upstream failure")) + abortStreamFailure( + controller, + failurePayload, + publicFailureMessage || "Upstream failure" ); return; } @@ -2087,14 +2117,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (upstreamErrorForwarded) continue; - if (parsed.error) { - const output = formatTranslatedStreamError(parsed, sourceFormat); - reqLogger?.appendConvertedChunk?.(output); - forward(controller, encoder.encode(output)); - upstreamErrorForwarded = true; - doneSent = true; - continue; - } + if (emitTranslatedFailureAndAbort(controller, parsed)) return; // #5786 — drop replayed Responses-API events (identical/lower sequence_number // re-sent on an upstream reconnect) so their deltas are not glued twice into @@ -2356,6 +2379,8 @@ export function createSSEStream(options: StreamOptions = {}) { ]) as JsonRecord, restoreOpenAIToolNames: (parsed: JsonRecord) => restoreOpenAIToolNames(parsed, toolNameMap), + abortFailure: (failure: StreamFailurePayload, publicMessage: string) => + abortStreamFailure(controller, failure, publicMessage), }; for (const line of normalizedTailLines) { @@ -2369,12 +2394,18 @@ export function createSSEStream(options: StreamOptions = {}) { clearPendingPassthroughEvent(); } else if (buffer) { let output = buffer; + let bufferedProjectedFailure: ReturnType = null; if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) { output = "data: " + buffer.slice(5); } - const bufferedPayload = parseSSELine(bufferedLine); + let bufferedPayload = parseSSELine(bufferedLine); if (bufferedPayload) { providerPayloadCollector.push(bufferedPayload); + bufferedProjectedFailure = projectStreamFailureEvent(bufferedPayload); + if (bufferedProjectedFailure) { + bufferedPayload = bufferedProjectedFailure.publicPayload; + output = `data: ${JSON.stringify(bufferedPayload)}\n\n`; + } if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat)) output = `data: ${JSON.stringify(bufferedPayload)}\n\n`; if ( @@ -2423,6 +2454,14 @@ export function createSSEStream(options: StreamOptions = {}) { } reqLogger?.appendConvertedChunk?.(output); forward(controller, encoder.encode(output)); + if (bufferedProjectedFailure) { + abortStreamFailure( + controller, + bufferedProjectedFailure.internalFailure, + bufferedProjectedFailure.publicMessage + ); + return; + } } if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { @@ -2532,7 +2571,7 @@ export function createSSEStream(options: StreamOptions = {}) { created: Math.floor(Date.now() / 1000), model, choices: [], - usage: filterUsageForFormat(usage, sourceFormat || FORMATS.OPENAI), + usage: timing.withTps(filterUsageForFormat(usage, sourceFormat || FORMATS.OPENAI)), }; const usageOutput = `data: ${JSON.stringify(usageOnlyChunk)}\n\n`; reqLogger?.appendConvertedChunk?.(usageOutput); @@ -2673,6 +2712,7 @@ export function createSSEStream(options: StreamOptions = {}) { if (buffer.trim()) { const parsed = parseSSELine(buffer.trim()); if (parsed && !parsed.done) { + if (emitTranslatedFailureAndAbort(controller, parsed)) return; providerPayloadCollector.push(parsed); // Extract usage from remaining buffer — if the usage-bearing event // (e.g. response.completed) is the last SSE line, it ends up here @@ -2737,58 +2777,9 @@ export function createSSEStream(options: StreamOptions = {}) { // terminal signal for the client. } - let failureHandled = false; - if (onFailure) { - try { - timing.markInterrupted(); - failureHandled = - onFailure({ - status: err.status, - message: err.message, - code: err.code, - type: err.type, - }) === true; - } catch (e) { - console.debug(`[STREAM] onFailure callback error (${model || "unknown"}):`, e); - } - } - const errorBody = buildErrorBody(err.status, err.message); - if (onComplete) { - try { - onComplete({ - status: err.status, - usage: state?.usage, - responseBody: errorBody, - ttft: timing.ttftMs(), - itlMs: timing.avgItlMs(), - interrupted: timing.interrupted, - error: err.message, - errorCode: err.code, - providerPayload: providerPayloadCollector.build( - providerPayloadCollector.getSummary(), - { includeEvents: false } - ), - clientPayload: clientPayloadCollector.build(errorBody, { - includeEvents: false, - }), - }); - failureHandled = true; - } catch (e) { - console.debug( - `[STREAM] onComplete callback error in error path (${model || "unknown"}):`, - e - ); - } - } - - clearIdleTimer(); - if (!failureHandled) { - clearPendingRequestFromStream(); - } - controller.error( - markPendingRequestCleared(new Error(err.message || "Upstream failure")) - ); + const publicErrorMessage = errorBody.error.message; + abortStreamFailure(controller, err, publicErrorMessage, { notifyComplete: true }); return; } @@ -2996,8 +2987,8 @@ export function createSSEStream(options: StreamOptions = {}) { clearIdleTimer(); }, }, - { highWaterMark }, - { highWaterMark } + { highWaterMark: 16384 }, + { highWaterMark: 16384 } ); } @@ -3019,8 +3010,7 @@ export function createSSETransformStreamWithLogger( copilotCompatibleReasoning = false, suppressThinkClose = false, customToolNames: ReadonlySet = new Set(), - requestToolIdentityMap: Map | null = null, - highWaterMark?: number + requestToolIdentityMap: Map | null = null ) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, @@ -3039,7 +3029,6 @@ export function createSSETransformStreamWithLogger( suppressThinkClose, customToolNames, requestToolIdentityMap, - highWaterMark, }); } @@ -3054,8 +3043,7 @@ export function createPassthroughStreamWithLogger( apiKeyInfo: unknown = null, onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise) | null = null, clientResponseFormat: string | null = null, - requestToolIdentityMap: Map | null = null, - highWaterMark?: number + requestToolIdentityMap: Map | null = null ) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, @@ -3070,7 +3058,6 @@ export function createPassthroughStreamWithLogger( onFailure, clientResponseFormat, requestToolIdentityMap, - highWaterMark, }); } diff --git a/open-sse/utils/streamErrorFormat.ts b/open-sse/utils/streamErrorFormat.ts index 56b747f4e4..05a065a864 100644 --- a/open-sse/utils/streamErrorFormat.ts +++ b/open-sse/utils/streamErrorFormat.ts @@ -1,5 +1,6 @@ import { FORMATS } from "../translator/formats.ts"; -import { buildErrorBody } from "./error.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "./error.ts"; +import { projectResponsesFailureOutput } from "./responsesFailureOutput.ts"; /** * Upstream stream-failure normalization + client-format error framing. @@ -17,10 +18,125 @@ export type StreamFailurePayload = { type?: string; }; +export type ProjectedStreamFailureEvent = { + internalFailure: StreamFailurePayload; + publicMessage: string; + publicPayload: JsonRecord; +}; + +export type PreparedTranslatedStreamFailure = { + record: JsonRecord; + providerPayload: JsonRecord; + internalFailure: StreamFailurePayload; + publicMessage: string; +}; + +export function projectCompletedStreamError( + failure: StreamFailurePayload | null | undefined +): JsonRecord | null { + if (!failure) return null; + const status = Number.isInteger(failure.status) ? failure.status : 502; + return buildErrorBody(status, failure.message, undefined, { + type: failure.type ?? "server_error", + code: String(failure.status ?? 502), + }).error; +} + function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } +const RESPONSES_FAILURE_SCALAR_FIELDS = [ + "id", + "object", + "created_at", + "completed_at", + "background", + "model", + "max_output_tokens", + "max_tool_calls", + "parallel_tool_calls", + "previous_response_id", + "service_tier", + "store", + "temperature", + "top_p", + "truncation", +] as const; + +const ABSOLUTE_PATH_SEGMENT = + /(?:^|[\\/])(?:Users|app|etc|home|opt|private|root|srv|tmp|usr|var|workspace)[\\/]/i; + +function projectResponsesFailureString(key: string, value: string): string { + const sanitized = sanitizeErrorMessage(value); + if (sanitized !== value || ABSOLUTE_PATH_SEGMENT.test(value)) return "[REDACTED]"; + if ( + (key === "id" || key === "previous_response_id") && + !/^[A-Za-z0-9][\w.:-]{0,511}$/.test(value) + ) { + return "[REDACTED]"; + } + if (key === "model" && !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value)) { + return "[REDACTED]"; + } + return sanitized; +} + +function projectResponsesFailureUsage(value: unknown): JsonRecord | null { + const usage = asRecord(value); + const projected: JsonRecord = {}; + for (const key of ["input_tokens", "output_tokens", "total_tokens"] as const) { + if (typeof usage[key] === "number" && Number.isFinite(usage[key])) { + projected[key] = usage[key]; + } + } + const allowedDetailFields = { + input_tokens_details: new Set(["cached_tokens"]), + output_tokens_details: new Set([ + "reasoning_tokens", + "accepted_prediction_tokens", + "rejected_prediction_tokens", + ]), + } as const; + for (const key of ["input_tokens_details", "output_tokens_details"] as const) { + const details = asRecord(usage[key]); + const projectedDetails = Object.fromEntries( + Object.entries(details).filter( + ([detailKey, detail]) => + allowedDetailFields[key].has(detailKey) && + typeof detail === "number" && + Number.isFinite(detail) + ) + ); + if (Object.keys(projectedDetails).length > 0) projected[key] = projectedDetails; + } + return Object.keys(projected).length > 0 ? projected : null; +} + +function projectResponsesFailureObject(response: JsonRecord, publicError: JsonRecord): JsonRecord { + const projected: JsonRecord = { status: "failed", error: publicError }; + + // A failed Responses event is an error boundary, so copy only documented protocol + // fields with their scalar shapes. Spreading the upstream object would also publish + // provider-only siblings such as diagnostics, settings, raw messages, or stack traces. + for (const key of RESPONSES_FAILURE_SCALAR_FIELDS) { + const value = response[key]; + if (typeof value === "string") projected[key] = projectResponsesFailureString(key, value); + else if (value === null || typeof value === "number" || typeof value === "boolean") + projected[key] = value; + } + if (Array.isArray(response.output)) { + projected.output = projectResponsesFailureOutput( + response.output, + projectResponsesFailureString + ); + } + const usage = projectResponsesFailureUsage(response.usage); + if (usage) projected.usage = usage; + if ("last_error" in response) projected.last_error = publicError; + return projected; +} + function toStreamFailureStatus(value: unknown): number | null { if (typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599) { return value; @@ -48,19 +164,30 @@ function looksLikeStreamRateLimit(code: string, type: string, message: string): export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePayload | null { const record = payload && typeof payload === "object" ? (payload as JsonRecord) : {}; const response = asRecord(record.response); - const error = Object.keys(asRecord(response.error)).length - ? asRecord(response.error) - : Object.keys(asRecord(record.error)).length - ? asRecord(record.error) - : record; + const responseError = response.error; + const responseLastError = response.last_error; + const rootError = record.error; + const error = Object.keys(asRecord(responseError)).length + ? asRecord(responseError) + : Object.keys(asRecord(responseLastError)).length + ? asRecord(responseLastError) + : Object.keys(asRecord(rootError)).length + ? asRecord(rootError) + : record; const code = typeof error.code === "string" ? error.code : "upstream_error"; const type = typeof error.type === "string" ? error.type : undefined; const message = typeof error.message === "string" && error.message.trim() ? error.message - : typeof record.message === "string" && record.message.trim() - ? record.message - : "Upstream failure"; + : typeof responseError === "string" && responseError.trim() + ? responseError + : typeof responseLastError === "string" && responseLastError.trim() + ? responseLastError + : typeof rootError === "string" && rootError.trim() + ? rootError + : typeof record.message === "string" && record.message.trim() + ? record.message + : "Upstream failure"; const status = toStreamFailureStatus(error.status_code) ?? toStreamFailureStatus(error.status) ?? @@ -78,6 +205,80 @@ export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePa }; } +export function prepareTranslatedStreamFailure( + payload: unknown +): PreparedTranslatedStreamFailure | null { + const record = asRecord(payload); + const projected = projectStreamFailureEvent(record); + if (!projected && !record.error) return null; + return { + record, + providerPayload: projected?.publicPayload ?? record, + internalFailure: projected?.internalFailure ?? + normalizeStreamFailurePayload(record) ?? { + status: 502, + message: "Upstream failure", + code: "stream_error", + type: "server_error", + }, + publicMessage: projected?.publicMessage || "Upstream failure", + }; +} + +/** + * Project same-format upstream failure events before they cross the client/log boundary. + * + * `internalFailure` intentionally retains the raw provider wording: account fallback uses it + * to classify quota/reset hints before the persistence seam sanitizes the stored message. + * `publicPayload` is a separate protocol-preserving object whose failure subtrees are rebuilt by + * the canonical public boundary. Callers must never forward the raw payload for these events. + */ +export function projectStreamFailureEvent(payload: unknown): ProjectedStreamFailureEvent | null { + const record = asRecord(payload); + const response = asRecord(record.response); + const hasRootError = + Object.keys(asRecord(record.error)).length > 0 || + (typeof record.error === "string" && record.error.trim().length > 0); + const isResponsesFailure = + record.type === "response.failed" || + (record.type === "response.completed" && response.status === "failed"); + const isClaudeFailure = record.type === "error"; + if (!isResponsesFailure && !isClaudeFailure && !hasRootError) return null; + + const internalFailure = normalizeStreamFailurePayload(record); + if (!internalFailure) return null; + + const publicError = buildErrorBody(internalFailure.status, internalFailure.message, undefined, { + type: internalFailure.type ?? "server_error", + code: internalFailure.code ?? "stream_error", + }).error; + let publicPayload: JsonRecord; + if (isResponsesFailure) { + // Preserve protocol metadata and partial `output[].content[]` without passing output + // through a bounded-depth details sanitizer, while excluding arbitrary diagnostic siblings. + const publicResponse = projectResponsesFailureObject(response, publicError); + publicPayload = { + type: record.type, + response: publicResponse, + ...(typeof record.sequence_number === "number" + ? { sequence_number: record.sequence_number } + : {}), + }; + } else if (isClaudeFailure) { + publicPayload = { type: "error", error: publicError }; + } else { + // OpenAI-compatible HTTP-200 streams commonly emit a bare `{ error: ... }` frame. + // Rebuild the complete public envelope so provider-only fields cannot cross the wire. + publicPayload = { error: publicError }; + } + + return { + internalFailure, + publicMessage: publicError.message, + publicPayload, + }; +} + export function formatTranslatedStreamError(payload: unknown, sourceFormat?: string): string { const failure = normalizeStreamFailurePayload(payload) ?? { status: 502, diff --git a/open-sse/utils/streamFailureBoundary.ts b/open-sse/utils/streamFailureBoundary.ts new file mode 100644 index 0000000000..bb3da7c850 --- /dev/null +++ b/open-sse/utils/streamFailureBoundary.ts @@ -0,0 +1,76 @@ +import { buildErrorBody } from "./error.ts"; +import type { StreamFailurePayload } from "./streamErrorFormat.ts"; +import type { StreamTiming } from "./streamTiming.ts"; + +type CompletePayload = { + status: number; + usage: unknown; + responseBody: unknown; + providerPayload: unknown; + clientPayload: unknown; + error: string; + errorCode?: string; + ttft: number | null; + itlMs: number | null; + interrupted: boolean; +}; + +type AborterContext = { + onFailure?: ((payload: StreamFailurePayload) => boolean | void | Promise) | null; + onComplete?: ((payload: CompletePayload) => void) | null; + getUsage: () => unknown; + timing: StreamTiming; + buildProviderPayload: () => unknown; + buildClientPayload: (body: unknown) => unknown; + clearIdleTimer: () => void; + clearPendingRequest: () => void; + markPendingRequestCleared: (error: Error) => Error; + model?: string | null; +}; + +export function createStreamFailureAborter(context: AborterContext) { + return ( + controller: TransformStreamDefaultController, + failure: StreamFailurePayload, + publicMessage: string, + options: { notifyComplete?: boolean } = {} + ): void => { + let handled = false; + context.timing.markInterrupted(); + if (context.onFailure) { + try { + handled = context.onFailure(failure) === true; + } catch (error) { + console.debug("[STREAM] onFailure callback error:", error); + } + } + let safeMessage = publicMessage || "Upstream failure"; + if (options.notifyComplete && context.onComplete) { + const body = buildErrorBody(failure.status, failure.message); + safeMessage = body.error.message; + try { + context.onComplete({ + status: failure.status, + usage: context.getUsage(), + responseBody: body, + ttft: context.timing.ttftMs(), + itlMs: context.timing.avgItlMs(), + interrupted: context.timing.interrupted, + error: safeMessage, + errorCode: failure.code, + providerPayload: context.buildProviderPayload(), + clientPayload: context.buildClientPayload(body), + }); + handled = true; + } catch (error) { + console.debug( + `[STREAM] onComplete callback error in error path (${context.model || "unknown"}):`, + error + ); + } + } + context.clearIdleTimer(); + if (!handled) context.clearPendingRequest(); + controller.error(context.markPendingRequestCleared(new Error(safeMessage))); + }; +} diff --git a/open-sse/utils/streamFailureFinalization.ts b/open-sse/utils/streamFailureFinalization.ts index 7d4e57ffba..38a740d1fb 100644 --- a/open-sse/utils/streamFailureFinalization.ts +++ b/open-sse/utils/streamFailureFinalization.ts @@ -5,6 +5,7 @@ import { import { HTTP_STATUS } from "../config/constants.ts"; import { buildErrorBody } from "./error.ts"; +import { sanitizeErrorMessage } from "./errorSanitization.ts"; export type StreamCompletionPayload = { status: number; @@ -129,9 +130,7 @@ export function finalizeStreamRequestLog({ } else { console.warn( "finalizeMostRecentPendingRequest failed:", - error && typeof error === "object" && "message" in error - ? (error as { message?: unknown }).message - : error + sanitizeErrorMessage(error) || "Stream request finalization failed" ); } } catch {} @@ -158,12 +157,12 @@ export function createStreamFailureFinalizers({ const status = failure.status || HTTP_STATUS.BAD_GATEWAY; const message = failure.message || "Upstream stream error"; - const code = failure.code || failure.type || String(status); const classification = failure.code || failure.type ? { code: failure.code, type: failure.type } : undefined; + const errorBody = buildErrorBody(status, message, undefined, classification); + const projectedCode = errorBody.error.code || String(status); if (!isFailureCompletionRecorded()) { - const errorBody = buildErrorBody(status, message, undefined, classification); onStreamComplete({ status, usage: null, @@ -171,12 +170,12 @@ export function createStreamFailureFinalizers({ providerPayload: errorBody, clientPayload: errorBody, error: message, - errorCode: code, + errorCode: projectedCode, ttft: 0, }); } - persistFailureUsage(status, code); + persistFailureUsage(status, projectedCode); try { onStreamFailure?.(failure); } catch { diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index 3774cc0260..908c18725c 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -422,56 +422,66 @@ function prependBufferedChunks( reader: ReadableStreamDefaultReader ): ReadableStream { let bufferedIndex = 0; - let cancelled = false; + let readInFlight = false; + let cancelRequested = false; let readerReleased = false; const releaseReader = () => { if (readerReleased) return; readerReleased = true; + reader.releaseLock(); + }; + + const cancelReader = (reason: unknown) => { + if (cancelRequested) return; + cancelRequested = true; + try { - reader.releaseLock(); + // The provider controls this promise and may never settle. Cancellation + // of the replay stream must remain bounded, so cleanup is deliberately + // fire-and-forget while the in-flight read releases the lock in `pull`. + void reader.cancel(reason).catch(() => {}); } catch { - // A hostile source can keep a read/cancel pending forever. The public stream must - // remain cancellable even when its abandoned source cannot release immediately. + // A synchronous cancellation failure is cleanup-only; the downstream + // stream has already been cancelled by its consumer. } + + if (!readInFlight) releaseReader(); }; return new ReadableStream({ async pull(controller) { - if (cancelled) return; + if (cancelRequested) return; - // Replay exactly one readiness chunk per pull. Keeping the first buffered chunk at - // the stream's default high-water mark prevents an eager read of a later upstream - // failure from discarding that legitimate prefix before the caller attaches. + // Replay exactly one readiness chunk per demand. Reading the source + // eagerly here would let a subsequent source error clear this queue + // before the consumer has observed the buffered prefix. if (bufferedIndex < chunks.length) { controller.enqueue(chunks[bufferedIndex]); bufferedIndex += 1; return; } + readInFlight = true; try { const { done, value } = await reader.read(); - if (cancelled) return; + if (cancelRequested) return; if (done) { releaseReader(); controller.close(); - return; + } else if (value) { + controller.enqueue(value); } - if (value) controller.enqueue(value); } catch (error) { releaseReader(); - if (!cancelled) controller.error(error); + if (!cancelRequested) controller.error(error); + } finally { + readInFlight = false; + if (cancelRequested) releaseReader(); } }, cancel(reason) { - if (cancelled) return; - cancelled = true; - // Do not await a provider's cancel hook: a hostile or stalled source must not make - // downstream cancellation hang. Release the lock once cancellation actually settles. - void reader - .cancel(reason) - .catch(() => {}) - .finally(releaseReader); + cancelReader(reason); }, }); } diff --git a/open-sse/utils/streamTiming.ts b/open-sse/utils/streamTiming.ts index ee5385ebb3..28b1329a7c 100644 --- a/open-sse/utils/streamTiming.ts +++ b/open-sse/utils/streamTiming.ts @@ -30,6 +30,8 @@ * instances as absolute times — the same convention `earlyStreamKeepalive.ts` * already follows on this streaming path. */ +import { attachTokensPerSecond, generationDurationMs } from "./generationThroughput.ts"; + export interface StreamTiming { startedAt: number; firstByteAt: number | null; @@ -48,6 +50,10 @@ export interface StreamTiming { avgItlMs(): number | null; /** Time from stream start to completion (ms). */ totalMs(): number; + /** + * Attach gateway-measured tok/s (TTFT excluded). No-op when TTFT is unknown. + */ + withTps(usage: T): T; } /** Max number of inter-chunk samples kept (bounds memory). */ @@ -88,6 +94,9 @@ export function createStreamTiming(): StreamTiming { totalMs() { return performance.now() - this.startedAt; }, + withTps(usage) { + return attachTokensPerSecond(usage, generationDurationMs(this.totalMs(), this.ttftMs())); + }, }; return timing; } diff --git a/open-sse/utils/upstreamErrorPassthrough.ts b/open-sse/utils/upstreamErrorPassthrough.ts index b62fff2adf..30fa4ffff2 100644 --- a/open-sse/utils/upstreamErrorPassthrough.ts +++ b/open-sse/utils/upstreamErrorPassthrough.ts @@ -1,13 +1,16 @@ -import { RAW_CREDENTIAL_PATTERNS } from "./error.ts"; +import { + containsSensitiveErrorCredential, + sanitizePassthroughUpstreamDetails, +} from "./errorSanitization.ts"; + /** * Selective upstream 4xx error passthrough (Claude Code auto-recover contract). * - * Claude Code matches the upstream error WORDING to auto-disable capabilities - * (thinking / output_config) for the rest of the conversation. Wrapping the body - * via buildErrorBody() truncates the message and breaks that recovery. For - * upstream-originated 4xx errors the body is the provider's public API message — - * not our internals — so it is safe and required to relay it verbatim. - * OmniRoute-generated errors MUST keep using buildErrorBody() (Hard Rule #12). + * Claude Code matches upstream error wording to auto-disable capabilities + * (thinking / output_config) for the rest of the conversation. This path keeps + * the wording and JSON shape required for that recovery after applying the + * canonical recursive sanitizer. OmniRoute-generated errors MUST keep using + * buildErrorBody() (Hard Rule #12). */ const PASSTHROUGH_MIN = 400; const PASSTHROUGH_MAX = 499; @@ -18,39 +21,28 @@ const EXCLUDED_STATUSES = new Set([401, 403, 407]); const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i; // #10898-sec / secret-in-error hardening: some providers echo the offending // request (including an Authorization header or api key) inside a 400/422/429 -// validation body. Passthrough relays the body VERBATIM (the Claude Code -// capability-recovery contract needs the exact wording), so we cannot key-drop -// via sanitizeUpstreamDetails without breaking that contract. Instead, if the -// body actually carries a credential pattern, REFUSE passthrough and let the -// caller fall back to the sanitized buildErrorBody path. Bodies without a -// secret (the overwhelming majority, carrying capability/quota wording) still -// relay verbatim. Mirrors the vocabulary of redactSensitiveErrorText in error.ts. -const LABELLED_CREDENTIAL_RE = - /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i; - -/** - * The raw-token shapes (sk-…, AIza…, JWT) come from error.ts's - * RAW_CREDENTIAL_PATTERNS rather than a second local copy. The previous local - * copy carried `sk-` while the sanitizer this file falls back to did NOT, so a - * body recognized as leaky here was returned unredacted there - * (GHSA-qv45-56jc-4wmj). One source, no drift. - */ -function containsCredential(text: string): boolean { - if (LABELLED_CREDENTIAL_RE.test(text)) return true; - return RAW_CREDENTIAL_PATTERNS.some((pattern) => { - pattern.lastIndex = 0; // the shared patterns are /g — reset before .test() - return pattern.test(text); - }); -} +// validation body. If the body carries a credential pattern, REFUSE passthrough +// before the recursive sanitizer so the caller falls back to buildErrorBody. +// Eligible JSON retains its safe shape and capability/quota wording after the +// recursive projection. Mirrors redactSensitiveErrorText in errorSanitization.ts. +const CREDENTIAL_LEAK_RE = + /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i; export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean { if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false; if (EXCLUDED_STATUSES.has(statusCode)) return false; if (!upstreamBody || typeof upstreamBody !== "object") return false; - const text = JSON.stringify(upstreamBody); + let text: string | undefined; + try { + text = JSON.stringify(upstreamBody); + } catch { + // Relay only JSON-stable objects; cyclic/BigInt/hostile toJSON bodies fail closed. + return false; + } + if (typeof text !== "string") return false; if (INTERNAL_LEAK_RE.test(text)) return false; // Refuse passthrough when the provider echoed a credential back to us. - if (containsCredential(text)) return false; + if (CREDENTIAL_LEAK_RE.test(text) || containsSensitiveErrorCredential(text)) return false; return true; } @@ -60,8 +52,18 @@ export function buildPassthroughErrorResponse( headers?: Record ): Response | null { if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null; - return new Response(JSON.stringify(upstreamBody), { - status: statusCode, - headers: { "Content-Type": "application/json", ...(headers || {}) }, - }); + try { + const sanitizedBody = sanitizePassthroughUpstreamDetails(upstreamBody); + const publicBody = + sanitizedBody && typeof sanitizedBody === "object" + ? sanitizedBody + : { error: { message: "Upstream error" } }; + return new Response(JSON.stringify(publicBody), { + status: statusCode, + headers: { "Content-Type": "application/json", ...(headers || {}) }, + }); + } catch { + // A proxy/getter may behave differently between eligibility and projection. + return null; + } } diff --git a/open-sse/utils/upstreamErrorResponse.ts b/open-sse/utils/upstreamErrorResponse.ts new file mode 100644 index 0000000000..1581160900 --- /dev/null +++ b/open-sse/utils/upstreamErrorResponse.ts @@ -0,0 +1,46 @@ +import { buildErrorBody, sanitizeUpstreamDetails } from "./error.ts"; + +interface SanitizedUpstreamErrorResponseOptions { + status: number; + rawBody: string; + fallbackMessage: string; + headers?: Record; +} + +/** + * Preserve a provider's JSON error shape while applying the canonical recursive sanitizer. + * Providers sometimes label plain text as JSON; those bodies use OmniRoute's canonical error + * envelope so the advertised content type always matches the response bytes. + */ +export function buildSanitizedUpstreamErrorResponse({ + status, + rawBody, + fallbackMessage, + headers, +}: SanitizedUpstreamErrorResponseOptions): Response { + const trimmedBody = rawBody.trim(); + + if (trimmedBody) { + try { + const parsedBody: unknown = JSON.parse(trimmedBody); + const serializedBody = JSON.stringify(sanitizeUpstreamDetails(parsedBody)); + if (serializedBody !== undefined) { + return new Response(serializedBody, { + status, + headers: { ...headers, "Content-Type": "application/json" }, + }); + } + } catch { + // Upstreams commonly return text or HTML despite an application/json response header. + // Treat it as an opaque message and use the canonical JSON envelope below. + } + } + + // Non-JSON is an opaque upstream body. Do not echo even sanitized fragments: + // provider HTML/plaintext can contain credentials or implementation details + // outside the patterns the canonical sanitizer knows about. + return new Response(JSON.stringify(buildErrorBody(status, fallbackMessage)), { + status, + headers: { ...headers, "Content-Type": "application/json" }, + }); +} diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 25a1a20d8b..d71b858cc8 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -293,6 +293,7 @@ export function filterUsageForFormat(usage: UsageLike | null | undefined, target "cache_read_input_tokens", "cache_creation_input_tokens", "estimated", + "tokens_per_second", ], [FORMATS.GEMINI]: [ "promptTokenCount", @@ -301,6 +302,7 @@ export function filterUsageForFormat(usage: UsageLike | null | undefined, target "cachedContentTokenCount", "thoughtsTokenCount", "estimated", + "tokens_per_second", ], [FORMATS.OPENAI_RESPONSES]: [ "input_tokens", @@ -312,6 +314,7 @@ export function filterUsageForFormat(usage: UsageLike | null | undefined, target "cost_in_usd_ticks", "server_side_tool_usage_details", "server_side_tool_usage", + "tokens_per_second", ], // OpenAI format (default for OPENAI, CODEX, KIRO, etc.) default: [ @@ -327,6 +330,7 @@ export function filterUsageForFormat(usage: UsageLike | null | undefined, target "cache_read_input_tokens", "cache_creation_input_tokens", "estimated", + "tokens_per_second", ], }; @@ -671,9 +675,20 @@ export function hasValidUsage(usage: UsageLike | null | undefined) { export function isEmptyUsage(usage: unknown): boolean { if (!usage || typeof usage !== "object" || Array.isArray(usage)) return true; const u = usage as Record; - for (const k of ["prompt_tokens","completion_tokens","total_tokens","input_tokens","output_tokens","promptTokenCount","candidatesTokenCount","totalTokenCount"]) { + for (const k of [ + "prompt_tokens", + "completion_tokens", + "total_tokens", + "input_tokens", + "output_tokens", + "promptTokenCount", + "candidatesTokenCount", + "totalTokenCount", + ]) { const v = u[k]; - if (typeof v === "number" && Number.isFinite(v)) { if (v > 0) return false; } + if (typeof v === "number" && Number.isFinite(v)) { + if (v > 0) return false; + } } return true; } diff --git a/scripts/check/check-docs-counts-sync.mjs b/scripts/check/check-docs-counts-sync.mjs index 29a42fca2a..4e74d30464 100644 --- a/scripts/check/check-docs-counts-sync.mjs +++ b/scripts/check/check-docs-counts-sync.mjs @@ -218,12 +218,16 @@ function readCodeFacts() { "const t=computeFreeModelTotals();const cli=Object.values(CLI_TOOLS);", "const by=(c)=>cli.filter(x=>x.category===c).length;", // "Free forever" = every provider whose free access renews or needs no key at all. - // one-time-initial (signup credits) and discontinued pools are excluded on purpose. + // one-time-initial (signup credits) and discontinued pools are excluded on purpose, + // and so is every eligibility-gated row: a provider nobody can sign up for without + // clearing a gate is not "free forever" for the reader of the headline. "const FOREVER=new Set(['recurring-monthly','recurring-daily','recurring-uncapped',", "'recurring-credit','keyless']);", - "const ff=new Set();for(const m of t.perModel)if(FOREVER.has(m.freeType))ff.add(m.provider);", + "const ff=new Set();for(const m of t.perModel)", + "if(FOREVER.has(m.freeType)&&!m.eligibilityGate)ff.add(m.provider);", 'console.log("@@"+JSON.stringify({freeSteady:t.steadyRecurringTokens,entries:t.perModel.length,', - "freeFirst:t.firstMonthRealisticTokens,freePools:t.poolCount,engines:ENGINE_IDS.length,", + "freeFirst:t.firstMonthRealisticTokens,freeGated:t.gatedRecurringTokens,", + "freePools:t.poolCount,engines:ENGINE_IDS.length,", "cliTotal:cli.length,cliCode:by('code'),cliAgent:by('agent'),", "mcpTools:countUniqueMcpTools(cols),mcpScopes:sc.size,providers:pids.size,freeForever:ff.size,", "modePacks:Object.keys(MODE_PACKS),", @@ -271,6 +275,20 @@ export function extractHeadlineClaims(content) { return claims; } +// The eligibility-gated figure ("+~6M behind regional identity verification") is validated +// with its own anchor so it can neither drift nor be silently dropped once it exists. +const GATED_ANCHOR = /^\s*behind regional identity verification/i; + +export function extractGatedClaims(content) { + const claims = []; + for (const m of content.matchAll(/\+?~?(\d+(?:\.\d+)?)([BM])\b/g)) { + const after = content.slice(m.index + m[0].length, m.index + m[0].length + 60); + if (!GATED_ANCHOR.test(after)) continue; + claims.push({ tokens: Number(m[1]) * (m[2] === "B" ? 1e9 : 1e6), unit: m[2], text: m[0] }); + } + return claims; +} + export function checkFreeTierHeadline(content, totals) { const claims = extractHeadlineClaims(content); if (!claims.length) return { ok: true, detail: "no aggregate free-tier headline in this file" }; @@ -279,14 +297,31 @@ export function checkFreeTierHeadline(content, totals) { const stale = claims.filter( (c) => Math.abs(c.value - steady) >= 0.05 && Math.abs(c.value - first) >= 0.05 ); - if (!stale.length) - return { ok: true, detail: `${claims.length} headline claim(s) match the live catalog` }; - return { - ok: false, - detail: + const problems = []; + if (stale.length) { + problems.push( `stale headline ${[...new Set(stale.map((c) => c.text))].join(", ")} — live catalog ` + - `computes ~${steady.toFixed(2)}B steady / ~${first.toFixed(2)}B first month`, - }; + `computes ~${steady.toFixed(2)}B steady / ~${first.toFixed(2)}B first month` + ); + } + if (totals.g != null && totals.g > 0) { + const gated = extractGatedClaims(content); + const tol = (c) => (c.unit === "B" ? 0.05e9 : 0.5e6); + const gatedStale = gated.filter((c) => Math.abs(c.tokens - totals.g) >= tol(c)); + if (!gated.length) { + problems.push( + `missing gated figure — live catalog computes ${Math.round(totals.g / 1e6)}M behind regional identity verification` + ); + } else if (gatedStale.length) { + problems.push( + `stale gated figure ${[...new Set(gatedStale.map((c) => c.text))].join(", ")} — live catalog ` + + `computes ${Math.round(totals.g / 1e6)}M behind regional identity verification` + ); + } + } + if (!problems.length) + return { ok: true, detail: `${claims.length} headline claim(s) match the live catalog` }; + return { ok: false, detail: problems.join("; ") }; } // PURE: docs prose that names the product version ("OmniRoute v3.8.50 ·", @@ -599,12 +634,12 @@ export function buildChecks() { }, { label: "Free-tier headline (live catalog)", - actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools`, + actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools / ${Math.round(f.freeGated / 1e6)}M gated`, docKey: "free-tier headline", strict: true, files: ["README.md", "docs/reference/FREE_TIERS.md"], validate: (content) => - checkFreeTierHeadline(content, { s: f.freeSteady, m: f.freeFirst }), + checkFreeTierHeadline(content, { s: f.freeSteady, m: f.freeFirst, g: f.freeGated }), }, claim( f.engines, diff --git a/scripts/research/gen-budget-card-svg.mjs b/scripts/research/gen-budget-card-svg.mjs index f253ded074..f2c01b082c 100644 --- a/scripts/research/gen-budget-card-svg.mjs +++ b/scripts/research/gen-budget-card-svg.mjs @@ -1,56 +1,81 @@ -// Generates docs/screenshots/free-tier-budget-card.svg from the per-model catalog. -// Run: node scripts/research/gen-budget-card-svg.mjs +#!/usr/bin/env node +// Generates the free-tier budget card from the per-model catalog, through the +// same function the docs gate and the dashboard use — never by parsing the data +// file with a regex (that silently skipped every row carrying an extra field). +// Run from the repo root: +// node --import tsx/esm scripts/research/gen-budget-card-svg.mjs [--out path.svg] import fs from "node:fs"; +import { computeFreeModelTotals } from "../../open-sse/config/freeModelCatalog.ts"; -const txt = fs.readFileSync("open-sse/config/freeModelCatalog.data.ts", "utf8"); -const recs = [ - ...txt.matchAll( - /\{ provider: "([^"]+)", modelId: "([^"]+)", displayName: "([^"]+)", monthlyTokens: (\d+), creditTokens: (\d+), freeType: "([^"]+)", poolKey: (null|"[^"]+"), tos: "([^"]+)" \}/g - ), -].map((m) => ({ - provider: m[1], - modelId: m[2], - displayName: m[3], - monthlyTokens: +m[4], - creditTokens: +m[5], - freeType: m[6], - poolKey: m[7] === "null" ? null : m[7].slice(1, -1), - tos: m[8], -})); +const outIdx = process.argv.indexOf("--out"); +if (outIdx >= 0 && !process.argv[outIdx + 1]) throw new Error("--out requires a path"); +const OUT = outIdx >= 0 ? process.argv[outIdx + 1] : "docs/screenshots/free-tier-budget-card.svg"; +const t = computeFreeModelTotals(); +const STEADY_TYPES = new Set(["recurring-daily", "recurring-monthly", "keyless"]); const fmt = (n) => - n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : n >= 1e6 ? Math.round(n / 1e6) + "M" : Math.round(n / 1e3) + "K"; + n >= 1e9 + ? (n / 1e9).toFixed(2) + "B" + : n >= 1e6 + ? Math.round(n / 1e6) + "M" + : Math.round(n / 1e3) + "K"; +// One bar segment per steady pool (largest member), gated rows excluded like the headline. const poolMap = new Map(); -for (const r of recs) { - if (!["recurring-daily", "recurring-monthly", "keyless"].includes(r.freeType)) continue; +for (const r of t.perModel) { + if (!STEADY_TYPES.has(r.freeType) || r.eligibilityGate) continue; const k = r.poolKey || `${r.provider}:${r.modelId}`; const cur = poolMap.get(k); if (!cur || r.monthlyTokens > cur.monthlyTokens) poolMap.set(k, r); } -const pools = [...poolMap.values()].filter((r) => r.monthlyTokens > 0).sort((a, b) => b.monthlyTokens - a.monthlyTokens); -const steady = pools.reduce((s, r) => s + r.monthlyTokens, 0); +const pools = [...poolMap.values()] + .filter((r) => r.monthlyTokens > 0) + .sort((a, b) => b.monthlyTokens - a.monthlyTokens); +const steady = t.steadyRecurringTokens; +const firstMonth = t.firstMonthRealisticTokens; +const gated = t.gatedRecurringTokens; const otMap = new Map(); -for (const r of recs) { - if (r.freeType !== "one-time-initial" || r.creditTokens <= 0) continue; +for (const r of t.perModel) { + // Gated rows are excluded here too — they are absent from firstMonthRealisticTokens. + if (r.freeType !== "one-time-initial" || r.creditTokens <= 0 || r.eligibilityGate) continue; const k = r.poolKey || r.provider; otMap.set(k, { provider: r.provider, v: Math.max(otMap.get(k)?.v || 0, r.creditTokens) }); } const oneTime = [...otMap.values()].sort((a, b) => b.v - a.v); const oneTimeSum = oneTime.reduce((s, r) => s + r.v, 0); -const firstMonth = steady + oneTimeSum; -const avoidProviders = [...new Set(recs.filter((r) => r.tos === "avoid").map((r) => r.provider))].length; -const uncappedProviders = [...new Set(recs.filter((r) => r.freeType === "recurring-uncapped").map((r) => r.provider))]; +const avoidProviders = new Set(t.perModel.filter((r) => r.tos === "avoid").map((r) => r.provider)) + .size; +const uncappedProviders = t.uncappedProviders; const GRID = pools.slice(0, 28); const STRIP = oneTime.slice(0, 9); -const PAL = ["#6c5ce7","#00b894","#0984e3","#e17055","#fdcb6e","#e84393","#00cec9","#d63031","#a29bfe","#55efc4","#74b9ff","#ffeaa7","#fab1a0","#81ecec"]; +const PAL = [ + "#6c5ce7", + "#00b894", + "#0984e3", + "#e17055", + "#fdcb6e", + "#e84393", + "#00cec9", + "#d63031", + "#a29bfe", + "#55efc4", + "#74b9ff", + "#ffeaa7", + "#fab1a0", + "#81ecec", +]; const color = (i) => PAL[i % PAL.length]; -const cleanName = (r) => (r.displayName || r.provider).replace(/\s*\(.*$/, "").replace(/ —.*$/, "").slice(0, 24); +const cleanName = (r) => + (r.displayName || r.provider) + .replace(/\s*\(.*$/, "") + .replace(/ —.*$/, "") + .slice(0, 24); -// bar segments (min width so every pool shows) -const BAR_X = 32, BAR_W = 836, MIN = 7; +const BAR_X = 32, + BAR_W = 836, + MIN = 7; const extra = BAR_W - MIN * GRID.length; let bx = BAR_X; const segs = GRID.map((r, i) => { @@ -60,11 +85,13 @@ const segs = GRID.map((r, i) => { return s; }); -const B = []; // body elements -// title -B.push(`Monthly free-token budget`); -B.push(`${pools.length} free pools · ${recs.length} models · one endpoint`); -// stats +const B = []; +B.push( + `Monthly free-token budget` +); +B.push( + `${pools.length} free pools · ${t.modelCount} models · one endpoint` +); const stat = (sx, label, val, vc) => { B.push(`${label}`); B.push(`${val}`); @@ -72,50 +99,90 @@ const stat = (sx, label, val, vc) => { stat(32, "Steady / month", `~${fmt(steady)}`, "#e6edf3"); stat(330, "First month (+ signup credits)", `~${fmt(firstMonth)}`, "#3fb950"); stat(700, "ToS-flagged (you decide)", `${avoidProviders} providers`, "#d29922"); -// bar -B.push(``); -B.push(``); -for (const s of segs) B.push(``); +B.push( + `` +); +B.push( + `` +); +for (const s of segs) + B.push( + `` + ); B.push(``); -B.push(`Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.`); -// model grid 4 cols -const COLS = 4, COLW = 213, GX = 32, GY = 200, RH = 30; +B.push( + `Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.` +); +const COLS = 4, + COLW = 213, + GX = 32, + GY = 200, + RH = 30; GRID.forEach((r, i) => { - const col = i % COLS, row = (i / COLS) | 0; - const cx = GX + col * COLW, cy = GY + row * RH; + const col = i % COLS, + row = (i / COLS) | 0; + const cx = GX + col * COLW, + cy = GY + row * RH; B.push(``); - B.push(`${cleanName(r)} ${fmt(r.monthlyTokens)}`); + B.push( + `${cleanName(r)} ${fmt(r.monthlyTokens)}` + ); }); let y = GY + Math.ceil(GRID.length / COLS) * RH + 6; -// first-month strip (wrapping) B.push(``); y += 26; -B.push(`+ First month: one-time signup credits (~${fmt(oneTimeSum)})`); +B.push( + `+ First month: one-time signup credits (~${fmt(oneTimeSum)})` +); y += 24; let sxp = 32; for (const r of STRIP) { const label = `${r.provider} ${fmt(r.v)}`; const w = 16 + label.length * 6.7; - if (sxp + w > 862) { sxp = 32; y += 30; } - B.push(``); - B.push(`${label}`); + if (sxp + w > 862) { + sxp = 32; + y += 30; + } + B.push( + `` + ); + B.push( + `${label}` + ); sxp += w + 8; } y += 26; -// ToS note (softened) -B.push(``); -B.push(`Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.`); -B.push(`+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +24M/mo.`); -y += 34; -const H = y + 24; // card content bottom +const noteH = gated > 0 ? 48 : 34; +B.push( + `` +); +B.push( + `Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.` +); +B.push( + `+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +${fmt(t.boostMonthlyTokens)}/mo.` +); +if (gated > 0) { + B.push( + `+ ~${fmt(gated)} behind regional identity verification (${t.gatedProviders.join(", ")}) — real quota, never in the headline.` + ); +} +y += noteH; +const H = y + 24; const CANVAS = H + 16; const out = []; -out.push(``); +out.push( + `` +); out.push(``); out.push(``); -out.push(`OmniRoute · /dashboard/free-tiers · preview mockup`); +out.push( + `OmniRoute · /dashboard/free-tiers · preview mockup` +); out.push(...B); out.push(``); -fs.writeFileSync("docs/screenshots/free-tier-budget-card.svg", out.join("\n") + "\n"); -console.log(`SVG: ${GRID.length} models, ${STRIP.length} first-month chips, canvas ${CANVAS}px. steady=${fmt(steady)} firstMonth=${fmt(firstMonth)} oneTime=${fmt(oneTimeSum)}`); +fs.writeFileSync(OUT, out.join("\n") + "\n"); +console.log( + `SVG → ${OUT}: ${GRID.length} pools, ${STRIP.length} first-month chips, canvas ${CANVAS}px. steady=${fmt(steady)} firstMonth=${fmt(firstMonth)} gated=${fmt(gated)} oneTime=${fmt(oneTimeSum)}` +); diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 0105dd8723..1a666b3634 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -2868,7 +2868,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo { model: "if/qwen3-coder-plus", weight: 0 }, { model: "if/deepseek-v3.2", weight: 0 }, { model: "nvidia/llama-3.3-70b-instruct", weight: 0 }, - { model: "groq/llama-3.3-70b-versatile", weight: 0 }, + { model: "groq/openai/gpt-oss-120b", weight: 0 }, ]; const PAID_PREMIUM_PRESET_MODELS = [ diff --git a/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx b/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx index 4fd8dff8ed..4e5ecaadca 100644 --- a/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx @@ -33,6 +33,10 @@ export interface FreeBudgetData { boostMonthlyTokens?: number; /** Providers that are permanently free but publish no token cap (rate/concurrency-limited). */ uncappedProviders?: string[]; + /** Pool-deduped tokens/mo behind a regional identity check — real quota, never in the headline. */ + gatedRecurringTokens?: number; + /** Providers behind that check. */ + gatedProviders?: string[]; headline?: string; /** ISO timestamp of the last catalog update. Absent/null → freshness is not shown. */ catalogUpdatedAt?: string | null; @@ -88,6 +92,7 @@ interface FreeBudgetLabels { segmentHint: string; boost: (tokens: string) => string; uncapped: string; + gated: (tokens: string) => string; tosRestricted: (count: number) => string; provider: string; model: string; @@ -111,6 +116,8 @@ const DEFAULT_LABELS: FreeBudgetLabels = { `Unlock ~${tokens} more/mo with a one-time $10 OpenRouter top-up (50 → 1000 req/day)`, uncapped: "Permanently free, no published cap (rate-limited) — real access, not counted in the headline:", + gated: (tokens) => + `~${tokens}/mo more behind a regional identity check — real quota, not counted in the headline:`, tosRestricted: (count) => `${count} model${count === 1 ? "" : "s"} flagged as ToS-restricted — you decide`, provider: "Provider", @@ -339,6 +346,8 @@ export function FreeBudgetView({ perModel, boostMonthlyTokens = 0, uncappedProviders = [], + gatedRecurringTokens = 0, + gatedProviders = [], catalogUpdatedAt, noCredentialProviders = [], } = data; @@ -429,9 +438,7 @@ export function FreeBudgetView({ lock_open - - {labels.noApiKey} - + {labels.noApiKey} ({keylessModels.length}个模型 · {keylessProviders.length}个提供者) @@ -475,6 +482,23 @@ export function FreeBudgetView({
)} + {gatedRecurringTokens > 0 && ( +
+ + {labels.gated(fmt(gatedRecurringTokens))} + +
+ {gatedProviders.map((p) => ( + + {p} + + ))} +
+
+ )} {/* ToS-restricted callout */} {avoidModels.length > 0 && ( @@ -669,6 +693,7 @@ export default function FreeBudgetCard() { segmentHint: t("segmentHint"), boost: (tokens) => t("boost", { tokens }), uncapped: t("uncapped"), + gated: (tokens) => t("gated", { tokens }), tosRestricted: (count) => t("tosRestricted", { count }), provider: t("provider"), model: t("model"), diff --git a/src/app/api/free-tier/summary/route.ts b/src/app/api/free-tier/summary/route.ts index 52c38f8673..f391634f53 100644 --- a/src/app/api/free-tier/summary/route.ts +++ b/src/app/api/free-tier/summary/route.ts @@ -46,6 +46,7 @@ function toBudgetEntry(entry: MergedEntry): FreeModelBudget & { enabled?: boolea poolKey: entry.poolKey, tos: entry.tos, trainsOnPrompts: entry.trainsOnPrompts, + eligibilityGate: entry.eligibilityGate, hardStopGuaranteed: HARD_STOP_BY_KEY.get(`${entry.provider}:${entry.modelId}`), enabled: entry.enabled, }; diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index 11f4b94b97..51016b9f52 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -71,6 +71,9 @@ export async function POST(request) { } const { name, + modelAccessMode, + allowedModels, + allowedCombos, noLog, scopes, allowedConnections, @@ -84,7 +87,12 @@ export async function POST(request) { // Always get machineId from server const machineId = await getConsistentMachineId(); const normalizedScopes = normalizeSelfServiceScopesForCreate(scopes); - const apiKey = await createApiKey(name, machineId, normalizedScopes, { allowedConnections }); + const apiKey = await createApiKey(name, machineId, normalizedScopes, { + modelAccessMode, + allowedModels, + allowedCombos, + allowedConnections, + }); if ( noLog === true || allowUsageCommand === true || @@ -119,6 +127,9 @@ export async function POST(request) { name: apiKey.name, id: apiKey.id, machineId: apiKey.machineId, + modelAccessMode: apiKey.modelAccessMode, + allowedModels: apiKey.allowedModels, + allowedCombos: apiKey.allowedCombos, allowedConnections: apiKey.allowedConnections, noLog: noLog === true, allowUsageCommand: allowUsageCommand === true, diff --git a/src/app/api/logs/[id]/route.ts b/src/app/api/logs/[id]/route.ts index afdb7d2432..4fad3932a9 100644 --- a/src/app/api/logs/[id]/route.ts +++ b/src/app/api/logs/[id]/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { sanitizeErrorFramesFromLogChunks } from "@/lib/logPayloads"; import { getCallLogById } from "@/lib/usageDb"; import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory"; import { @@ -18,6 +20,29 @@ import { // before it's parsed. const CHUNK_LOG_TIMESTAMP_PREFIX = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/; +type ManagementStreamChunks = { + provider?: string[]; + openai?: string[]; + client?: string[]; +}; + +function projectManagementStreamChunks( + streamChunks: ManagementStreamChunks | null | undefined +): ManagementStreamChunks | null { + if (!streamChunks) return null; + return { + ...(streamChunks.provider + ? { provider: sanitizeErrorFramesFromLogChunks(streamChunks.provider) } + : {}), + ...(streamChunks.openai + ? { openai: sanitizeErrorFramesFromLogChunks(streamChunks.openai) } + : {}), + ...(streamChunks.client + ? { client: sanitizeErrorFramesFromLogChunks(streamChunks.client) } + : {}), + }; +} + // Best-effort parse of the accumulated SSE `data:` lines captured live for an // in-flight request (open-sse/utils/requestLogger.ts's appendConvertedChunk // mutates these arrays in place as chunks arrive, so this reflects "the reply @@ -77,12 +102,13 @@ export async function GET( try { const pendingRequestDetail = getPendingById().get(id); if (pendingRequestDetail) { + const safeStreamChunks = projectManagementStreamChunks(pendingRequestDetail.streamChunks); const pipelinePayloads: any = { clientRequest: pendingRequestDetail.clientRequest ?? null, providerRequest: pendingRequestDetail.providerRequest ?? null, providerResponse: pendingRequestDetail.providerResponse ?? null, clientResponse: pendingRequestDetail.clientResponse ?? null, - streamChunks: pendingRequestDetail.streamChunks ?? null, + streamChunks: safeStreamChunks, }; const activeEntry = { @@ -102,7 +128,7 @@ export async function GET( // The still-generating reply so far — the request's own context // panel renders this alongside its (already-complete) requestBody // instead of waiting for the stream to finish. - partialAssistantText: extractPartialAssistantText(pendingRequestDetail.streamChunks), + partialAssistantText: extractPartialAssistantText(safeStreamChunks), }; return NextResponse.json(activeEntry); @@ -123,12 +149,13 @@ export async function GET( const completed = getCompletedDetails(); const inMem = completed.get(id); if (inMem) { + const safeStreamChunks = projectManagementStreamChunks(inMem.streamChunks); const pipelinePayloads: any = { clientRequest: inMem.clientRequest ?? null, providerRequest: inMem.providerRequest ?? null, providerResponse: inMem.providerResponse ?? null, clientResponse: inMem.clientResponse ?? null, - streamChunks: inMem.streamChunks ?? null, + streamChunks: safeStreamChunks, }; const minimal = { @@ -142,7 +169,7 @@ export async function GET( duration: Date.now() - inMem.startedAt, detailState: "in-memory", active: false, - error: inMem.error || null, + error: sanitizeErrorMessage(inMem.error) || null, pipelinePayloads, hasPipelineDetails: true, }; diff --git a/src/app/api/monitoring/health/route.ts b/src/app/api/monitoring/health/route.ts index f913dbb2c5..c859ad7bdd 100644 --- a/src/app/api/monitoring/health/route.ts +++ b/src/app/api/monitoring/health/route.ts @@ -14,14 +14,23 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; * Returns system info, provider health (circuit breakers), * rate limit status, and database stats. */ -// §8.2 optimization: short-TTL cache for the health payload. Health is a -// frequently-polled endpoint and rebuilding it every request (DB reads + -// status aggregation across 8 subsystems) is wasteful under rapid polling. 1s -// stays near-real-time for monitoring; the cache is invalidated on DELETE -// (circuit-breaker reset) so a manual reset is reflected immediately. +// §8.2 / #12532: short-TTL cache with stale-while-revalidate. Health is a +// frequently-polled endpoint; rebuilding it on the request path (DB reads + +// status aggregation) shares the event loop with GET /healthz. After the first +// fill, scrapes always receive the last payload immediately. An expired entry +// is refreshed in the background — never by awaiting live credential probes. let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null; +let healthPayloadRefreshInFlight = false; +let healthPayloadCacheGeneration = 0; const HEALTH_PAYLOAD_TTL_MS = 1000; +/** Test-only: drop the in-process health payload cache. */ +export function __test_resetMonitoringHealthPayloadCache(): void { + healthPayloadCache = null; + healthPayloadRefreshInFlight = false; + healthPayloadCacheGeneration += 1; +} + // GHSA-mvf8-qc78-5mxm: the full health payload fingerprints the host (version, // node version, pid, memory, provider config). An anonymous caller — the common // case on a keyless install, and what a liveness/load-balancer probe needs — gets @@ -34,15 +43,70 @@ function publicHealthView(payload: unknown): Record { }; } +function serveHealthPayload(fullView: boolean, payload: unknown) { + return NextResponse.json(fullView ? payload : publicHealthView(payload)); +} + +function scheduleHealthPayloadRefresh(): void { + if (healthPayloadRefreshInFlight) return; + healthPayloadRefreshInFlight = true; + setImmediate(() => { + rebuildHealthPayload() + .catch((error) => { + console.warn( + "[API] GET /api/monitoring/health background refresh failed:", + error instanceof Error ? error.message : error + ); + }) + .finally(() => { + healthPayloadRefreshInFlight = false; + }); + }); +} + export async function GET(request: Request) { const fullView = (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null; const cachedNow = Date.now(); - if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) { - return NextResponse.json( - fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload) - ); + if (healthPayloadCache) { + if (cachedNow > healthPayloadCache.expiresAt) { + scheduleHealthPayloadRefresh(); + } + return serveHealthPayload(fullView, healthPayloadCache.payload); } + try { + const payload = await rebuildHealthPayload(); + return serveHealthPayload(fullView, payload); + } catch (error) { + console.error("[API] GET /api/monitoring/health error:", error); + return NextResponse.json({ + status: "degraded", + error: "Health check partially unavailable", + timestamp: new Date().toISOString(), + providerBreakers: [], + providerHealth: {}, + rateLimitStatus: {}, + learnedLimits: {}, + lockouts: [], + quotaMonitor: { + active: 0, + alerting: 0, + exhausted: 0, + errors: 0, + statusCounts: { starting: 0, idle: 0, healthy: 0, warning: 0, exhausted: 0, error: 0 }, + byProvider: {}, + monitors: [], + }, + sessions: { activeCount: 0, stickyBoundCount: 0, byApiKey: {}, top: [] }, + adaptiveAdmission: null, + chatAdmission: null, + dedup: { inflightRequests: 0 }, + }); + } +} + +async function rebuildHealthPayload(): Promise { + const generation = healthPayloadCacheGeneration; const readHealthValue = (label: string, reader: () => T, fallback: T): T => { try { return reader(); @@ -64,179 +128,150 @@ export async function GET(request: Request) { byProvider: {}, }; - try { - const [ - circuitBreakerModule, - rateLimitModule, - accountFallbackModule, - requestDedupModule, - quotaMonitorModule, - sessionManagerModule, - credentialHealthModule, - localHealthModule, - adaptiveAdmissionModule, - chatAdmissionModule, - settingsResult, - connectionsResult, - ] = await Promise.allSettled([ - import("@/shared/utils/circuitBreaker"), - import("@omniroute/open-sse/services/rateLimitManager"), - import("@omniroute/open-sse/services/accountFallback"), - import("@omniroute/open-sse/services/requestDedup.ts"), - import("@omniroute/open-sse/services/quotaMonitor.ts"), - import("@omniroute/open-sse/services/sessionManager.ts"), - import("@/lib/credentialHealth/cache"), - import("@/lib/localHealthCheck"), - import("@omniroute/open-sse/services/admission/runtime.ts"), - import("@/shared/middleware/chatBodyAdmission"), - getCachedSettings(), - getProviderConnections(), - ]); + const [ + circuitBreakerModule, + rateLimitModule, + accountFallbackModule, + requestDedupModule, + quotaMonitorModule, + sessionManagerModule, + credentialHealthModule, + localHealthModule, + adaptiveAdmissionModule, + chatAdmissionModule, + settingsResult, + connectionsResult, + ] = await Promise.allSettled([ + import("@/shared/utils/circuitBreaker"), + import("@omniroute/open-sse/services/rateLimitManager"), + import("@omniroute/open-sse/services/accountFallback"), + import("@omniroute/open-sse/services/requestDedup.ts"), + import("@omniroute/open-sse/services/quotaMonitor.ts"), + import("@omniroute/open-sse/services/sessionManager.ts"), + import("@/lib/credentialHealth/cache"), + import("@/lib/localHealthCheck"), + import("@omniroute/open-sse/services/admission/runtime.ts"), + import("@/shared/middleware/chatBodyAdmission"), + getCachedSettings(), + getProviderConnections(), + ]); - const circuitBreakers = - circuitBreakerModule.status === "fulfilled" - ? readHealthValue( - "circuit breakers", - () => circuitBreakerModule.value.getAllCircuitBreakerStatuses(), - [] - ) - : []; - const rateLimitStatus = - rateLimitModule.status === "fulfilled" - ? readHealthValue("rate limits", () => rateLimitModule.value.getAllRateLimitStatus(), {}) - : {}; - const learnedLimits = - rateLimitModule.status === "fulfilled" - ? readHealthValue("learned limits", () => rateLimitModule.value.getLearnedLimits(), {}) - : {}; - const lockouts = - accountFallbackModule.status === "fulfilled" - ? readHealthValue( - "model lockouts", - () => accountFallbackModule.value.getAllModelLockouts(), - [] - ) - : []; - const quotaMonitorSummary = - quotaMonitorModule.status === "fulfilled" - ? readHealthValue( - "quota monitor summary", - () => quotaMonitorModule.value.getQuotaMonitorSummary(), - fallbackQuotaMonitorSummary - ) - : fallbackQuotaMonitorSummary; - const quotaMonitorMonitors = - quotaMonitorModule.status === "fulfilled" - ? readHealthValue( - "quota monitor snapshots", - () => quotaMonitorModule.value.getQuotaMonitorSnapshots(), - [] - ) - : []; - const activeSessions = - sessionManagerModule.status === "fulfilled" - ? readHealthValue( - "active sessions", - () => sessionManagerModule.value.getActiveSessions(), - [] - ) - : []; - const activeSessionsByKey = - sessionManagerModule.status === "fulfilled" - ? readHealthValue( - "active sessions by key", - () => sessionManagerModule.value.getAllActiveSessionCountsByKey(), - {} - ) - : {}; - const credentialHealth = - credentialHealthModule.status === "fulfilled" - ? readHealthValue( - "credential health", - () => credentialHealthModule.value.getCredentialHealthSummary(), - undefined - ) - : undefined; - const localProviders = - localHealthModule.status === "fulfilled" - ? readHealthValue( - "local providers", - () => localHealthModule.value.getAllHealthStatuses(), - {} - ) - : {}; - const settings = settingsResult.status === "fulfilled" ? settingsResult.value : {}; - const connections = connectionsResult.status === "fulfilled" ? connectionsResult.value : []; - const adaptiveAdmission = - adaptiveAdmissionModule.status === "fulfilled" - ? readHealthValue( - "adaptive admission", - () => adaptiveAdmissionModule.value.getAdaptiveAdmissionRuntime().snapshot(), - null - ) - : null; - // #11244: the STRUCTURAL admission gate (chatBodyAdmission.ts — bounded - // heavyweight lease + shed counters), exposed next to but distinct from the - // adaptive shadow-mode snapshot above. Additive key — nothing existing moves. - const chatAdmission = - chatAdmissionModule.status === "fulfilled" - ? readHealthValue( - "chat admission", - () => chatAdmissionModule.value.perConnectionAdmissionController.snapshot(), - null - ) - : null; + const circuitBreakers = + circuitBreakerModule.status === "fulfilled" + ? readHealthValue( + "circuit breakers", + () => circuitBreakerModule.value.getAllCircuitBreakerStatuses(), + [] + ) + : []; + const rateLimitStatus = + rateLimitModule.status === "fulfilled" + ? readHealthValue("rate limits", () => rateLimitModule.value.getAllRateLimitStatus(), {}) + : {}; + const learnedLimits = + rateLimitModule.status === "fulfilled" + ? readHealthValue("learned limits", () => rateLimitModule.value.getLearnedLimits(), {}) + : {}; + const lockouts = + accountFallbackModule.status === "fulfilled" + ? readHealthValue( + "model lockouts", + () => accountFallbackModule.value.getAllModelLockouts(), + [] + ) + : []; + const quotaMonitorSummary = + quotaMonitorModule.status === "fulfilled" + ? readHealthValue( + "quota monitor summary", + () => quotaMonitorModule.value.getQuotaMonitorSummary(), + fallbackQuotaMonitorSummary + ) + : fallbackQuotaMonitorSummary; + const quotaMonitorMonitors = + quotaMonitorModule.status === "fulfilled" + ? readHealthValue( + "quota monitor snapshots", + () => quotaMonitorModule.value.getQuotaMonitorSnapshots(), + [] + ) + : []; + const activeSessions = + sessionManagerModule.status === "fulfilled" + ? readHealthValue("active sessions", () => sessionManagerModule.value.getActiveSessions(), []) + : []; + const activeSessionsByKey = + sessionManagerModule.status === "fulfilled" + ? readHealthValue( + "active sessions by key", + () => sessionManagerModule.value.getAllActiveSessionCountsByKey(), + {} + ) + : {}; + const credentialHealth = + credentialHealthModule.status === "fulfilled" + ? readHealthValue( + "credential health", + () => credentialHealthModule.value.getCachedCredentialHealthSummary(), + undefined + ) + : undefined; + const localProviders = + localHealthModule.status === "fulfilled" + ? readHealthValue("local providers", () => localHealthModule.value.getAllHealthStatuses(), {}) + : {}; + const settings = settingsResult.status === "fulfilled" ? settingsResult.value : {}; + const connections = connectionsResult.status === "fulfilled" ? connectionsResult.value : []; + const adaptiveAdmission = + adaptiveAdmissionModule.status === "fulfilled" + ? readHealthValue( + "adaptive admission", + () => adaptiveAdmissionModule.value.getAdaptiveAdmissionRuntime().snapshot(), + null + ) + : null; + // #11244: the STRUCTURAL admission gate (chatBodyAdmission.ts — bounded + // heavyweight lease + shed counters), exposed next to but distinct from the + // adaptive shadow-mode snapshot above. Additive key — nothing existing moves. + const chatAdmission = + chatAdmissionModule.status === "fulfilled" + ? readHealthValue( + "chat admission", + () => chatAdmissionModule.value.perConnectionAdmissionController.snapshot(), + null + ) + : null; - const payload = buildHealthPayload({ - appVersion: APP_CONFIG.version, - // #10427: surface the artifact's git SHA so a deployment can be audited over HTTP - // instead of SSH + grepping compiled chunks (the 2026-08-14 gateway outage). - buildSha: readRunningBuildSha(), - catalogCount: Object.keys(AI_PROVIDERS).length, - settings, - connections, - circuitBreakers, - rateLimitStatus, - learnedLimits, - lockouts, - localProviders, - inflightRequests: - requestDedupModule.status === "fulfilled" - ? readHealthValue( - "inflight requests", - () => requestDedupModule.value.getInflightCount(), - 0 - ) - : 0, - quotaMonitorSummary, - quotaMonitorMonitors, - activeSessions, - activeSessionsByKey, - credentialHealth, - adaptiveAdmission, - chatAdmission, - }); + const payload = buildHealthPayload({ + appVersion: APP_CONFIG.version, + // #10427: surface the artifact's git SHA so a deployment can be audited over HTTP + // instead of SSH + grepping compiled chunks (the 2026-08-14 gateway outage). + buildSha: readRunningBuildSha(), + catalogCount: Object.keys(AI_PROVIDERS).length, + settings, + connections, + circuitBreakers, + rateLimitStatus, + learnedLimits, + lockouts, + localProviders, + inflightRequests: + requestDedupModule.status === "fulfilled" + ? readHealthValue("inflight requests", () => requestDedupModule.value.getInflightCount(), 0) + : 0, + quotaMonitorSummary, + quotaMonitorMonitors, + activeSessions, + activeSessionsByKey, + credentialHealth, + adaptiveAdmission, + chatAdmission, + }); + if (generation === healthPayloadCacheGeneration) { healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS }; - return NextResponse.json(fullView ? payload : publicHealthView(payload)); - } catch (error) { - console.error("[API] GET /api/monitoring/health error:", error); - return NextResponse.json({ - status: "degraded", - error: "Health check partially unavailable", - timestamp: new Date().toISOString(), - providerBreakers: [], - providerHealth: {}, - rateLimitStatus: {}, - learnedLimits: {}, - lockouts: [], - quotaMonitor: { ...fallbackQuotaMonitorSummary, monitors: [] }, - sessions: { activeCount: 0, stickyBoundCount: 0, byApiKey: {}, top: [] }, - adaptiveAdmission: null, - chatAdmission: null, - dedup: { inflightRequests: 0 }, - }); } + return payload; } /** diff --git a/src/app/api/providers/[id]/models/staleEncryptionGuard.ts b/src/app/api/providers/[id]/models/staleEncryptionGuard.ts index fc410b1a37..5f2384e921 100644 --- a/src/app/api/providers/[id]/models/staleEncryptionGuard.ts +++ b/src/app/api/providers/[id]/models/staleEncryptionGuard.ts @@ -40,9 +40,7 @@ export function buildStaleEncryptionKeyResponse( `(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` + `STORAGE_ENCRYPTION_KEY matches the key used to store it.`; - // buildErrorBody sanitizes the message (Rule #12); override the type so the - // client can key off the specific stale-encryption cause. - const body = buildErrorBody(424, message); - body.error.type = "storage_encryption_stale"; + // buildErrorBody sanitizes the message and projects the client-visible classification. + const body = buildErrorBody(424, message, undefined, { type: "storage_encryption_stale" }); return NextResponse.json(body, { status: 424 }); } diff --git a/src/app/api/providers/[id]/test/publicErrorBoundary.ts b/src/app/api/providers/[id]/test/publicErrorBoundary.ts new file mode 100644 index 0000000000..30addfeffb --- /dev/null +++ b/src/app/api/providers/[id]/test/publicErrorBoundary.ts @@ -0,0 +1,155 @@ +import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; +import { makeDiagnosis } from "./codexAppServerHealth"; +import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; + +export function toSafeMessage(value: unknown, fallback = "Unknown error"): string { + const safeMessage = sanitizeErrorMessage(value).trim(); + return safeMessage || fallback; +} + +/** + * A provider/account that the upstream has deactivated (vs. a revoked/expired token). + * #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT + * account is deactivated, in which case the API returns 401 — mislabeling that as + * "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the + * account-fallback classifier already trusts. + */ +export function isAccountDeactivatedMessage(text: string): boolean { + const normalized = (text || "").toLowerCase(); + return ( + normalized.includes("account_deactivated") || + (normalized.includes("deactivat") && normalized.includes("account")) + ); +} + +export function classifyFailure({ + error, + statusCode = null, + refreshFailed = false, + unsupported = false, + provider, +}: ClassifyFailureArgs) { + const message = toSafeMessage(error, "Connection test failed"); + const normalized = message.toLowerCase(); + const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null; + + if (unsupported) { + return makeDiagnosis("unsupported", "validation", message, "unsupported"); + } + + if (refreshFailed || normalized.includes("refresh failed")) { + return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed"); + } + + // #1444: a deactivated account is distinct from a revoked/expired token — surface it + // as account_deactivated (which the dashboard renders as "Account Deactivated") before + // the generic 401/403 branch below would mark it "upstream_auth_error". + if (isAccountDeactivatedMessage(normalized)) { + return makeDiagnosis("account_deactivated", "account", message, "account_deactivated"); + } + + if (numericStatus === 401 || numericStatus === 403) { + return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus); + } + + if (numericStatus === 429) { + return makeDiagnosis("upstream_rate_limited", "upstream", message, "429"); + } + + if (numericStatus && numericStatus >= 500) { + return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus)); + } + + if (normalized.includes("token expired") || normalized.includes("expired")) { + return makeDiagnosis("token_expired", "oauth", message, "token_expired"); + } + + if ( + normalized.includes("invalid api key") || + normalized.includes("token invalid") || + normalized.includes("revoked") || + normalized.includes("access denied") || + normalized.includes("unauthorized") || + normalized.includes("forbidden") + ) { + return makeDiagnosis( + "upstream_auth_error", + "upstream", + message, + numericStatus ? String(numericStatus) : "auth_failed" + ); + } + + if ( + normalized.includes("rate limit") || + normalized.includes("quota") || + normalized.includes("too many requests") + ) { + return makeDiagnosis( + "upstream_rate_limited", + "upstream", + message, + numericStatus ? String(numericStatus) : "rate_limited" + ); + } + + if ( + normalized.includes("fetch failed") || + normalized.includes("network") || + normalized.includes("timeout") || + normalized.includes("timed out") || + normalized.includes("econn") || + normalized.includes("enotfound") || + normalized.includes("socket") + ) { + return makeDiagnosis("network_error", "upstream", message, "network_error"); + } + + return makeDiagnosis( + "upstream_error", + "upstream", + message, + numericStatus ? String(numericStatus) : "upstream_error" + ); +} + +/** Allowlist the CLI health fields safe to expose outside the local runtime boundary. */ +export function projectProviderRuntimeForPublicResponse( + runtime: unknown +): Record | null { + if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return null; + const record = runtime as Record; + const projected: Record = {}; + + for (const field of ["installed", "runnable", "requiresBinary"] as const) { + if (typeof record[field] === "boolean") projected[field] = record[field]; + } + for (const field of ["reason", "runtimeMode", "version", "command"] as const) { + if (typeof record[field] !== "string") continue; + const safeValue = sanitizeErrorMessage(record[field]).trim(); + if (safeValue) projected[field] = safeValue.slice(0, 512); + } + + return projected; +} + +/** Sanitize every connection-test result before health writes, logs, and HTTP responses. */ +export function projectConnectionTestResultForPublicResponse< + T extends { error?: unknown; warning?: unknown; diagnosis?: unknown }, +>(result: T) { + const projected = projectProviderValidationResultForPublicResponse(result); + if (!projected.diagnosis || typeof projected.diagnosis !== "object") return projected; + + const diagnosis = projected.diagnosis as Record; + return { + ...projected, + diagnosis: { + ...diagnosis, + message: + diagnosis.message === null || diagnosis.message === undefined + ? null + : toSafeMessage(diagnosis.message, "Connection test failed"), + }, + }; +} diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index cc663a95c8..1a81180358 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -7,6 +7,7 @@ import { isCloudEnabled, resolveProxyForConnection } from "@/lib/db/settings"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { validateProviderApiKey } from "@/lib/providers/validation"; +import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport"; import { getCliRuntimeStatus } from "@/shared/services/cliRuntime"; import { buildQoderCliNotFoundHint } from "@omniroute/open-sse/services/qoderCliResolve.ts"; // Use the shared open-sse token refresh with built-in dedup/race-condition cache @@ -29,11 +30,19 @@ import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHea import { recoverKeyHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { shouldClearErrorStateOnValidProbe } from "@/lib/usage/providerLimits"; import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; -import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult"; import { classifyOAuthProbeInconclusive, OAUTH_TEST_CONFIG } from "./oauthTestConfig"; import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts"; import * as retirement from "@/lib/providers/chatgptWebRetirementResponse"; +import { + classifyFailure, + isAccountDeactivatedMessage, + projectConnectionTestResultForPublicResponse, + projectProviderRuntimeForPublicResponse, + toSafeMessage, +} from "./publicErrorBoundary"; + +export { classifyFailure, projectProviderRuntimeForPublicResponse } from "./publicErrorBoundary"; // Match the API-key path's 30s timeout so a hung OAuth upstream cannot block the test queue. const OAUTH_TEST_TIMEOUT_MS = 30_000; @@ -45,115 +54,6 @@ const providerConnectionTestBodySchema = z.object({ validationModelId: z.string().max(500).optional(), }); -function toSafeMessage(value: any, fallback = "Unknown error"): string { - if (typeof value !== "string") return fallback; - const trimmed = value.trim(); - return trimmed || fallback; -} - -/** - * A provider/account that the upstream has deactivated (vs. a revoked/expired token). - * #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT - * account is deactivated, in which case the API returns 401 — mislabeling that as - * "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the - * account-fallback classifier already trusts. - */ -function isAccountDeactivatedMessage(text: string): boolean { - const n = (text || "").toLowerCase(); - return n.includes("account_deactivated") || (n.includes("deactivat") && n.includes("account")); -} - -export function classifyFailure({ - error, - statusCode = null, - refreshFailed = false, - unsupported = false, - provider, -}: ClassifyFailureArgs) { - const message = toSafeMessage(error, "Connection test failed"); - const normalized = message.toLowerCase(); - const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null; - - if (unsupported) { - return makeDiagnosis("unsupported", "validation", message, "unsupported"); - } - - if (refreshFailed || normalized.includes("refresh failed")) { - return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed"); - } - - // #1444: a deactivated account is distinct from a revoked/expired token — surface it - // as account_deactivated (which the dashboard renders as "Account Deactivated") before - // the generic 401/403 branch below would mark it "upstream_auth_error". - if (isAccountDeactivatedMessage(normalized)) { - return makeDiagnosis("account_deactivated", "account", message, "account_deactivated"); - } - - if (numericStatus === 401 || numericStatus === 403) { - return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus); - } - - if (numericStatus === 429) { - return makeDiagnosis("upstream_rate_limited", "upstream", message, "429"); - } - - if (numericStatus && numericStatus >= 500) { - return makeDiagnosis("upstream_unavailable", "upstream", message, String(numericStatus)); - } - - if (normalized.includes("token expired") || normalized.includes("expired")) { - return makeDiagnosis("token_expired", "oauth", message, "token_expired"); - } - - if ( - normalized.includes("invalid api key") || - normalized.includes("token invalid") || - normalized.includes("revoked") || - normalized.includes("access denied") || - normalized.includes("unauthorized") || - normalized.includes("forbidden") - ) { - return makeDiagnosis( - "upstream_auth_error", - "upstream", - message, - numericStatus ? String(numericStatus) : "auth_failed" - ); - } - - if ( - normalized.includes("rate limit") || - normalized.includes("quota") || - normalized.includes("too many requests") - ) { - return makeDiagnosis( - "upstream_rate_limited", - "upstream", - message, - numericStatus ? String(numericStatus) : "rate_limited" - ); - } - - if ( - normalized.includes("fetch failed") || - normalized.includes("network") || - normalized.includes("timeout") || - normalized.includes("timed out") || - normalized.includes("econn") || - normalized.includes("enotfound") || - normalized.includes("socket") - ) { - return makeDiagnosis("network_error", "upstream", message, "network_error"); - } - - return makeDiagnosis( - "upstream_error", - "upstream", - message, - numericStatus ? String(numericStatus) : "upstream_error" - ); -} - function hasQoderToken(connection: any): boolean { if (typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0) return true; const psd = connection?.providerSpecificData; @@ -218,7 +118,10 @@ async function getProviderRuntimeStatus(connection: any) { error: runtimeMessage, }; } catch (error) { - const runtimeMessage = `Failed to check local CLI runtime: ${(error as any)?.message || "runtime_check_failed"}`; + const runtimeMessage = `Failed to check local CLI runtime: ${toSafeMessage( + error, + "runtime_check_failed" + )}`; return { installed: false, runnable: false, @@ -302,7 +205,10 @@ async function refreshOAuthToken(connection: any) { }); return result; // { accessToken, expiresIn, refreshToken } or null } catch (err) { - console.error(`Error refreshing ${provider} token:`, (err as any).message); + console.error( + `Error refreshing ${provider} token:`, + toSafeMessage(err, "Token refresh failed") + ); return null; } } @@ -376,7 +282,10 @@ async function syncToCloudIfEnabled() { const machineId = await getConsistentMachineId(); await syncToCloud(machineId); } catch (error) { - console.log("Error syncing to cloud after token refresh:", error); + console.log( + "Error syncing to cloud after token refresh:", + toSafeMessage(error, "Cloud sync failed") + ); } } @@ -934,11 +843,13 @@ async function testApiKeyConnection(connection: any) { }; } - const result = await validateProviderApiKey({ - provider: connection.provider, - apiKey: connection.apiKey, - providerSpecificData: connection.providerSpecificData, - }); + const result = projectProviderValidationResultForPublicResponse( + await validateProviderApiKey({ + provider: connection.provider, + apiKey: connection.apiKey, + providerSpecificData: connection.providerSpecificData, + }) + ); if (result.unsupported) { const error = "Provider test not supported"; @@ -1001,8 +912,11 @@ export async function testSingleConnection(connectionId: string, validationModel let proxyInfo: any = null; try { proxyInfo = await resolveProxyForConnection(connectionId); - } catch (proxyErr: any) { - console.log(`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`, proxyErr?.message); + } catch (proxyErr: unknown) { + console.log( + `[ConnectionTest] Failed to resolve proxy for ${connectionId}:`, + toSafeMessage(proxyErr, "Proxy resolution failed") + ); } let result; @@ -1046,6 +960,12 @@ export async function testSingleConnection(connectionId: string, validationModel ); } + // Every runtime path converges here before any health-state write, diagnosis, + // persistent log, or public response. API-key validation is projected at its + // own seam above as well so future refactors cannot move it past this boundary. + result = projectConnectionTestResultForPublicResponse(result); + const publicRuntime = projectProviderRuntimeForPublicResponse(runtime); + const latencyMs = Date.now() - startTime; // Unsupported validation capability is neutral: the probe established that @@ -1063,14 +983,14 @@ export async function testSingleConnection(connectionId: string, validationModel } catch (activateError) { console.log( `[ConnectionTest] Failed to activate unverifiable connection ${connectionId}:`, - (activateError as any)?.message || activateError + toSafeMessage(activateError, "Connection activation failed") ); } } return { ...result, latencyMs, - runtime: runtime || null, + runtime: publicRuntime, testedAt: null, }; } @@ -1214,7 +1134,7 @@ export async function testSingleConnection(connectionId: string, validationModel diagnosis, latencyMs, statusCode: result.statusCode || null, - runtime: runtime || null, + runtime: publicRuntime, testedAt: now, }; } @@ -1245,7 +1165,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: } catch (error) { const retired = retirement.responseForError(error); if (retired) return retired; - console.log("Error testing connection:", error); + console.log("Error testing connection:", toSafeMessage(error, "Connection test failed")); return NextResponse.json({ error: "Test failed" }, { status: 500 }); } } diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index 7d92d4ac92..0992acde94 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { getProviderNodeById } from "@/models"; @@ -8,6 +9,7 @@ import { isAnthropicCompatibleProvider, } from "@/shared/constants/providers"; import { validateProviderApiKey } from "@/lib/providers/validation"; +import { projectProviderValidationResultForPublicResponse } from "@/lib/providers/validation/transport"; import { getProxyForLevel } from "@/lib/db/settings"; import { resolveProxyForProvider } from "@/lib/db/proxies"; import { validateProviderApiKeySchema } from "@/shared/validation/schemas"; @@ -123,12 +125,14 @@ export async function POST(request) { proxyToUse = providerProxy || globalProxy || null; } - const result = await runWithProxyContextOrDirect(proxyToUse || null, () => - validateProviderApiKey({ - provider, - apiKey, - providerSpecificData, - }) + const result = projectProviderValidationResultForPublicResponse( + await runWithProxyContextOrDirect(proxyToUse || null, () => + validateProviderApiKey({ + provider, + apiKey, + providerSpecificData, + }) + ) ); if (result.unsupported) { @@ -174,7 +178,7 @@ export async function POST(request) { providerSpecificData: result.providerSpecificData || null, }); } catch (error) { - console.log("Error validating API key:", error); + console.log("Error validating API key:", sanitizeErrorMessage(error) || "Validation failed"); return NextResponse.json({ error: "Validation failed" }, { status: 500 }); } } diff --git a/src/app/api/v1/models/catalogCache.ts b/src/app/api/v1/models/catalogCache.ts index c5a9509397..a7f8d0aa51 100644 --- a/src/app/api/v1/models/catalogCache.ts +++ b/src/app/api/v1/models/catalogCache.ts @@ -135,35 +135,27 @@ export type CatalogCacheOptions = { */ export const CATALOG_CACHE_TTL_MS_DEFAULT = 60_000; -/** - * Per-call knobs for {@link resolveCachedCatalogResponse}. - * - * `hideAutoCombos` / `hideNoThinkVariants` are catalog-shape dimensions folded into - * the cache key. `getStaleWhileRevalidateMs` and `scheduleBackgroundRefresh` are the - * injection points restored in #11551: the route wires Next's `after()` so the - * background refresh runs only once the response has been flushed to the client. - */ +/** Cold-path wait bound for a coalesced catalog rebuild (#12627). Override with CATALOG_BUILD_TIMEOUT_MS. */ +export const CATALOG_BUILD_TIMEOUT_MS_DEFAULT = 8_000; -/** Defers `task` until it is safe to run without delaying the current response. */ +function catalogBuildTimeoutMs(): number { + const raw = process.env.CATALOG_BUILD_TIMEOUT_MS; + if (!raw) return CATALOG_BUILD_TIMEOUT_MS_DEFAULT; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : CATALOG_BUILD_TIMEOUT_MS_DEFAULT; +} -/** - * Default scheduler (#8728 / #11551). - * - * Next's `after()` runs the task once the response has been flushed, which is the - * whole point of the stale-while-revalidate path: the builder is overwhelmingly - * synchronous under the single-threaded App Router, so running it before the flush - * pins the event loop and the "served immediately" stale body only reaches the - * client after the rebuild finishes. - * - * `after()` requires a Next request scope. Callers outside one (instrumentation - * warm-up, direct unit-test imports) fall back to a macrotask, which preserves the - * "hand the response back first" ordering within the same process. - */ +const catalogLastGood = new Map(); -type CatalogInFlight = { - version: number; - promise: Promise; -}; +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(label)), ms); + promise.then( + (value) => { clearTimeout(timer); resolve(value); }, + (err) => { clearTimeout(timer); reject(err); } + ); + }); +} const catalogCache = new Map(); @@ -251,6 +243,7 @@ function storePayload( if (buildGeneration === getModelCatalogCacheVersion()) { catalogCache.set(cacheKey, entry); } + if (entry.status === 200) catalogLastGood.set(cacheKey, entry); return entry; } @@ -318,6 +311,37 @@ function runBuilder( return buildPayload(request); } +async function awaitCatalogInFlight( + cacheKey: string, + inflight: InFlightBuild, + corsHeaders: Record, + diagnosticHeaders: Record +): Promise { + let payload: CachedCatalog; + try { + payload = await withTimeout(inflight.promise, catalogBuildTimeoutMs(), "catalog_build_timeout"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (catalogInFlight.get(cacheKey)?.promise === inflight.promise) { + catalogInFlight.delete(cacheKey); + } + const lastGood = catalogLastGood.get(cacheKey); + if (msg === "catalog_build_timeout" && lastGood) { + return new Response(lastGood.body, { + status: lastGood.status, + headers: mergeCatalogHeaders(corsHeaders, lastGood.headers, diagnosticHeaders, { + "x-omniroute-catalog": "last-good", + }), + }); + } + throw err; + } + return new Response(payload.body, { + status: payload.status, + headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders), + }); +} + /** * Resolve the cached catalog response for `request`, building it through * `buildPayload` when there is nothing fresh to serve. @@ -382,11 +406,7 @@ export async function resolveCachedCatalogResponse( }); } - const payload = await inflight.promise; - return new Response(payload.body, { - status: payload.status, - headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders), - }); + return awaitCatalogInFlight(cacheKey, inflight, corsHeaders, diagnosticHeaders); } // ── Test hooks ─────────────────────────────────────────────────────────────── @@ -397,6 +417,7 @@ export function __resetCatalogBuilderRunsForTest(): void { _catalogBuilderRuns = 0; catalogCache.clear(); catalogInFlight.clear(); + catalogLastGood.clear(); lastSeenCatalogCacheVersion = getModelCatalogCacheVersion(); } diff --git a/src/app/api/v1/models/syncedCapabilities.ts b/src/app/api/v1/models/syncedCapabilities.ts index 529753a8d9..aac6f9fd27 100644 --- a/src/app/api/v1/models/syncedCapabilities.ts +++ b/src/app/api/v1/models/syncedCapabilities.ts @@ -21,6 +21,12 @@ * `-` catalog entries (open-sse/utils/syncedEffortVariants.ts) — it * never runs over the base entry's `capabilities`, so it cannot substitute * for this check. Required (not optional) so no call site can silently skip it. + * + * #12299 carve-out: Kimi K3's synced base entries (`k3`, `k3-256k` — the kmca + * catalog's `low`/`high`/`max` vocabulary) are exempted from the exclusion so + * catalog-only clients (OpenCode, plain SDK pickers) can see and select their + * tiers. Model-scoped, never provider-wide: Codex, GLM, and non-K3 kimi models + * keep the full exclusion exactly as before this carve-out. */ // Use the same canonical alias as catalogModelPolicy.ts (l.1) — a relative path from // src/app/api/v1/models/ to open-sse/ would need 5 `../` and silently breaks under @@ -39,8 +45,30 @@ interface SyncedCapabilityFlags { supportedThinkingEfforts?: string[]; } +// Model-id pattern for the Kimi K3 family (#12299): the kmca catalog syncs +// `k3`/`k3-256k` (and prefixed forms such as `kmca/k3`). Same shape the +// executor/translator layers use to recognize K3 elsewhere +// (reasoningContentInjector.ts::K3_AUTHENTIC_REASONING_PATTERN). +const KIMI_K3_MODEL_ID_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i; + +/** + * #12299: only Kimi K3's synced BASE entries are exempt from the + * `isSkippedEffortProvider` exclusion. Model-scoped, never provider-wide — + * the exemption requires a kimi-owned provider AND a K3 model id, so Codex, + * GLM, and non-K3 kimi models keep the exclusion contract from #7694. + */ +function isExemptKimiK3BaseModel(sm: SyncedCapabilityFlags, ownedBy: string): boolean { + return ( + ownedBy.startsWith("kimi") && typeof sm.id === "string" && KIMI_K3_MODEL_ID_PATTERN.test(sm.id) + ); +} + function effectiveEffortTiers(sm: SyncedCapabilityFlags, ownedBy: string): string[] | undefined { - if (isSkippedEffortProvider(ownedBy)) return undefined; + // Exclusion gate (#7694): codex/glm/kimi own a conflicting `-{effort}` suffix + // mechanism — the blind opencode-plugin mapping must never see effort_tiers + // for them, or it double-handles the suffix. #12299 narrows only the kimi K3 + // base-model entries out of that gate; everything else stays excluded. + if (isSkippedEffortProvider(ownedBy) && !isExemptKimiK3BaseModel(sm, ownedBy)) return undefined; const learned = sm.id ? getLearnedReasoningEffortForModel(sm.id) : null; const synced = Array.isArray(sm.supportedThinkingEfforts) && sm.supportedThinkingEfforts.length > 0 diff --git a/src/domain/omnirouteResponseMeta.ts b/src/domain/omnirouteResponseMeta.ts index 6a6a05e958..c9a3d33c14 100644 --- a/src/domain/omnirouteResponseMeta.ts +++ b/src/domain/omnirouteResponseMeta.ts @@ -1,6 +1,10 @@ import { getProviderAlias } from "@/shared/constants/providers"; import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; import { APP_CONFIG } from "@/shared/constants/appConfig"; +import { + generationDurationMs, + tokensPerSecond, +} from "@omniroute/open-sse/utils/generationThroughput"; type UsageLike = Record | null | undefined; @@ -123,6 +127,7 @@ export function buildOmniRouteResponseMetaHeaders({ requestId = null, strategy = null, usage = null, + ttftMs = null, }: { cacheHit?: boolean; costUsd?: unknown; @@ -145,6 +150,12 @@ export function buildOmniRouteResponseMetaHeaders({ */ strategy?: string | null; usage?: UsageLike; + /** + * First-token latency in ms. Required to emit tok/s: generation speed is + * `output_tokens / (latencyMs - ttftMs)` and MUST omit the field when TTFT + * is unknown so plugins do not treat `tokens / total_latency` as speed. + */ + ttftMs?: number | null; }): Record { const tokens = getOmniRouteTokenCounts(usage); const headers: Record = { @@ -186,6 +197,15 @@ export function buildOmniRouteResponseMetaHeaders({ headers[OMNIROUTE_RESPONSE_HEADERS.decision] = decisionValue; } + let tps = tokensPerSecond(tokens.output, generationDurationMs(toFiniteNumber(latencyMs), ttftMs)); + if (tps == null && usage && typeof usage === "object") { + const fromUsage = toFiniteNumber((usage as Record).tokens_per_second); + if (fromUsage > 0) tps = fromUsage; + } + if (tps != null) { + headers[OMNIROUTE_RESPONSE_HEADERS.tokensPerSecond] = toHeaderValue(tps.toFixed(3)); + } + return headers; } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bdf4b7a4c7..47efa8dc95 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json لاكتشاف نموذج البوابة", "ccOnboardingCopy": "نسخ", "ccOnboardingCopied": "تم النسخ", - "ccOnboardingKeyPlaceholder": "<مفتاح واجهة برمجة تطبيقات OmniRoute الخاص بك>", + "ccOnboardingKeyPlaceholder": "'<مفتاح واجهة برمجة تطبيقات OmniRoute الخاص بك>'", "ccOnboardingWindowNote": "يفترض Claude Code وجود نافذة سياق تبلغ 200K لأي معرف نموذج لا يتعرف عليه. بالنسبة لنموذج له نافذة حقيقية مختلفة، أضف CLAUDE_CODE_AUTO_COMPACT_WINDOW أسفلها مباشرة حتى لا يتم تشغيل الضغط التلقائي في وقت مبكر جدًا.", "failedSave": "فشل الحفظ", "profileSyncTitle": "المزامنة التلقائية لملفات تعريف CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "بروكسي API مخفض لأكثر من 40 نموذجًا بما في ذلك GPT-5 و Claude Opus 4.6 و Claude Sonnet 4.6 و Qwen 3.5. احصل على مفتاح API الخاص بك من https://freeaiapikey.com/dashboard. عنوان URL الأساسي: https://freeaiapikey.com/v1.", "freemodel-dev": "احصل على رصيد API مجاني بقيمة 300 دولار على https://freemodel.dev — لا يلزم إدخال معلومات الدفع. نقطة نهاية متوافقة مع OpenAI. تتوفر نماذج GPT-5.4 و GPT-5.5.", "friendliai": "فئة مجانية للاستدلال بدون خادم — لا يلزم وجود بطاقة ائتمان", - "gemini": "مجاني للأبد: 1,500 طلب/يوم لـ Gemini 2.5 Flash — بدون بطاقة ائتمان، احصل على المفتاح من aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "ربط GigaChat (Sber) بمفتاح API.", "gitlab": "رمز وصول شخصي لـ GitLab لواجهة برمجة تطبيقات مقترحات الأكواد العامة. قم بتكوين عنوان URL أساسي مستضاف ذاتيًا عند عدم استخدام gitlab.com.", "gitlawb-gmi": "احصل على مفتاح API الخاص بك من لوحة تحكم Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "ربط GLM Coding (الصين) بمفتاح API.", "glmt": "ملف تعريف GLM مسبق الضبط بميزانية رموز أعلى، وتمكين التفكير، ومهلة أطول.", "getgoapi": "ربط GoAPI بمفتاح API.", - "groq": "الفئة المجانية: 30 طلبًا في الدقيقة / 14.4 ألف طلب في اليوم — بدون بطاقة ائتمان", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "احصل على مفتاح API من haiper.ai/haiper-api", "heroku": "ربط Heroku AI بمفتاح API.", "hcnsec": "احصل على مفتاح API من api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "كل شريحة = مجمع مجاني واحد · مجمع مفرود من التكرار، عدّ صادق (بدون حدود قصوى مضخمة لمعدل الطلبات).", "boost": "افتح حوالي {tokens} إضافية/شهرياً بشحن رصيد OpenRouter لمرة واحدة بقيمة 10$ (50 ← 1000 طلب/يوم)", "uncapped": "مجاني بشكل دائم، بدون حد أقصى معلن (محدود بمعدل الطلبات) — وصول حقيقي، لا يُحتسب في العنوان الرئيسي:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# نموذج تم وضع علامة عليه كمقيد بشروط الخدمة} other {# نماذج تم وضع علامة عليها كمقيدة بشروط الخدمة}} — القرار لك", "provider": "المزود", "model": "النموذج", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 543b6b60b0..9f9efb9640 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway modeli kəşfi üçün settings.json", "ccOnboardingCopy": "Kopyala", "ccOnboardingCopied": "Kopyalandı", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code tanımadığı hər hansı model id üçün 200K kontekst pəncərəsi qəbul edir. Fərqli real pəncərəyə malik bir model üçün, avtomatik sıxılmanın çox tez başlamaması üçün onun altına CLAUDE_CODE_AUTO_COMPACT_WINDOW əlavə edin.", "failedSave": "Yadda saxlamaq mümkün olmadı", "profileSyncTitle": "CLI profilinin avtomatik sinxronizasiyası", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 daxil olmaqla 40-dan çox model üçün endirimli API proksisi. API açarınızı https://freeaiapikey.com/dashboard ünvanından əldə edin. Baza URL: https://freeaiapikey.com/v1.", "freemodel-dev": "https://freemodel.dev ünvanında $300 pulsuz API krediti əldə edin — ödəniş məlumatı tələb olunmur. OpenAI ilə uyğun son nöqtə. GPT-5.4 və GPT-5.5 modelləri mövcuddur.", "friendliai": "Serverless çıxarış üçün pulsuz tarif — kredit kartı tələb olunmur", - "gemini": "Həmişə pulsuz: Gemini 2.5 Flash üçün gündə 1,500 sorğu — kredit kartı yoxdur, açarı aistudio.google.com ünvanından əldə edin", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "GigaChat (Sber)-ı API açarı ilə qoşun.", "gitlab": "İctimai Code Suggestions API üçün GitLab şəxsi giriş tokeni. gitlab.com istifadə etmədikdə, self-hosted baza URL-i konfiqurasiya edin.", "gitlawb-gmi": "API açarınızı Gitlawb Opengateway idarəetmə panelindən əldə edin.", @@ -6175,7 +6175,7 @@ "glm-cn": "GLM Coding (China)-i API açarı ilə qoşun.", "glmt": "Daha yüksək token büdcəsi, düşünmə aktivləşdirilmiş və daha uzun vaxt aşımı olan hazır GLM profili.", "getgoapi": "GoAPI-ni API açarı ilə qoşun.", - "groq": "Pulsuz tarif: 30 RPM / 14.4K RPD — kredit kartı tələb olunmur", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "API açarını haiper.ai/haiper-api ünvanından əldə edin", "heroku": "Heroku AI-ı API açarı ilə qoşun.", "hcnsec": "API açarını api.hcnsec.cn ünvanından əldə edin", @@ -13133,6 +13133,7 @@ "segmentHint": "Hər seqment = bir pulsuz hovuz · hovuz üzrə təkrarlanmayan, dürüst sayım (şişirdilmiş sorğu limiti tavanları olmadan).", "boost": "Birdəfəlik $10 OpenRouter balans artımı ilə ayda ~{tokens} daha çox əldə edin (50 → 1000 sorğu/gün)", "uncapped": "Həmişəlik pulsuz, dərc edilmiş limit yoxdur (sorğu sayı məhdudlaşdırılıb) — real giriş, əsas göstəricidə sayılmır:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# model}} ToS ilə məhdudlaşdırılmış kimi qeyd edilib — qərar sizindir", "provider": "Provayder", "model": "Model", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 3944642aff..e442c29e0d 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json за откриване на модел на шлюз", "ccOnboardingCopy": "Копирай", "ccOnboardingCopied": "Копирано", - "ccOnboardingKeyPlaceholder": "<вашият ключ за OmniRoute API>", + "ccOnboardingKeyPlaceholder": "'<вашият ключ за OmniRoute API>'", "ccOnboardingWindowNote": "Claude Code предполагае контекстен прозорец от 200K за всяко идентификатор на модел, който не разпознава. За модел с различен реален прозорец, добавете CLAUDE_CODE_AUTO_COMPACT_WINDOW точно под него, за да не се задейства автоматичното компресиране твърде рано.", "failedSave": "Неуспешно запазване", "profileSyncTitle": "Автоматично синхронизиране на CLI профили", @@ -6166,7 +6166,7 @@ "freeaiapikey": "API прокси с отстъпка за над 40 модела, включително GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Вземете своя API ключ на https://freeaiapikey.com/dashboard. Базов URL адрес: https://freeaiapikey.com/v1.", "freemodel-dev": "Вземете $300 безплатни API кредити на https://freemodel.dev — не се изисква информация за плащане. Съвместима с OpenAI крайна точка. Налични са модели GPT-5.4 и GPT-5.5.", "friendliai": "Безплатен план за serverless inference — не се изисква кредитна карта", - "gemini": "Безплатно завинаги: 1,500 заявки/ден за Gemini 2.5 Flash — без кредитна карта, вземете ключ на aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Свържете GigaChat (Sber) с API ключ.", "gitlab": "Личен токен за достъп на GitLab за публичния Code Suggestions API. Конфигурирайте self-hosted базов URL адрес, когато не използвате gitlab.com.", "gitlawb-gmi": "Вземете своя API ключ от таблото за управление на Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Свържете GLM Coding (China) с API ключ.", "glmt": "Предварително зададен GLM профил с по-висок бюджет за токени, активирано мислене и по-дълъг таймаут.", "getgoapi": "Свържете GoAPI с API ключ.", - "groq": "Безплатен план: 30 RPM / 14.4K RPD — без кредитна карта", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Вземете API ключ на haiper.ai/haiper-api", "heroku": "Свържете Heroku AI с API ключ.", "hcnsec": "Вземете API ключ на api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Всеки сегмент = един безплатен пул · дедупликиран пул, честно отчитане (без изкуствено завишени тавани на лимитите за скорост).", "boost": "Отключете още ~{tokens}/месец с еднократно допълване от $10 в OpenRouter (50 → 1000 заявки/ден)", "uncapped": "Постоянно безплатно, без публикуван лимит (с ограничение на скоростта) — реален достъп, който не се отчита в заглавието:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# модел е маркиран като ограничен от Условията за ползване — вие решавате} other {# модела са маркирани като ограничени от Условията за ползване — вие решавате}}", "provider": "Доставчик", "model": "Модел", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 8f6afadff3..fb1e088781 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway মডেল আবিষ্কারের জন্য settings.json", "ccOnboardingCopy": "কপি করুন", "ccOnboardingCopied": "কপি করা হয়েছে", - "ccOnboardingKeyPlaceholder": "<আপনার OmniRoute API কী>", + "ccOnboardingKeyPlaceholder": "'<আপনার OmniRoute API কী>'", "ccOnboardingWindowNote": "Claude Code একটি 200K প্রসঙ্গ উইন্ডো ধারণ করে যেকোন মডেল আইডির জন্য যা এটি চিনতে পারে না। একটি ভিন্ন বাস্তব উইন্ডো সহ মডেলের জন্য, এর ঠিক নিচে CLAUDE_CODE_AUTO_COMPACT_WINDOW যোগ করুন যাতে স্বয়ংক্রিয় সংকোচন খুব তাড়াতাড়ি শুরু না হয়।", "failedSave": "সংরক্ষণ করতে ব্যর্থ হয়েছে", "profileSyncTitle": "CLI প্রোফাইল অটো-সিঙ্ক", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 সহ 40+ মডেলের জন্য ডিসকাউন্টেড API প্রক্সি। https://freeaiapikey.com/dashboard থেকে আপনার API কী পান। বেস URL: https://freeaiapikey.com/v1।", "freemodel-dev": "https://freemodel.dev থেকে $300 ফ্রি API ক্রেডিট পান — কোনো পেমেন্ট তথ্যের প্রয়োজন নেই। OpenAI-সামঞ্জস্যপূর্ণ এন্ডপয়েন্ট। GPT-5.4 and GPT-5.5 মডেলগুলো উপলব্ধ।", "friendliai": "সার্ভারলেস ইনফারেন্সের জন্য ফ্রি টিয়ার — কোনো ক্রেডিট কার্ডের প্রয়োজন নেই", - "gemini": "চিরকালের জন্য ফ্রি: Gemini 2.5 Flash-এর জন্য প্রতিদিন 1,500টি রিকোয়েস্ট — কোনো ক্রেডিট কার্ড লাগবে না, aistudio.google.com থেকে কী পান", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "একটি API কী দিয়ে GigaChat (Sber) কানেক্ট করুন।", "gitlab": "পাবলিক Code Suggestions API-এর জন্য GitLab পার্সোনাল অ্যাক্সেস টোকেন। gitlab.com ব্যবহার না করার সময় একটি সেলফ-হোস্টেড বেস URL কনফিগার করুন।", "gitlawb-gmi": "Gitlawb Opengateway ড্যাশবোর্ড থেকে আপনার API কী পান।", @@ -6175,7 +6175,7 @@ "glm-cn": "একটি API কী দিয়ে GLM Coding (China) কানেক্ট করুন।", "glmt": "উচ্চতর টোকেন বাজেট, থিংকিং সক্রিয় এবং দীর্ঘতর টাইমআউট সহ প্রিসেট GLM প্রোফাইল।", "getgoapi": "একটি API কী দিয়ে GoAPI কানেক্ট করুন।", - "groq": "ফ্রি টিয়ার: 30 RPM / 14.4K RPD — কোনো ক্রেডিট কার্ড লাগবে না", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api থেকে API কী পান", "heroku": "একটি API কী দিয়ে Heroku AI কানেক্ট করুন।", "hcnsec": "api.hcnsec.cn-এ API কী পান", @@ -13133,6 +13133,7 @@ "segmentHint": "প্রতিটি সেগমেন্ট = একটি ফ্রি পুল · পুল-ডিডুপ্লিকেটেড, সঠিক গণনা (কোনো অতিরঞ্জিত রেট-লিমিট সিলিং নেই)।", "boost": "এককালীন $10 OpenRouter টপ-আপের মাধ্যমে প্রতি মাসে আরও ~{tokens} আনলক করুন (50 → 1000 req/day)", "uncapped": "স্থায়ীভাবে বিনামূল্যে, কোনো প্রকাশিত সীমা নেই (রেট-সীমিত) — প্রকৃত অ্যাক্সেস, হেডলাইনে গণনা করা হয়নি:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {#টি মডেল} other {#টি মডেল}} ToS-সীমাবদ্ধ হিসেবে চিহ্নিত — সিদ্ধান্ত আপনার", "provider": "প্রোভাইডার", "model": "মডেল", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 29af05425a..14bd25e496 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json pro objevování modelu brány", "ccOnboardingCopy": "Kopírovat", "ccOnboardingCopied": "Zkopírováno", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code předpokládá kontextové okno 200K pro jakýkoli model id, který nepozná. Pro model s jiným skutečným oknem přidejte CLAUDE_CODE_AUTO_COMPACT_WINDOW těsně pod něj, aby automatická komprese nenastala příliš brzy.", "failedSave": "Nepodařilo se uložit", "profileSyncTitle": "Automatická synchronizace profilů CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Zlevněná API proxy pro více než 40 modelů včetně GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Získejte svůj API klíč na https://freeaiapikey.com/dashboard. Základní URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Získejte bezplatný API kredit 300 $ na https://freemodel.dev – nejsou vyžadovány žádné platební údaje. Koncový bod kompatibilní s OpenAI. K dispozici jsou modely GPT-5.4 a GPT-5.5.", "friendliai": "Bezplatná úroveň pro serverless inferenci – není vyžadována platební karta", - "gemini": "Navždy zdarma: 1 500 požadavků/den pro Gemini 2.5 Flash – bez platební karty, klíč získáte na aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Připojte GigaChat (Sber) pomocí API klíče.", "gitlab": "Osobní přístupový token (PAT) GitLab pro veřejné rozhraní API Code Suggestions. Pokud nepoužíváte gitlab.com, nakonfigurujte vlastní základní URL.", "gitlawb-gmi": "Získejte svůj API klíč z nástěnky Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Připojte GLM Coding (Čína) pomocí API klíče.", "glmt": "Přednastavený profil GLM s vyšším rozpočtem tokenů, povoleným přemýšlením a delším časovým limitem.", "getgoapi": "Připojte GoAPI pomocí API klíče.", - "groq": "Bezplatná úroveň: 30 RPM / 14,4K RPD – bez platební karty", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Získejte API klíč na haiper.ai/haiper-api", "heroku": "Připojte Heroku AI pomocí API klíče.", "hcnsec": "Získejte API klíč na api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Každý segment = jeden bezplatný pool · deduplikovaný pool, poctivé počítání (žádné uměle navýšené stropy limitů).", "boost": "Odemkněte o ~{tokens} více/měs. jednorázovým dobitím $10 na OpenRouteru (50 → 1000 požadavků/den)", "uncapped": "Trvale zdarma, bez zveřejněného limitu (omezená rychlost) — reálný přístup, nepočítá se do hlavního přehledu:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model označený jako omezený ToS} few {# modely označené jako omezené ToS} other {# modelů označených jako omezené ToS}} — rozhodnutí je na vás", "provider": "Poskytovatel", "model": "Model", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index bea97fe340..7309787fd2 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json til gateway model opdagelse", "ccOnboardingCopy": "Kopier", "ccOnboardingCopied": "Kopieret", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code antager et 200K kontekstvindue for enhver model-id, den ikke genkender. For en model med et andet reelt vindue, tilføj CLAUDE_CODE_AUTO_COMPACT_WINDOW lige under den, så auto-komprimering ikke aktiveres for tidligt.", "failedSave": "Kunne ikke gemme", "profileSyncTitle": "Automatisk synkronisering af CLI-profil", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Rabatbelagt API-proxy til 40+ modeller inklusive GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Hent din API-nøgle på https://freeaiapikey.com/dashboard. Base-URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Få $300 gratis API-kredit på https://freemodel.dev — ingen betalingsoplysninger påkrævet. OpenAI-kompatibelt slutpunkt. GPT-5.4- og GPT-5.5-modeller tilgængelige.", "friendliai": "Gratis niveau til serverløs inferens — intet kreditkort påkrævet", - "gemini": "Gratis altid: 1.500 anmodninger/dag til Gemini 2.5 Flash — intet kreditkort, hent nøgle på aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Forbind GigaChat (Sber) med en API-nøgle.", "gitlab": "Personligt adgangstoken til GitLab til den offentlige Code Suggestions API. Konfigurer en selvhostet base-URL, når du ikke bruger gitlab.com.", "gitlawb-gmi": "Hent din API-nøgle fra Gitlawb Opengateway-dashboardet.", @@ -6175,7 +6175,7 @@ "glm-cn": "Forbind GLM Coding (Kina) med en API-nøgle.", "glmt": "Forudindstillet GLM-profil med højere token-budget, tænkning aktiveret og længere timeout.", "getgoapi": "Forbind GoAPI med en API-nøgle.", - "groq": "Gratis niveau: 30 RPM / 14,4K RPD — intet kreditkort", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Hent API-nøgle på haiper.ai/haiper-api", "heroku": "Forbind Heroku AI med en API-nøgle.", "hcnsec": "Få API-nøgle på api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Hvert segment = én gratis pulje · pulje-dedupliceret, ærlig optælling (ingen oppustede hastighedsgrænselofter).", "boost": "Lås op for ~{tokens} mere/md. med en engangsoptankning på $10 hos OpenRouter (50 → 1000 anm./dag)", "uncapped": "Permanent gratis, intet offentliggjort loft (hastighedsbegrænset) — reel adgang, ikke talt med i overskriften:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# modeller}} markeret som ToS-begrænset — du bestemmer", "provider": "Udbyder", "model": "Model", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d0c0322ac1..41ea050e7b 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json für die Entdeckung des Gateway-Modells", "ccOnboardingCopy": "Kopieren", "ccOnboardingCopied": "Kopiert", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code geht von einem 200K-Kontextfenster für jede Modell-ID aus, die es nicht erkennt. Für ein Modell mit einem anderen tatsächlichen Fenster fügen Sie CLAUDE_CODE_AUTO_COMPACT_WINDOW direkt darunter hinzu, damit die automatische Komprimierung nicht zu früh ausgelöst wird.", "failedSave": "Speichern fehlgeschlagen", "profileSyncTitle": "Automatische Synchronisierung von CLI-Profilen", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Vergünstigter API-Proxy für über 40 Modelle, darunter GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Holen Sie sich Ihren API-Schlüssel unter https://freeaiapikey.com/dashboard. Basis-URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Holen Sie sich 300 $ kostenloses API-Guthaben unter https://freemodel.dev – keine Zahlungsinformationen erforderlich. OpenAI-kompatibler Endpunkt. GPT-5.4- und GPT-5.5-Modelle verfügbar.", "friendliai": "Kostenlose Stufe für serverlose Inferenz – keine Kreditkarte erforderlich", - "gemini": "Für immer kostenlos: 1.500 Anfragen/Tag für Gemini 2.5 Flash — keine Kreditkarte, Schlüssel unter aistudio.google.com anfordern", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "GigaChat (Sber) mit einem API-Schlüssel verbinden.", "gitlab": "Persönliches GitLab-Zugriffstoken für die öffentliche Code Suggestions API. Konfigurieren Sie eine selbstgehostete Basis-URL, wenn Sie nicht gitlab.com verwenden.", "gitlawb-gmi": "Holen Sie sich Ihren API-Schlüssel aus dem Gitlawb Opengateway-Dashboard.", @@ -6175,7 +6175,7 @@ "glm-cn": "GLM Coding (China) mit einem API-Schlüssel verbinden.", "glmt": "Voreingestelltes GLM-Profil mit höherem Token-Budget, aktiviertem Denken und längerem Timeout.", "getgoapi": "GoAPI mit einem API-Schlüssel verbinden.", - "groq": "Kostenlose Stufe: 30 RPM / 14,4K RPD — keine Kreditkarte", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "API-Schlüssel unter haiper.ai/haiper-api anfordern", "heroku": "Heroku AI mit einem API-Schlüssel verbinden.", "hcnsec": "API-Schlüssel unter api.hcnsec.cn anfordern", @@ -13140,6 +13140,7 @@ "segmentHint": "Jedes Segment = ein kostenloser Pool · Pool-dedupliziert, ehrliche Zählung (keine künstlich erhöhten Rate-Limit-Obergrenzen).", "boost": "Schalten Sie ~{tokens} mehr/Monat mit einer einmaligen $10 OpenRouter-Aufladung frei (50 → 1000 Anfr./Tag)", "uncapped": "Dauerhaft kostenlos, kein veröffentlichtes Limit (ratenbegrenzt) — echter Zugang, nicht in der Überschrift gezählt:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# Modell} other {# Modelle}} als ToS-eingeschränkt markiert — Sie entscheiden", "provider": "Anbieter", "model": "Modell", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e9643f3511..ae4b3b28fe 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json for gateway model discovery", "ccOnboardingCopy": "Copy", "ccOnboardingCopied": "Copied", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code assumes a 200K context window for any model id it does not recognize. For a model with a different real window, add CLAUDE_CODE_AUTO_COMPACT_WINDOW just under it so auto-compaction does not fire too early.", "failedSave": "Failed to save", "profileSyncTitle": "CLI profile auto-sync", @@ -6139,7 +6139,7 @@ "bluesminds": "Get your API key at https://www.bluesminds.com — OpenAI-compatible endpoint at https://api.bluesminds.com/v1 with free daily credits. VIP models (Claude Opus 4.5, Gemini 2.5 Pro) consume pi credits.", "byteplus": "Connect BytePlus ModelArk with an API key.", "bytez": "$1 free credits, refreshes every 4 weeks", - "cerebras": "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.", + "cerebras": "One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier.", "charm-hyper": "Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.", "chutes": "Bearer API key for the Chutes OpenAI-compatible gateway.", "clarifai": "Clarifai exposes OpenAI-compatible chat, responses and /models on /v2/ext/openai/v1. Public/community models typically require a PAT; app-scoped keys only work for resources inside that app.", @@ -6169,7 +6169,7 @@ "freeaiapikey": "Discounted API proxy for 40+ models including GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Get your API key at https://freeaiapikey.com/dashboard. Base URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Get $300 free API credits at https://freemodel.dev — no payment info required. OpenAI-compatible endpoint. GPT-5.4 and GPT-5.5 models available.", "friendliai": "Free tier for serverless inference — no credit card required", - "gemini": "Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com", + "gemini": "Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Connect GigaChat (Sber) with an API key.", "gitlab": "GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com.", "gitlawb-gmi": "Get your API key from Gitlawb Opengateway dashboard.", @@ -6178,7 +6178,7 @@ "glm-cn": "Connect GLM Coding (China) with an API key.", "glmt": "Preset GLM profile with higher token budget, thinking enabled, and longer timeout.", "getgoapi": "Connect GoAPI with an API key.", - "groq": "Free tier: 30 RPM / 14.4K RPD — no credit card", + "groq": "Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Get API key at haiper.ai/haiper-api", "heroku": "Connect Heroku AI with an API key.", "hcnsec": "Get API key at api.hcnsec.cn", @@ -13143,6 +13143,7 @@ "segmentHint": "Each segment = one free pool · pool-deduped, honest counting (no inflated rate-limit ceilings).", "boost": "Unlock ~{tokens} more/mo with a one-time $10 OpenRouter top-up (50 → 1000 req/day)", "uncapped": "Permanently free, no published cap (rate-limited) — real access, not counted in the headline:", + "gated": "~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# models}} flagged as ToS-restricted — you decide", "provider": "Provider", "model": "Model", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index ba61b59daa..348db0b960 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json para el descubrimiento del modelo de gateway", "ccOnboardingCopy": "Copiar", "ccOnboardingCopied": "Copiado", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code asume una ventana de contexto de 200K para cualquier ID de modelo que no reconozca. Para un modelo con una ventana real diferente, añade CLAUDE_CODE_AUTO_COMPACT_WINDOW justo debajo para que la auto-compresión no se active demasiado pronto.", "failedSave": "Failed to save", "profileSyncTitle": "CLI profile auto-sync", @@ -6136,7 +6136,7 @@ "bluesminds": "Get your API key at https://www.bluesminds.com — OpenAI-compatible endpoint at https://api.bluesminds.com/v1 with free daily credits. VIP models (Claude Opus 4.5, Gemini 2.5 Pro) consume pi credits.", "byteplus": "Connect BytePlus ModelArk with an API key.", "bytez": "$1 free credits, refreshes every 4 weeks", - "cerebras": "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.", + "cerebras": "One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier.", "charm-hyper": "Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.", "chutes": "Bearer API key for the Chutes OpenAI-compatible gateway.", "clarifai": "Clarifai exposes OpenAI-compatible chat, responses and /models on /v2/ext/openai/v1. Public/community models typically require a PAT; app-scoped keys only work for resources inside that app.", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Discounted API proxy for 40+ models including GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Get your API key at https://freeaiapikey.com/dashboard. Base URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Get $300 free API credits at https://freemodel.dev — no payment info required. OpenAI-compatible endpoint. GPT-5.4 and GPT-5.5 models available.", "friendliai": "Free tier for serverless inference — no credit card required", - "gemini": "Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Connect GigaChat (Sber) with an API key.", "gitlab": "GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com.", "gitlawb-gmi": "Get your API key from Gitlawb Opengateway dashboard.", @@ -6175,7 +6175,7 @@ "glm-cn": "Connect GLM Coding (China) with an API key.", "glmt": "Preset GLM profile with higher token budget, thinking enabled, and longer timeout.", "getgoapi": "Connect GoAPI with an API key.", - "groq": "Free tier: 30 RPM / 14.4K RPD — no credit card", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Get API key at haiper.ai/haiper-api", "heroku": "Connect Heroku AI with an API key.", "hcnsec": "Get API key at api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Each segment = one free pool · pool-deduped, honest counting (no inflated rate-limit ceilings).", "boost": "Unlock ~{tokens} more/mo with a one-time $10 OpenRouter top-up (50 → 1000 req/day)", "uncapped": "Permanently free, no published cap (rate-limited) — real access, not counted in the headline:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# models}} flagged as ToS-restricted — you decide", "provider": "Provider", "model": "Model", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 79c0ec653f..0fa364611b 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json برای کشف مدل دروازه", "ccOnboardingCopy": "کپی", "ccOnboardingCopied": "کپی شد", - "ccOnboardingKeyPlaceholder": "<کلید API OmniRoute شما>", + "ccOnboardingKeyPlaceholder": "'<کلید API OmniRoute شما>'", "ccOnboardingWindowNote": "Claude Code فرض می‌کند که یک پنجره متنی 200K برای هر شناسه مدلی که شناسایی نمی‌کند وجود دارد. برای مدلی با پنجره واقعی متفاوت، CLAUDE_CODE_AUTO_COMPACT_WINDOW را درست زیر آن اضافه کنید تا فشرده‌سازی خودکار خیلی زود فعال نشود.", "failedSave": "ذخیره‌سازی ناموفق بود", "profileSyncTitle": "همگام‌سازی خودکار پروفایل CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "پروکسی API تخفیف‌خورده برای بیش از ۴۰ مدل از جمله GPT-5، Claude Opus 4.6، Claude Sonnet 4.6، Qwen 3.5. کلید API خود را در https://freeaiapikey.com/dashboard دریافت کنید. URL پایه: https://freeaiapikey.com/v1.", "freemodel-dev": "۳۰۰ دلار اعتبار رایگان API در https://freemodel.dev دریافت کنید — بدون نیاز به اطلاعات پرداخت. نقطه پایانی سازگار با OpenAI. مدل‌های GPT-5.4 و GPT-5.5 در دسترس هستند.", "friendliai": "سطح رایگان برای استنتاج بدون سرور (serverless inference) — بدون نیاز به کارت اعتباری", - "gemini": "رایگان برای همیشه: ۱,۵۰۰ درخواست در روز برای Gemini 2.5 Flash — بدون نیاز به کارت اعتباری، کلید را در aistudio.google.com دریافت کنید", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "اتصال GigaChat (Sber) با یک کلید API.", "gitlab": "توکن دسترسی شخصی GitLab برای API عمومی Code Suggestions. در صورت عدم استفاده از gitlab.com، یک URL پایه خودمیزبانی‌شده (self-hosted) پیکربندی کنید.", "gitlawb-gmi": "کلید API خود را از داشبورد Gitlawb Opengateway دریافت کنید.", @@ -6175,7 +6175,7 @@ "glm-cn": "اتصال GLM Coding (China) با یک کلید API.", "glmt": "پروفایل پیش‌فرض GLM با بودجه توکن بالاتر، فعال بودن تفکر (thinking) و زمان انتظار (timeout) طولانی‌تر.", "getgoapi": "اتصال GoAPI با یک کلید API.", - "groq": "سطح رایگان: ۳۰ RPM / ۱۴.۴K RPD — بدون نیاز به کارت اعتباری", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "کلید API را در haiper.ai/haiper-api دریافت کنید", "heroku": "اتصال Heroku AI با یک کلید API.", "hcnsec": "دریافت کلید API در api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "هر بخش = یک استخر رایگان · حذف تکرار استخر، شمارش واقعی (بدون سقف‌های محدودیت نرخ کاذب).", "boost": "با یک‌بار شارژ ۱۰ دلاری OpenRouter، حدود ~{tokens} بیشتر در ماه آزاد کنید (۵۰ → ۱۰۰۰ درخواست/روز)", "uncapped": "دائماً رایگان، بدون سقف اعلام‌شده (دارای محدودیت نرخ) — دسترسی واقعی، بدون احتساب در عنوان اصلی:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# مدل} other {# مدل}} دارای محدودیت ToS علامت‌گذاری شده‌اند — تصمیم با شماست", "provider": "ارائه‌دهنده", "model": "مدل", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index c83a56cb2c..e5a09ea7dc 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json portin mallin löytämiseksi", "ccOnboardingCopy": "Kopioi", "ccOnboardingCopied": "Kopioitu", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code olettaa 200K kontekstin ikkunan kaikille mallin tunnuksille, joita se ei tunnista. Mallille, jolla on eri todellinen ikkuna, lisää CLAUDE_CODE_AUTO_COMPACT_WINDOW sen alle, jotta automaattinen tiivistys ei käynnisty liian aikaisin.", "failedSave": "Tallennus epäonnistui", "profileSyncTitle": "CLI-profiilien automaattinen synkronointi", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Alennettu API-välityspalvelin yli 40 mallille, mukaan lukien GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Hanki API-avaimesi osoitteesta https://freeaiapikey.com/dashboard. Perus-URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Hanki 300 $ ilmaista API-saldoa osoitteesta https://freemodel.dev — maksutietoja ei vaadita. OpenAI-yhteensopiva päätepiste. GPT-5.4- ja GPT-5.5-mallit saatavilla.", "friendliai": "Ilmainen taso palvelimettomaan päättelyyn — luottokorttia ei vaadita", - "gemini": "Aina ilmainen: 1 500 pyyntöä/päivä Gemini 2.5 Flashille — ei luottokorttia, hanki avain osoitteesta aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Yhdistä GigaChat (Sber) API-avaimella.", "gitlab": "GitLabin henkilökohtainen käyttöoikeustunniste julkiselle Code Suggestions API:lle. Määritä itse isännöity perus-URL, kun et käytä gitlab.com-palvelua.", "gitlawb-gmi": "Hanki API-avaimesi Gitlawb Opengateway -hallintapaneelista.", @@ -6175,7 +6175,7 @@ "glm-cn": "Yhdistä GLM Coding (China) API-avaimella.", "glmt": "Esiasetettu GLM-profiili suuremmalla token-budjetilla, ajattelu käytössä ja pidemmällä aikakatkaisulla.", "getgoapi": "Yhdistä GoAPI API-avaimella.", - "groq": "Ilmainen taso: 30 RPM / 14,4K RPD — ei luottokorttia", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Hanki API-avain osoitteesta haiper.ai/haiper-api", "heroku": "Yhdistä Heroku AI API-avaimella.", "hcnsec": "Hanki API-avain osoitteesta api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Jokainen segmentti = yksi ilmainen pooli · poolikohtaisesti duplikaatit poistettu, rehellinen laskenta (ei paisutettuja nopeusrajoituskattoja).", "boost": "Avaa ~{tokens} lisää/kk kertaluonteisella 10 dollarin OpenRouter-latauksella (50 → 1000 pyyntöä/päivä)", "uncapped": "Pysyvästi ilmainen, ei julkaistua ylärajaa (nopeusrajoitettu) — todellinen käyttöoikeus, ei lasketa pääotsikkoon:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# malli} other {# mallia}} merkitty käyttöehtojen vastaiseksi — sinä päätät", "provider": "Tarjoaja", "model": "Malli", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1bb74a6c87..48bcdae45a 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json pour la découverte des modèles de la passerelle", "ccOnboardingCopy": "Copier", "ccOnboardingCopied": "Copié", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code suppose une fenêtre de contexte de 200K pour tout identifiant de modèle qu'il ne reconnaît pas. Pour un modèle dont la fenêtre réelle est différente, ajoutez CLAUDE_CODE_AUTO_COMPACT_WINDOW juste en dessous afin que le compactage automatique ne se déclenche pas trop tôt.", "failedSave": "Échec de l'enregistrement", "profileSyncTitle": "Synchronisation automatique des profils CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Proxy API à tarif réduit pour plus de 40 modèles, dont GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Obtenez votre clé API sur https://freeaiapikey.com/dashboard. URL de base : https://freeaiapikey.com/v1.", "freemodel-dev": "Obtenez 300 $ de crédits API gratuits sur https://freemodel.dev — aucune information de paiement requise. Point de terminaison compatible avec OpenAI. Modèles GPT-5.4 et GPT-5.5 disponibles.", "friendliai": "Offre gratuite pour l'inférence serverless — aucune carte de crédit requise", - "gemini": "Gratuit à vie : 1 500 req/jour pour Gemini 2.5 Flash — sans carte de crédit, obtenez la clé sur aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Connectez GigaChat (Sber) avec une clé API.", "gitlab": "Jeton d'accès personnel GitLab pour l'API publique Code Suggestions. Configurez une URL de base auto-hébergée si vous n'utilisez pas gitlab.com.", "gitlawb-gmi": "Obtenez votre clé API depuis le tableau de bord Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Connectez GLM Coding (Chine) avec une clé API.", "glmt": "Profil GLM prédéfini avec un budget de tokens plus élevé, mode pensée activé et délai d'attente plus long.", "getgoapi": "Connectez GoAPI avec une clé API.", - "groq": "Offre gratuite : 30 RPM / 14,4K RPD — sans carte de crédit", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Obtenez une clé API sur haiper.ai/haiper-api", "heroku": "Connectez Heroku AI avec une clé API.", "hcnsec": "Obtenir une clé API sur api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Chaque segment = un pool gratuit · dédoublonné par pool, décompte honnête (sans plafonds de limite de débit gonflés).", "boost": "Débloquez ~{tokens} de plus/mois avec une recharge unique de 10 $ sur OpenRouter (50 → 1000 req/jour)", "uncapped": "Gratuit en permanence, sans plafond publié (limité en débit) — accès réel, non comptabilisé dans le total :", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# modèle} other {# modèles}} signalés comme restreints par les CGU — à vous de décider", "provider": "Fournisseur", "model": "Modèle", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 7fbc8ac29b..c9014de70b 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "ગેટવે મોડલ શોધ માટે settings.json", "ccOnboardingCopy": "કોપી", "ccOnboardingCopied": "કોપી કરેલ", - "ccOnboardingKeyPlaceholder": "<તમારો OmniRoute API કી>", + "ccOnboardingKeyPlaceholder": "'<તમારો OmniRoute API કી>'", "ccOnboardingWindowNote": "Claude Code એ કોઈપણ મોડેલ આઈડી માટે 200K સંદર્ભ વિન્ડો માન્ય રાખે છે જે તે ઓળખતું નથી. જુદા રિયલ વિન્ડો ધરાવતા મોડેલ માટે, નીચે CLAUDE_CODE_AUTO_COMPACT_WINDOW ઉમેરો જેથી ઓટો-કમ્પેક્શન ખૂબ જ વહેલા ન ફાયર થાય.", "failedSave": "સાચવવામાં નિષ્ફળ", "profileSyncTitle": "CLI પ્રોફાઇલ ઓટો-સિંક", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 સહિત 40+ મોડલ્સ માટે ડિસ્કાઉન્ટેડ API પ્રોક્સી. https://freeaiapikey.com/dashboard પર તમારી API કી મેળવો. બેઝ URL: https://freeaiapikey.com/v1.", "freemodel-dev": "https://freemodel.dev પર $300 મફત API ક્રેડિટ્સ મેળવો — કોઈ ચુકવણી માહિતીની જરૂર નથી. OpenAI-સુસંગત એન્ડપોઇન્ટ. GPT-5.4 અને GPT-5.5 મોડલ્સ ઉપલબ્ધ છે.", "friendliai": "સર્વરલેસ ઇન્ફરન્સ માટે મફત સ્તર — કોઈ ક્રેડિટ કાર્ડની જરૂર નથી", - "gemini": "હંમેશા માટે મફત: Gemini 2.5 Flash માટે દરરોજ 1,500 વિનંતીઓ — કોઈ ક્રેડિટ કાર્ડ નહીં, aistudio.google.com પર કી મેળવો", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "API કી વડે GigaChat (Sber) ને કનેક્ટ કરો.", "gitlab": "સાર્વજનિક Code Suggestions API માટે GitLab વ્યક્તિગત ઍક્સેસ ટોકન. જ્યારે gitlab.com નો ઉપયોગ ન કરી રહ્યા હોવ ત્યારે સેલ્ફ-હોસ્ટેડ બેઝ URL કન્ફિગર કરો.", "gitlawb-gmi": "Gitlawb Opengateway ડેશબોર્ડ પરથી તમારી API કી મેળવો.", @@ -6175,7 +6175,7 @@ "glm-cn": "API કી વડે GLM Coding (China) ને કનેક્ટ કરો.", "glmt": "ઉચ્ચ ટોકન બજેટ, વિચારવાની ક્ષમતા સક્ષમ અને લાંબા સમયસમાપ્તિ સાથે પ્રીસેટ GLM પ્રોફાઇલ.", "getgoapi": "API કી વડે GoAPI ને કનેક્ટ કરો.", - "groq": "મફત સ્તર: 30 RPM / 14.4K RPD — કોઈ ક્રેડિટ કાર્ડ નહીં", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api પર API કી મેળવો", "heroku": "API કી વડે Heroku AI ને કનેક્ટ કરો.", "hcnsec": "api.hcnsec.cn પર API કી મેળવો", @@ -13133,6 +13133,7 @@ "segmentHint": "દરેક સેગમેન્ટ = એક મફત પૂલ · પૂલ-ડિડુપ્લિકેટ, પ્રમાણિક ગણતરી (કોઈ ફૂલેલી રેટ-મર્યાદા સીમાઓ નહીં).", "boost": "એક વખતના $10 OpenRouter ટોપ-અપ સાથે દર મહિને ~{tokens} વધુ અનલૉક કરો (50 → 1000 req/day)", "uncapped": "કાયમી ધોરણે મફત, કોઈ પ્રકાશિત મર્યાદા નથી (રેટ-મર્યાદિત) — વાસ્તવિક ઍક્સેસ, હેડલાઇનમાં ગણવામાં આવતી નથી:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# મોડેલ} other {# મોડેલો}} ToS-પ્રતિબંધિત તરીકે ચિહ્નિત થયેલ છે — તમે નક્કી કરો", "provider": "પ્રદાતા", "model": "મોડેલ", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 66bea20bb8..8305333bb0 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json עבור גילוי מודל שער", "ccOnboardingCopy": "העתק", "ccOnboardingCopied": "הועתק", - "ccOnboardingKeyPlaceholder": "<מפתח ה-API של OmniRoute שלך>", + "ccOnboardingKeyPlaceholder": "'<מפתח ה-API של OmniRoute שלך>'", "ccOnboardingWindowNote": "Claude Code מניח חלון הקשר של 200K עבור כל מזהה מודל שהוא לא מזהה. עבור מודל עם חלון אמיתי שונה, הוסף CLAUDE_CODE_AUTO_COMPACT_WINDOW מיד מתחתיו כך שהדחיסה האוטומטית לא תתבצע מוקדם מדי.", "failedSave": "השמירה נכשלה", "profileSyncTitle": "סנכרון אוטומטי של פרופילי CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "פרוקסי API מוזל עבור יותר מ-40 מודלים כולל GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. קבל את מפתח ה-API שלך ב-https://freeaiapikey.com/dashboard. כתובת URL בסיסית: https://freeaiapikey.com/v1.", "freemodel-dev": "קבל קרדיט API חינם בסך $300 ב-https://freemodel.dev — ללא צורך בפרטי תשלום. נקודת קצה תואמת OpenAI. מודלים של GPT-5.4 ו-GPT-5.5 זמינים.", "friendliai": "מסלול חינמי להסקה ללא שרת — ללא צורך בכרטיס אשראי", - "gemini": "חינם לתמיד: 1,500 בקשות/יום עבור Gemini 2.5 Flash — ללא כרטיס אשראי, קבל מפתח ב-aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "חבר את GigaChat (Sber) באמצעות מפתח API.", "gitlab": "טוקן גישה אישי של GitLab עבור ה-API הציבורי של Code Suggestions. הגדר כתובת URL בסיסית באירוח עצמי כאשר אינך משתמש ב-gitlab.com.", "gitlawb-gmi": "קבל את מפתח ה-API שלך מלוח הבקרה של Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "חבר את GLM Coding (China) באמצעות מפתח API.", "glmt": "פרופיל GLM מוגדר מראש עם תקציב טוקנים גבוה יותר, חשיבה מופעלת ופסק זמן ארוך יותר.", "getgoapi": "חבר את GoAPI באמצעות מפתח API.", - "groq": "מסלול חינמי: 30 RPM / 14.4K RPD — ללא כרטיס אשראי", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "קבל מפתח API ב-haiper.ai/haiper-api", "heroku": "חבר את Heroku AI באמצעות מפתח API.", "hcnsec": "קבל מפתח API ב-api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "כל מקטע = מאגר חינמי אחד · מניעת כפילויות במאגר, ספירה הוגנת (ללא תקרות מגבלת קצב מנופחות).", "boost": "פתחו עוד כ-{tokens}/חודש עם טעינה חד-פעמית של $10 ב-OpenRouter ‏(50 → 1000 בקשות/יום)", "uncapped": "חינם לצמיתות, ללא מגבלה מפורסמת (מוגבל בקצב) — גישה אמיתית, לא נספר בכותרת הראשית:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {מודל #} other {# מודלים}} מסומנים כחסומים לפי תנאי השימוש — ההחלטה בידיך", "provider": "ספק", "model": "מודל", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index f45c0fad90..4a7226cbfe 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "गेटवे मॉडल खोज के लिए settings.json", "ccOnboardingCopy": "कॉपी", "ccOnboardingCopied": "कॉपी किया गया", - "ccOnboardingKeyPlaceholder": "<आपकी OmniRoute API कुंजी>", + "ccOnboardingKeyPlaceholder": "'<आपकी OmniRoute API कुंजी>'", "ccOnboardingWindowNote": "Claude Code किसी भी मॉडल आईडी के लिए 200K संदर्भ विंडो मानता है जिसे वह पहचानता नहीं है। यदि किसी मॉडल की वास्तविक विंडो अलग है, तो इसे उसके ठीक नीचे CLAUDE_CODE_AUTO_COMPACT_WINDOW जोड़ें ताकि ऑटो-कम्पैक्शन बहुत जल्दी न चले।", "failedSave": "सहेजने में विफल", "profileSyncTitle": "CLI प्रोफ़ाइल ऑटो-सिंक", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 सहित 40+ मॉडलों के लिए रियायती API प्रॉक्सी। https://freeaiapikey.com/dashboard पर अपनी API कुंजी प्राप्त करें। बेस URL: https://freeaiapikey.com/v1।", "freemodel-dev": "https://freemodel.dev पर $300 का निःशुल्क API क्रेडिट प्राप्त करें — किसी भुगतान जानकारी की आवश्यकता नहीं है। OpenAI-संगत एंडपॉइंट। GPT-5.4 और GPT-5.5 मॉडल उपलब्ध हैं।", "friendliai": "सर्वरलेस इनफेरेंस के लिए निःशुल्क टियर — किसी क्रेडिट कार्ड की आवश्यकता नहीं है", - "gemini": "हमेशा के लिए निःशुल्क: Gemini 2.5 Flash के लिए 1,500 req/day — कोई क्रेडिट कार्ड नहीं, aistudio.google.com पर कुंजी प्राप्त करें", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "GigaChat (Sber) को एक API कुंजी से कनेक्ट करें।", "gitlab": "सार्वजनिक Code Suggestions API के लिए GitLab व्यक्तिगत एक्सेस टोकन। gitlab.com का उपयोग न करते समय एक स्व-होस्टेड बेस URL कॉन्फ़िगर करें।", "gitlawb-gmi": "Gitlawb Opengateway डैशबोर्ड से अपनी API कुंजी प्राप्त करें।", @@ -6175,7 +6175,7 @@ "glm-cn": "GLM Coding (China) को एक API कुंजी से कनेक्ट करें।", "glmt": "उच्च टोकन बजट, थिंकिंग (thinking) सक्षम और लंबे टाइमआउट के साथ प्रीसेट GLM प्रोफ़ाइल।", "getgoapi": "GoAPI को एक API कुंजी से कनेक्ट करें।", - "groq": "निःशुल्क टियर: 30 RPM / 14.4K RPD — कोई क्रेडिट कार्ड नहीं", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api पर API कुंजी प्राप्त करें", "heroku": "Heroku AI को एक API कुंजी से कनेक्ट करें।", "hcnsec": "api.hcnsec.cn पर API कुंजी प्राप्त करें", @@ -13133,6 +13133,7 @@ "segmentHint": "प्रत्येक सेगमेंट = एक मुफ़्त पूल · पूल-डीडुप्लिकेटेड, सटीक गणना (कोई बढ़ी हुई रेट-लिमिट सीमा नहीं)।", "boost": "एकमुश्त $10 OpenRouter टॉप-अप के साथ ~{tokens} अधिक/माह अनलॉक करें (50 → 1000 req/day)", "uncapped": "स्थायी रूप से मुफ़्त, कोई प्रकाशित सीमा नहीं (रेट-लिमिटेड) — वास्तविक एक्सेस, हेडलाइन में नहीं गिना गया:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# मॉडल} other {# मॉडल}} ToS-प्रतिबंधित के रूप में चिह्नित — आप तय करें", "provider": "प्रदाता", "model": "मॉडल", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index b1275355b9..99d95becd5 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json a gateway modell felfedezéshez", "ccOnboardingCopy": "Másolás", "ccOnboardingCopied": "Másolt", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "A Claude Code 200K kontextusablakot feltételez bármely olyan modellazonosító esetén, amelyet nem ismer fel. Ha a modellnek eltérő valós ablaka van, add hozzá a CLAUDE_CODE_AUTO_COMPACT_WINDOW-t közvetlenül alá, hogy az automatikus tömörítés ne lépjen működésbe túl korán.", "failedSave": "Nem sikerült menteni", "profileSyncTitle": "CLI-profil automatikus szinkronizálása", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Kedvezményes API-proxy több mint 40 modellhez, beleértve a GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 modelleket. Szerezze be API-kulcsát a https://freeaiapikey.com/dashboard oldalon. Alap URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Szerezzen $300 ingyenes API-kreditet a https://freemodel.dev oldalon — fizetési adat nem szükséges. OpenAI-kompatibilis végpont. GPT-5.4 és GPT-5.5 modellek érhetők el.", "friendliai": "Ingyenes csomag szerver nélküli következtetéshez — bankkártya nem szükséges", - "gemini": "Örökké ingyenes: 1500 kérés/nap a Gemini 2.5 Flash-hez — bankkártya nem szükséges, kulcs beszerzése: aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Csatlakoztassa a GigaChatet (Sber) egy API-kulccsal.", "gitlab": "GitLab személyes hozzáférési token a nyilvános Code Suggestions API-hoz. Állítson be saját üzemeltetésű alap URL-t, ha nem a gitlab.com-ot használja.", "gitlawb-gmi": "Szerezze be API-kulcsát a Gitlawb Opengateway irányítópultjáról.", @@ -6175,7 +6175,7 @@ "glm-cn": "Csatlakoztassa a GLM Coding (Kína) szolgáltatást egy API-kulccsal.", "glmt": "Előre beállított GLM-profil magasabb tokenkerettel, engedélyezett gondolkodással és hosszabb időtúllépéssel.", "getgoapi": "Csatlakoztassa a GoAPI-t egy API-kulccsal.", - "groq": "Ingyenes csomag: 30 RPM / 14,4K RPD — bankkártya nem szükséges", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Szerezzen API-kulcsot a haiper.ai/haiper-api oldalon", "heroku": "Csatlakoztassa a Heroku AI-t egy API-kulccsal.", "hcnsec": "Szerezzen API-kulcsot itt: api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Each segment = one free pool · pool-deduped, honest counting (no inflated rate-limit ceilings).", "boost": "Oldjon fel további ~{tokens}/hó-t egy egyszeri 10 dolláros OpenRouter feltöltéssel (50 → 1000 req/day)", "uncapped": "Tartósan ingyenes, nincs közzétett korlát (sebességkorlátozott) — valós hozzáférés, nem számít bele a főcímbe:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# modell} other {# modell}} ÁSZF-korlátozottként megjelölve — Ön dönt", "provider": "Provider", "model": "Model", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 474a26973d..6ac4539837 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json untuk penemuan model gateway", "ccOnboardingCopy": "Salin", "ccOnboardingCopied": "Disalin", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code mengasumsikan jendela konteks 200K untuk setiap ID model yang tidak dikenalnya. Untuk model dengan jendela nyata yang berbeda, tambahkan CLAUDE_CODE_AUTO_COMPACT_WINDOW tepat di bawahnya agar kompak otomatis tidak aktif terlalu awal.", "failedSave": "Gagal menyimpan", "profileSyncTitle": "Sinkronisasi otomatis profil CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Proksi API berdiskon untuk 40+ model termasuk GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Dapatkan kunci API Anda di https://freeaiapikey.com/dashboard. URL Dasar: https://freeaiapikey.com/v1.", "freemodel-dev": "Dapatkan kredit API gratis senilai $300 di https://freemodel.dev — tidak memerlukan informasi pembayaran. Endpoint yang kompatibel dengan OpenAI. Model GPT-5.4 dan GPT-5.5 tersedia.", "friendliai": "Tingkat gratis untuk inferensi serverless — tidak memerlukan kartu kredit", - "gemini": "Gratis selamanya: 1.500 req/hari untuk Gemini 2.5 Flash — tanpa kartu kredit, dapatkan kunci di aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Hubungkan GigaChat (Sber) dengan kunci API.", "gitlab": "Token akses pribadi GitLab untuk API Code Suggestions publik. Konfigurasikan URL dasar yang di-host sendiri saat tidak menggunakan gitlab.com.", "gitlawb-gmi": "Dapatkan kunci API Anda dari dasbor Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Hubungkan GLM Coding (China) dengan kunci API.", "glmt": "Profil GLM prasetel dengan anggaran token yang lebih tinggi, proses berpikir diaktifkan, dan batas waktu yang lebih lama.", "getgoapi": "Hubungkan GoAPI dengan kunci API.", - "groq": "Tingkat gratis: 30 RPM / 14,4K RPD — tanpa kartu kredit", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Dapatkan kunci API di haiper.ai/haiper-api", "heroku": "Hubungkan Heroku AI dengan kunci API.", "hcnsec": "Dapatkan kunci API di api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Setiap segmen = satu pool gratis · deduplikasi pool, penghitungan jujur (tanpa batas rate-limit yang digelembungkan).", "boost": "Buka ~{tokens} tambahan/bln dengan top-up OpenRouter $10 satu kali (50 → 1000 req/hari)", "uncapped": "Gratis permanen, tanpa batas yang dipublikasikan (dibatasi rate-limit) — akses nyata, tidak dihitung dalam tajuk utama:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# model}} ditandai sebagai dibatasi ToS — Anda yang menentukan", "provider": "Penyedia", "model": "Model", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 86e1226241..7bf00a4c77 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json per la scoperta modelli gateway", "ccOnboardingCopy": "Copia", "ccOnboardingCopied": "Copiato", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code assume una finestra di contesto di 200K per qualsiasi id modello non riconosciuto. Per un modello con una finestra reale diversa, aggiungi CLAUDE_CODE_AUTO_COMPACT_WINDOW subito sotto in modo che l'auto-compattazione non si attivi troppo presto.", "failedSave": "Impossibile salvare", "profileSyncTitle": "Sincronizzazione automatica profili CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Proxy API scontato per oltre 40 modelli tra cui GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Ottieni la tua chiave API su https://freeaiapikey.com/dashboard. URL di base: https://freeaiapikey.com/v1.", "freemodel-dev": "Ottieni 300 $ di crediti API gratuiti su https://freemodel.dev — nessuna informazione di pagamento richiesta. Endpoint compatibile con OpenAI. Modelli GPT-5.4 e GPT-5.5 disponibili.", "friendliai": "Piano gratuito per inferenza serverless — nessuna carta di credito richiesta", - "gemini": "Gratis per sempre: 1.500 richieste/giorno per Gemini 2.5 Flash — nessuna carta di credito, ottieni la chiave su aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Connetti GigaChat (Sber) con una chiave API.", "gitlab": "Token di accesso personale GitLab per l'API pubblica Code Suggestions. Configura un URL di base self-hosted quando non utilizzi gitlab.com.", "gitlawb-gmi": "Ottieni la tua chiave API dalla dashboard di Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Connetti GLM Coding (Cina) con una chiave API.", "glmt": "Profilo GLM preimpostato con budget di token più elevato, pensiero abilitato e timeout più lungo.", "getgoapi": "Connetti GoAPI con una chiave API.", - "groq": "Piano gratuito: 30 RPM / 14,4K RPD — nessuna carta di credito", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Ottieni la chiave API su haiper.ai/haiper-api", "heroku": "Connetti Heroku AI con una chiave API.", "hcnsec": "Ottieni la chiave API su api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Ogni segmento = un pool gratuito · pool deduplicato, conteggio onesto (nessun limite massimo di rate-limit gonfiato).", "boost": "Sblocca ~{tokens} in più al mese con una ricarica una tantum di $10 su OpenRouter (50 → 1000 rich/giorno)", "uncapped": "Permanentemente gratuito, nessun limite pubblicato (soggetto a rate-limit) — accesso reale, non conteggiato nel titolo:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# modello contrassegnato come limitato dai ToS} other {# modelli contrassegnati come limitati dai ToS}} — decidi tu", "provider": "Provider", "model": "Modello", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ab2f52c289..0948496009 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gatewayモデル発見のためのsettings.json", "ccOnboardingCopy": "コピー", "ccOnboardingCopied": "コピーしました", - "ccOnboardingKeyPlaceholder": "<あなたのOmniRoute APIキー>", + "ccOnboardingKeyPlaceholder": "'<あなたのOmniRoute APIキー>'", "ccOnboardingWindowNote": "Claude Codeは、認識できないモデルIDに対して200Kのコンテキストウィンドウを仮定します。異なる実際のウィンドウを持つモデルの場合は、CLAUDE_CODE_AUTO_COMPACT_WINDOWをその直下に追加して、自動圧縮が早すぎることがないようにします。", "failedSave": "保存に失敗しました", "profileSyncTitle": "CLIプロファイルの自動同期", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5、Claude Opus 4.6、Claude Sonnet 4.6、Qwen 3.5を含む40以上のモデルに対応した割引APIプロキシ。https://freeaiapikey.com/dashboard でAPIキーを取得してください。ベースURL: https://freeaiapikey.com/v1。", "freemodel-dev": "https://freemodel.dev で$300の無料APIクレジットを取得 — 支払い情報は不要です。OpenAI互換エンドポイント。GPT-5.4およびGPT-5.5モデルが利用可能です。", "friendliai": "サーバーレス推論の無料枠 — クレジットカード不要", - "gemini": "永久無料: Gemini 2.5 Flashが1,500 req/日 — クレジットカード不要、aistudio.google.com でキーを取得", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "APIキーでGigaChat (Sber)に接続します。", "gitlab": "パブリックCode Suggestions API用のGitLabパーソナルアクセストークン。gitlab.comを使用しない場合は、セルフホストのベースURLを設定してください。", "gitlawb-gmi": "Gitlawb OpengatewayダッシュボードからAPIキーを取得します。", @@ -6175,7 +6175,7 @@ "glm-cn": "APIキーでGLM Coding (China)に接続します。", "glmt": "より大きなトークンバジェット、思考の有効化、およびより長いタイムアウトを備えたプリセットGLMプロファイル。", "getgoapi": "APIキーでGoAPIに接続します。", - "groq": "無料枠: 30 RPM / 14.4K RPD — クレジットカード不要", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api でAPIキーを取得", "heroku": "APIキーでHeroku AIに接続します。", "hcnsec": "api.hcnsec.cn でAPIキーを取得", @@ -13133,6 +13133,7 @@ "segmentHint": "各セグメント = 1つの無料プール · プール重複排除、誠実なカウント(誇張されたレート制限上限なし)。", "boost": "1回限りの$10のOpenRouterチャージで、月あたりさらに約{tokens}をアンロック(50 → 1000 リクエスト/日)", "uncapped": "恒久的に無料、公開された上限なし(レート制限あり) — 見出しにはカウントされない、実際のアクセス:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# 個のモデル} other {# 個のモデル}}が利用規約制限としてフラグ立てされています — ご自身で判断してください", "provider": "プロバイダー", "model": "モデル", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ad26677619..63d0b70434 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway 모델 발견을 위한 settings.json", "ccOnboardingCopy": "복사", "ccOnboardingCopied": "복사됨", - "ccOnboardingKeyPlaceholder": "<귀하의 OmniRoute API 키>", + "ccOnboardingKeyPlaceholder": "'<귀하의 OmniRoute API 키>'", "ccOnboardingWindowNote": "Claude Code는 인식하지 못하는 모델 ID에 대해 200K 컨텍스트 창을 가정합니다. 다른 실제 창을 가진 모델의 경우, 자동 압축이 너무 일찍 발생하지 않도록 그 아래에 CLAUDE_CODE_AUTO_COMPACT_WINDOW를 추가하세요.", "failedSave": "저장하지 못했습니다", "profileSyncTitle": "CLI 프로필 자동 동기화", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5를 포함한 40개 이상의 모델을 위한 할인된 API 프록시. https://freeaiapikey.com/dashboard 에서 API 키를 가져오세요. 베이스 URL: https://freeaiapikey.com/v1.", "freemodel-dev": "https://freemodel.dev 에서 $300 무료 API 크레딧을 받으세요 — 결제 정보 불필요. OpenAI 호환 엔드포인트. GPT-5.4 및 GPT-5.5 모델 사용 가능.", "friendliai": "서버리스 추론을 위한 무료 티어 — 신용카드 불필요", - "gemini": "평생 무료: Gemini 2.5 Flash 기준 1,500회 요청/일 — 신용카드 불필요, aistudio.google.com 에서 키 발급", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "API 키로 GigaChat (Sber) 연결.", "gitlab": "공개 Code Suggestions API용 GitLab 개인 액세스 토큰. gitlab.com을 사용하지 않는 경우 자체 호스팅 베이스 URL을 구성하세요.", "gitlawb-gmi": "Gitlawb Opengateway 대시보드에서 API 키를 가져오세요.", @@ -6175,7 +6175,7 @@ "glm-cn": "API 키로 GLM Coding (China) 연결.", "glmt": "더 높은 토큰 예산, 생각하기(thinking) 활성화 및 더 긴 타임아웃이 설정된 프리셋 GLM 프로필.", "getgoapi": "API 키로 GoAPI 연결.", - "groq": "무료 티어: 30 RPM / 14.4K RPD — 신용카드 불필요", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api 에서 API 키를 가져오세요.", "heroku": "API 키로 Heroku AI 연결.", "hcnsec": "api.hcnsec.cn 에서 API 키를 가져오세요.", @@ -13133,6 +13133,7 @@ "segmentHint": "각 세그먼트 = 하나의 무료 풀 · 풀 중복 제거, 정직한 집계 (부풀려진 요율 제한 한도 없음).", "boost": "일회성 $10 OpenRouter 충전으로 월 약 {tokens}개 추가 잠금 해제 (일일 50 → 1000회 요청)", "uncapped": "영구 무료, 공개된 제한 없음 (요율 제한됨) — 헤드라인에 집계되지 않는 실제 액세스:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {#개 모델} other {#개 모델}}이 이용약관(ToS) 제한으로 표시됨 — 귀하가 결정하세요", "provider": "제공업체", "model": "모델", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 7f5ee425c9..140afbdda6 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway मॉडेल शोधासाठी settings.json", "ccOnboardingCopy": "कॉपी", "ccOnboardingCopied": "कॉपी केलेले", - "ccOnboardingKeyPlaceholder": "<तुमचा OmniRoute API की>", + "ccOnboardingKeyPlaceholder": "'<तुमचा OmniRoute API की>'", "ccOnboardingWindowNote": "Claude Code कोणत्याही ओळखत नसलेल्या मॉडेल आयडीसाठी 200K संदर्भ विंडो गृहीत धरतो. भिन्न वास्तविक विंडो असलेल्या मॉडेलसाठी, CLAUDE_CODE_AUTO_COMPACT_WINDOW त्याच्या खालीच जोडा जेणेकरून ऑटो-कम्पॅक्शन लवकर सुरू होणार नाही.", "failedSave": "सेव्ह करण्यात अयशस्वी", "profileSyncTitle": "CLI प्रोफाइल ऑटो-सिंक", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 सह 40+ मॉडेल्ससाठी सवलतीचा API प्रॉक्सी. https://freeaiapikey.com/dashboard वर तुमची API की मिळवा. बेस URL: https://freeaiapikey.com/v1.", "freemodel-dev": "https://freemodel.dev वर $300 विनामूल्य API क्रेडिट्स मिळवा — पेमेंट माहितीची आवश्यकता नाही. OpenAI-सुसंगत एंडपॉइंट. GPT-5.4 आणि GPT-5.5 मॉडेल्स उपलब्ध आहेत.", "friendliai": "सर्व्हरलेस इन्फरन्ससाठी विनामूल्य टियर — क्रेडिट कार्डची आवश्यकता नाही", - "gemini": "कायमचे विनामूल्य: Gemini 2.5 Flash साठी 1,500 विनंत्या/दिवस — क्रेडिट कार्ड नाही, aistudio.google.com वर की मिळवा", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "API की सह GigaChat (Sber) कनेक्ट करा.", "gitlab": "सार्वजनिक Code Suggestions API साठी GitLab वैयक्तिक ॲक्सेस टोकन. gitlab.com न वापरताना सेल्फ-होस्टेड बेस URL कॉन्फिगर करा.", "gitlawb-gmi": "Gitlawb Opengateway डॅशबोर्डवरून तुमची API की मिळवा.", @@ -6175,7 +6175,7 @@ "glm-cn": "API की सह GLM Coding (China) कनेक्ट करा.", "glmt": "उच्च टोकन बजेट, थिंकिंग सक्षम आणि दीर्घ टाइमआउटसह प्रीसेट GLM प्रोफाइल.", "getgoapi": "API की सह GoAPI कनेक्ट करा.", - "groq": "विनामूल्य टियर: 30 RPM / 14.4K RPD — क्रेडिट कार्ड नाही", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api वर API की मिळवा", "heroku": "API की सह Heroku AI कनेक्ट करा.", "hcnsec": "api.hcnsec.cn वर API की मिळवा", @@ -13133,6 +13133,7 @@ "segmentHint": "प्रत्येक विभाग = एक मोफत पूल · पूल-डिड्युप केलेले, प्रामाणिक मोजणी (कोणतीही फुगवलेली रेट-लिमिट कमाल मर्यादा नाही).", "boost": "एकवेळच्या $10 OpenRouter टॉप-अपसह आणखी ~{tokens}/महिना अनलॉक करा (50 → 1000 req/day)", "uncapped": "कायमस्वरूपी मोफत, कोणतीही प्रकाशित मर्यादा नाही (रेट-लिमिटेड) — खरा ॲक्सेस, हेडलाइनमध्ये मोजला जात नाही:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# मॉडेल} other {# मॉडेल्स}} ToS-प्रतिबंधित म्हणून चिन्हांकित — तुम्ही ठरवा", "provider": "प्रदाता", "model": "मॉडेल", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index a780d09bcb..66e04377f7 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json untuk penemuan model gateway", "ccOnboardingCopy": "Salin", "ccOnboardingCopied": "Disalin", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code menganggap tetingkap konteks 200K untuk mana-mana ID model yang tidak dikenali. Untuk model dengan tetingkap sebenar yang berbeza, tambah CLAUDE_CODE_AUTO_COMPACT_WINDOW tepat di bawahnya supaya pemampatan automatik tidak berlaku terlalu awal.", "failedSave": "Gagal menyimpan", "profileSyncTitle": "Penyinkronan automatik profil CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Proksi API berdiskaun untuk 40+ model termasuk GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Dapatkan kunci API anda di https://freeaiapikey.com/dashboard. URL asas: https://freeaiapikey.com/v1.", "freemodel-dev": "Dapatkan kredit API percuma $300 di https://freemodel.dev — tiada maklumat pembayaran diperlukan. Titik akhir serasi OpenAI. Model GPT-5.4 dan GPT-5.5 tersedia.", "friendliai": "Peringkat percuma untuk inferens tanpa pelayan — tiada kad kredit diperlukan", - "gemini": "Percuma selamanya: 1,500 req/hari untuk Gemini 2.5 Flash — tiada kad kredit, dapatkan kunci di aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Sambungkan GigaChat (Sber) dengan kunci API.", "gitlab": "Token akses peribadi GitLab untuk API Code Suggestions awam. Konfigurasikan URL asas dihoskan sendiri apabila tidak menggunakan gitlab.com.", "gitlawb-gmi": "Dapatkan kunci API anda daripada papan pemuka Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Sambungkan GLM Coding (China) dengan kunci API.", "glmt": "Profil GLM pratetap dengan belanjawan token yang lebih tinggi, pemikiran didayakan dan tamat masa yang lebih lama.", "getgoapi": "Sambungkan GoAPI dengan kunci API.", - "groq": "Peringkat percuma: 30 RPM / 14.4K RPD — tiada kad kredit", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Dapatkan kunci API di haiper.ai/haiper-api", "heroku": "Sambungkan Heroku AI dengan kunci API.", "hcnsec": "Dapatkan kunci API di api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Setiap segmen = satu kolam percuma · kolam dinyahduplikasi, pengiraan jujur (tiada siling had kadar yang melambung).", "boost": "Nyahkunci ~{tokens} lagi/bln dengan tambah nilai OpenRouter $10 sekali sahaja (50 → 1000 perm/hari)", "uncapped": "Percuma selama-lamanya, tiada had diterbitkan (had kadar dikenakan) — akses sebenar, tidak dikira dalam tajuk utama:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# model}} ditandakan sebagai disekat ToS — anda tentukan", "provider": "Penyedia", "model": "Model", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 706a5ccd35..b16e43b7d8 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json voor gateway model ontdekking", "ccOnboardingCopy": "Kopiëren", "ccOnboardingCopied": "Gekopieerd", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code gaat uit van een contextvenster van 200K voor elk model-id dat het niet herkent. Voor een model met een ander echt venster, voeg CLAUDE_CODE_AUTO_COMPACT_WINDOW net eronder toe zodat automatische compactie niet te vroeg wordt geactiveerd.", "failedSave": "Opslaan mislukt", "profileSyncTitle": "Automatische synchronisatie van CLI-profielen", @@ -6166,7 +6166,7 @@ "freeaiapikey": "API-proxy met korting voor meer dan 40 modellen, waaronder GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Haal je API-sleutel op via https://freeaiapikey.com/dashboard. Basis-URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Ontvang $300 gratis API-tegoed op https://freemodel.dev — geen betalingsgegevens vereist. OpenAI-compatibel eindpunt. GPT-5.4- en GPT-5.5-modellen beschikbaar.", "friendliai": "Gratis abonnement voor serverloze inferentie — geen creditcard vereist", - "gemini": "Altijd gratis: 1.500 verzoeken/dag voor Gemini 2.5 Flash — geen creditcard, haal de sleutel op via aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Verbind GigaChat (Sber) met een API-sleutel.", "gitlab": "Persoonlijk toegangstoken van GitLab voor de openbare Code Suggestions-API. Configureer een zelf-gehoste basis-URL als je gitlab.com niet gebruikt.", "gitlawb-gmi": "Haal je API-sleutel op uit het Gitlawb Opengateway-dashboard.", @@ -6175,7 +6175,7 @@ "glm-cn": "Verbind GLM Coding (China) met een API-sleutel.", "glmt": "Vooraf ingesteld GLM-profiel met een hoger tokenbudget, denken ingeschakeld en een langere time-out.", "getgoapi": "Verbind GoAPI met een API-sleutel.", - "groq": "Gratis abonnement: 30 RPM / 14,4K RPD — geen creditcard", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Haal de API-sleutel op via haiper.ai/haiper-api", "heroku": "Verbind Heroku AI met een API-sleutel.", "hcnsec": "Haal de API-sleutel op via api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Elk segment = één gratis pool · pool-ontdubbeld, eerlijke telling (geen opgeblazen rate-limit-plafonds).", "boost": "Ontgrendel ~{tokens} extra/mnd met een eenmalige OpenRouter-opwaardering van $10 (50 → 1000 req/dag)", "uncapped": "Permanent gratis, geen gepubliceerde limiet (rate-limited) — echte toegang, niet meegeteld in de kop:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# modellen}} gemarkeerd als ToS-beperkt — jij bepaalt", "provider": "Provider", "model": "Model", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 9576e4b6a6..3e0aa602a7 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json for gateway modelloppdagelse", "ccOnboardingCopy": "Kopier", "ccOnboardingCopied": "Kopiert", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code antar et 200K kontekstvindu for enhver modell-ID den ikke gjenkjenner. For en modell med et annet reelt vindu, legg til CLAUDE_CODE_AUTO_COMPACT_WINDOW rett under den, slik at automatisk komprimering ikke aktiveres for tidlig.", "failedSave": "Kunne ikke lagre", "profileSyncTitle": "Automatisk synkronisering av CLI-profiler", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Rabattert API-proxy for 40+ modeller inkludert GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Hent API-nøkkelen din på https://freeaiapikey.com/dashboard. Base-URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Få $300 i gratis API-kreditter på https://freemodel.dev — ingen betalingsinformasjon påkrevd. OpenAI-kompatibelt endepunkt. GPT-5.4- og GPT-5.5-modeller tilgjengelig.", "friendliai": "Gratisnivå for serverløs inferens — ingen kredittkort påkrevd", - "gemini": "Gratis for alltid: 1,500 req/dag for Gemini 2.5 Flash — ingen kredittkort, hent nøkkel på aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Koble til GigaChat (Sber) med en API-nøkkel.", "gitlab": "GitLab personlig adgangstoken for det offentlige Code Suggestions-API-et. Konfigurer en selvvertet base-URL når du ikke bruker gitlab.com.", "gitlawb-gmi": "Hent API-nøkkelen din fra Gitlawb Opengateway-dashbordet.", @@ -6175,7 +6175,7 @@ "glm-cn": "Koble til GLM Coding (Kina) med en API-nøkkel.", "glmt": "Forhåndsinnstilt GLM-profil med høyere token-budsjett, tenkning aktivert og lengre tidsavbrudd.", "getgoapi": "Koble til GoAPI med en API-nøkkel.", - "groq": "Gratisnivå: 30 RPM / 14,4K RPD — uten kredittkort", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Hent API-nøkkel på haiper.ai/haiper-api", "heroku": "Koble til Heroku AI med en API-nøkkel.", "hcnsec": "Hent API-nøkkel på api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Hvert segment = én gratis pool · pool-deduplisert, ærlig telling (ingen oppblåste tak for hastighetsbegrensning).", "boost": "Lås opp ~{tokens} mer/mnd med en engangs $10 OpenRouter-påfylling (50 → 1000 forespørsler/dag)", "uncapped": "Permanent gratis, ingen publisert grense (hastighetsbegrenset) — reell tilgang, ikke talt med i overskriften:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# modell} other {# modeller}} flagget som ToS-begrenset — du bestemmer", "provider": "Leverandør", "model": "Modell", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 856b906cfb..282dc3c6f2 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json para sa pagtuklas ng modelo ng gateway", "ccOnboardingCopy": "Kopyahin", "ccOnboardingCopied": "Nakopya", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Ipinapalagay ng Claude Code ang 200K na konteksto para sa anumang model id na hindi nito nakikilala. Para sa isang modelo na may ibang tunay na bintana, idagdag ang CLAUDE_CODE_AUTO_COMPACT_WINDOW sa ilalim nito upang hindi masyadong maaga ang pag-activate ng auto-compaction.", "failedSave": "Nabigong i-save", "profileSyncTitle": "Auto-sync ng profile ng CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "May diskwentong API proxy para sa 40+ na modelo kabilang ang GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Kumuha ng iyong API key sa https://freeaiapikey.com/dashboard. Base URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Kumuha ng $300 libreng API credits sa https://freemodel.dev — walang kinakailangang impormasyon sa pagbabayad. OpenAI-compatible na endpoint. Available ang mga modelong GPT-5.4 at GPT-5.5.", "friendliai": "Libreng tier para sa serverless inference — walang kinakailangang credit card", - "gemini": "Libre magpakailanman: 1,500 req/araw para sa Gemini 2.5 Flash — walang credit card, kumuha ng key sa aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Ikonekta ang GigaChat (Sber) gamit ang isang API key.", "gitlab": "GitLab personal access token para sa pampublikong Code Suggestions API. Mag-configure ng self-hosted na base URL kapag hindi gumagamit ng gitlab.com.", "gitlawb-gmi": "Kumuha ng iyong API key mula sa Gitlawb Opengateway dashboard.", @@ -6175,7 +6175,7 @@ "glm-cn": "Ikonekta ang GLM Coding (China) gamit ang isang API key.", "glmt": "Preset na GLM profile na may mas mataas na token budget, naka-enable ang thinking, at mas mahabang timeout.", "getgoapi": "Ikonekta ang GoAPI gamit ang isang API key.", - "groq": "Libreng tier: 30 RPM / 14.4K RPD — walang credit card", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Kumuha ng API key sa haiper.ai/haiper-api", "heroku": "Ikonekta ang Heroku AI gamit ang isang API key.", "hcnsec": "Kumuha ng API key sa api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Bawat segment = isang libreng pool · pool-deduped, tapat na pagbibilang (walang pinalobong mga ceiling ng rate-limit).", "boost": "I-unlock ang ~{tokens} pa/buwan gamit ang isang beses na $10 OpenRouter top-up (50 → 1000 req/araw)", "uncapped": "Permanenteng libre, walang nai-publish na limitasyon (rate-limited) — totoong access, hindi binibilang sa headline:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# modelo} other {# na modelo}} ang na-flag bilang ToS-restricted — ikaw ang magpasya", "provider": "Provider", "model": "Modelo", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index b9ecc290db..39b053ca78 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json dla odkrywania modelu bramy", "ccOnboardingCopy": "Kopiuj", "ccOnboardingCopied": "Skopiowano", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code zakłada okno kontekstowe 200K dla każdego identyfikatora modelu, którego nie rozpoznaje. Dla modelu z innym rzeczywistym oknem, dodaj CLAUDE_CODE_AUTO_COMPACT_WINDOW tuż pod nim, aby automatyczna kompresja nie uruchomiła się zbyt wcześnie.", "failedSave": "Nie udało się zapisać", "profileSyncTitle": "Automatyczna synchronizacja profili CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Zrabatowane proxy API dla ponad 40 modeli, w tym GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Pobierz swój klucz API na stronie https://freeaiapikey.com/dashboard. Bazowy adres URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Odbierz $300 darmowych środków API na stronie https://freemodel.dev — dane płatnicze nie są wymagane. Punkt końcowy zgodny z OpenAI. Dostępne modele GPT-5.4 i GPT-5.5.", "friendliai": "Bezpłatny pakiet do wnioskowania bezserwerowego — karta kredytowa nie jest wymagana", - "gemini": "Darmowe na zawsze: 1500 zapytań/dzień dla Gemini 2.5 Flash — bez karty kredytowej, pobierz klucz na aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Połącz z GigaChat (Sber) za pomocą klucza API.", "gitlab": "Osobisty token dostępu GitLab dla publicznego API Code Suggestions. Skonfiguruj bazowy adres URL dla instancji self-hosted, jeśli nie korzystasz z gitlab.com.", "gitlawb-gmi": "Pobierz swój klucz API z panelu Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Połącz z GLM Coding (China) za pomocą klucza API.", "glmt": "Wstępnie zdefiniowany profil GLM z większym budżetem tokenów, włączonym myśleniem i dłuższym limitem czasu.", "getgoapi": "Połącz z GoAPI za pomocą klucza API.", - "groq": "Darmowy plan: 30 RPM / 14.4K RPD — bez karty kredytowej", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Pobierz klucz API na haiper.ai/haiper-api", "heroku": "Połącz z Heroku AI za pomocą klucza API.", "hcnsec": "Pobierz klucz API na api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Każdy segment = jedna darmowa pula · deduplikacja puli, rzetelne zliczanie (bez zawyżonych limitów zapytań).", "boost": "Odblokuj ~{tokens} więcej/mies. dzięki jednorazowemu doładowaniu OpenRouter za 10 $ (50 → 1000 żądań/dzień)", "uncapped": "Trwale bezpłatne, brak opublikowanego limitu (z ograniczeniem zapytań) — rzeczywisty dostęp, nieuwzględniony w nagłówku:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} few {# modele} many {# modeli} other {# modeli}} oznaczono jako ograniczone przez ToS — Ty decydujesz", "provider": "Dostawca", "model": "Model", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index e65916a54d..c50d940f5e 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -2711,7 +2711,7 @@ "ccOnboardingTitle": "settings.json para descoberta de modelos pelo gateway", "ccOnboardingCopy": "Copiar", "ccOnboardingCopied": "Copiado", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "O Claude Code assume uma janela de contexto de 200K para qualquer id de modelo que ele não reconhece. Para um modelo com janela real diferente, acrescente CLAUDE_CODE_AUTO_COMPACT_WINDOW logo abaixo para a compactação automática não disparar cedo demais.", "failedSave": "Falha ao salvar", "profileSyncTitle": "Sincronização automática de perfis de CLI", @@ -6170,7 +6170,7 @@ "freeaiapikey": "Proxy de API com desconto para mais de 40 modelos, incluindo GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Obtenha sua chave de API em https://freeaiapikey.com/dashboard. URL base: https://freeaiapikey.com/v1.", "freemodel-dev": "Obtenha $300 em créditos de API gratuitos em https://freemodel.dev — sem necessidade de dados de pagamento. Endpoint compatível com OpenAI. Modelos GPT-5.4 e GPT-5.5 disponíveis.", "friendliai": "Nível gratuito para inferência serverless — sem necessidade de cartão de crédito", - "gemini": "Gratuito para sempre: 1.500 solicitações/dia para o Gemini 2.5 Flash — sem cartão de crédito, obtenha a chave em aistudio.google.com", + "gemini": "Plano gratuito pelo Google AI Studio; as quotas por modelo não são mais publicadas (veja a página de quota no AI Studio) — sem cartão de crédito.", "gigachat": "Conecte o GigaChat (Sber) com uma chave de API.", "gitlab": "Token de acesso pessoal do GitLab para a API pública de Code Suggestions. Configure uma URL base self-hosted quando não estiver usando gitlab.com.", "gitlawb-gmi": "Obtenha sua chave de API no dashboard do Gitlawb Opengateway.", @@ -6179,7 +6179,7 @@ "glm-cn": "Conecte o GLM Coding (China) com uma chave de API.", "glmt": "Perfil GLM pré-configurado com orçamento de tokens maior, thinking ativado e timeout mais longo.", "getgoapi": "Conecte o GoAPI com uma chave de API.", - "groq": "Nível gratuito: 30 RPM / 14,4K RPD — sem cartão de crédito", + "groq": "Plano gratuito: limites por modelo (200K tokens/dia por modelo de chat; veja console.groq.com/docs/rate-limits para RPM/RPD) — sem meio de pagamento cadastrado.", "haiper": "Obtenha a chave de API em haiper.ai/haiper-api", "heroku": "Conecte o Heroku AI com uma chave de API.", "hcnsec": "Obtenha a chave de API em api.hcnsec.cn", @@ -13144,6 +13144,7 @@ "segmentHint": "Cada segmento = um pool gratuito · deduplicado por pool, contagem honesta (sem tetos de limite de taxa inflados).", "boost": "Desbloqueie ~{tokens} a mais/mês com uma recarga única de $10 na OpenRouter (50 → 1000 solicitações/dia)", "uncapped": "Permanentemente gratuito, sem teto publicado (limitado por taxa) — acesso real, não contabilizado no total principal:", + "gated": "~{tokens}/mês a mais atrás de verificação de identidade regional (ex.: real-name da China continental) — quota real, fora do headline:", "tosRestricted": "{count, plural, one {# modelo} other {# modelos}} sinalizado(s) como restrito(s) pelos Termos de Serviço — você decide", "provider": "Provedor", "model": "Modelo", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 8e518e2e0e..aa75826e5f 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json para descoberta do modelo de gateway", "ccOnboardingCopy": "Copiar", "ccOnboardingCopied": "Copiado", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code assume uma janela de contexto de 200K para qualquer ID de modelo que não reconhece. Para um modelo com uma janela real diferente, adicione CLAUDE_CODE_AUTO_COMPACT_WINDOW logo abaixo para que a auto-compacção não seja acionada muito cedo.", "failedSave": "Falha ao guardar", "profileSyncTitle": "Sincronização automática de perfis da CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Proxy de API com desconto para mais de 40 modelos, incluindo GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Obtenha a sua chave de API em https://freeaiapikey.com/dashboard. URL base: https://freeaiapikey.com/v1.", "freemodel-dev": "Obtenha $300 em créditos de API gratuitos em https://freemodel.dev — sem necessidade de dados de pagamento. Endpoint compatível com OpenAI. Modelos GPT-5.4 e GPT-5.5 disponíveis.", "friendliai": "Nível gratuito para inferência serverless — sem necessidade de cartão de crédito", - "gemini": "Gratuito para sempre: 1.500 req/dia para o Gemini 2.5 Flash — sem cartão de crédito, obtenha a chave em aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Ligue o GigaChat (Sber) com uma chave de API.", "gitlab": "Token de acesso pessoal do GitLab para a API pública Code Suggestions. Configure um URL base autoalojado quando não estiver a utilizar o gitlab.com.", "gitlawb-gmi": "Obtenha a sua chave de API no painel do Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Ligue o GLM Coding (China) com uma chave de API.", "glmt": "Perfil predefinido do GLM com maior orçamento de tokens, raciocínio ativado e tempo limite mais longo.", "getgoapi": "Ligue a GoAPI com uma chave de API.", - "groq": "Nível gratuito: 30 RPM / 14,4K RPD — sem cartão de crédito", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Obtenha a chave de API em haiper.ai/haiper-api", "heroku": "Ligue o Heroku AI com uma chave de API.", "hcnsec": "Obtenha a chave de API em api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Cada segmento = um pool gratuito · deduplicado por pool, contagem honesta (sem limites de taxa inflacionados).", "boost": "Desbloqueie mais ~{tokens}/mês com um carregamento único de $10 no OpenRouter (50 → 1000 ped/dia)", "uncapped": "Permanentemente gratuito, sem limite publicado (com limite de taxa) — acesso real, não contabilizado no destaque:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# modelo assinalado} other {# modelos assinalados}} com restrições de ToS — você decide", "provider": "Fornecedor", "model": "Modelo", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 0bfdea697e..4475a38325 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json pentru descoperirea modelului gateway", "ccOnboardingCopy": "Copiază", "ccOnboardingCopied": "Copiat", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code presupune o fereastră de context de 200K pentru orice ID de model pe care nu îl recunoaște. Pentru un model cu o fereastră reală diferită, adăugați CLAUDE_CODE_AUTO_COMPACT_WINDOW imediat sub acesta, astfel încât auto-compacția să nu se activeze prea devreme.", "failedSave": "Eroare la salvare", "profileSyncTitle": "Sincronizare automată profil CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Proxy API cu reducere pentru peste 40 de modele, inclusiv GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Obțineți cheia API la https://freeaiapikey.com/dashboard. URL de bază: https://freeaiapikey.com/v1.", "freemodel-dev": "Obțineți $300 credite API gratuite la https://freemodel.dev — nu sunt necesare informații de plată. Endpoint compatibil cu OpenAI. Modele GPT-5.4 și GPT-5.5 disponibile.", "friendliai": "Nivel gratuit pentru inferență serverless — nu este necesar card de credit", - "gemini": "Gratuit pentru totdeauna: 1.500 req/zi pentru Gemini 2.5 Flash — fără card de credit, obțineți cheia la aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Conectați GigaChat (Sber) cu o cheie API.", "gitlab": "Token de acces personal GitLab pentru API-ul public Code Suggestions. Configurați un URL de bază self-hosted când nu utilizați gitlab.com.", "gitlawb-gmi": "Obțineți cheia API din tabloul de bord Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Conectați GLM Coding (China) cu o cheie API.", "glmt": "Profil GLM prestabilit cu un buget de tokenuri mai mare, gândire activată și timeout mai lung.", "getgoapi": "Conectați GoAPI cu o cheie API.", - "groq": "Nivel gratuit: 30 RPM / 14.4K RPD — fără card de credit", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Obțineți cheia API la haiper.ai/haiper-api", "heroku": "Conectați Heroku AI cu o cheie API.", "hcnsec": "Obțineți cheia API la api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Fiecare segment = un pool gratuit · pool deduplicat, contorizare corectă (fără plafoane de limită de rată umflate).", "boost": "Deblochează încă ~{tokens}/lună cu o reîncărcare unică de 10 $ pe OpenRouter (50 → 1000 cereri/zi)", "uncapped": "Permanent gratuit, fără limită publicată (limitat ca rată) — acces real, necontorizat în titlu:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model marcat ca restricționat prin ToS — tu decizi} few {# modele marcate ca restricționate prin ToS — tu decizi} other {# de modele marcate ca restricționate prin ToS — tu decizi}}", "provider": "Furnizor", "model": "Model", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index c63a9fab99..d5ca918520 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json для обнаружения gateway моделей", "ccOnboardingCopy": "Копировать", "ccOnboardingCopied": "Скопировано", - "ccOnboardingKeyPlaceholder": "<ваш API ключ OmniRoute>", + "ccOnboardingKeyPlaceholder": "'<ваш API ключ OmniRoute>'", "ccOnboardingWindowNote": "Claude Code предполагает окно контекста в 200K для любой незнакомой модели. Если у модели другой реальный размер окна, добавьте CLAUDE_CODE_AUTO_COMPACT_WINDOW прямо под ней, чтобы авто-компактизация не срабатывала слишком рано.", "failedSave": "Не удалось сохранить", "profileSyncTitle": "Автосинхронизация профилей CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "API-прокси со скидкой для более чем 40 моделей, включая GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Получите API-ключ на https://freeaiapikey.com/dashboard. Базовый URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Получите бесплатный баланс API $300 на https://freemodel.dev — платежная информация не требуется. Совместимая с OpenAI конечная точка. Доступны модели GPT-5.4 и GPT-5.5.", "friendliai": "Бесплатный тариф для бессерверного инференса — кредитная карта не требуется", - "gemini": "Бесплатно навсегда: 1 500 запросов в день для Gemini 2.5 Flash — без кредитной карты, получите ключ на aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Подключите GigaChat (Сбер) с помощью API-ключа.", "gitlab": "Персональный токен доступа GitLab для публичного Code Suggestions API. Настройте собственный базовый URL-адрес, если не используете gitlab.com.", "gitlawb-gmi": "Получите API-ключ в панели управления Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Подключите GLM Coding (Китай) с помощью API-ключа.", "glmt": "Предустановленный профиль GLM с увеличенным лимитом токенов, включенным режимом рассуждения и более длительным таймаутом.", "getgoapi": "Подключите GoAPI с помощью API-ключа.", - "groq": "Бесплатный тариф: 30 RPM / 14.4K RPD — без кредитной карты", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Получите API-ключ на haiper.ai/haiper-api", "heroku": "Подключите Heroku AI с помощью API-ключа.", "hcnsec": "Получите API-ключ на api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Каждый сегмент = один бесплатный пул · дедупликация пулов, честный подсчет (без завышенных лимитов частоты запросов).", "boost": "Разблокируйте еще ~{tokens}/мес. с помощью разового пополнения OpenRouter на $10 (50 → 1000 запр./день)", "uncapped": "Навсегда бесплатно, без опубликованного лимита (с ограничением частоты) — реальный доступ, не учитывается в заголовке:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# модель помечена как ограниченная Условиями использования} few {# модели помечены как ограниченные Условиями использования} many {# моделей помечено как ограниченные Условиями использования} other {# моделей помечено как ограниченные Условиями использования}} — решать вам", "provider": "Провайдер", "model": "Модель", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index de5778a2cd..bf82f9a7ea 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json pre objavovanie modelu brány", "ccOnboardingCopy": "Kopírovať", "ccOnboardingCopied": "Skopírované", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code predpokladá kontextové okno 200K pre akékoľvek ID modelu, ktoré nerozpoznáva. Pre model s iným skutočným oknom pridajte CLAUDE_CODE_AUTO_COMPACT_WINDOW tesne pod ním, aby sa automatická kompresia nespustila príliš skoro.", "failedSave": "Nepodarilo sa uložiť", "profileSyncTitle": "Automatická synchronizácia profilov CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Zľavnená API proxy pre viac ako 40 modelov vrátane GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Získajte svoj API kľúč na adrese https://freeaiapikey.com/dashboard. Základná URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Získajte bezplatný API kredit 300 $ na adrese https://freemodel.dev — nevyžadujú sa žiadne platobné údaje. Koncový bod kompatibilný s OpenAI. K dispozícii sú modely GPT-5.4 a GPT-5.5.", "friendliai": "Bezplatná úroveň pre serverless inferenciu — nevyžaduje sa kreditná karta", - "gemini": "Navždy zadarmo: 1 500 požiadaviek/deň pre Gemini 2.5 Flash — bez kreditnej karty, kľúč získate na aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Pripojte GigaChat (Sber) pomocou API kľúča.", "gitlab": "Osobný prístupový token GitLab pre verejné API Code Suggestions. Ak nepoužívate gitlab.com, nakonfigurujte vlastnú základnú URL.", "gitlawb-gmi": "Získajte svoj API kľúč z nástenky Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Pripojte GLM Coding (Čína) pomocou API kľúča.", "glmt": "Prednastavený profil GLM s vyšším rozpočtom tokenov, povoleným premýšľaním a dlhším časovým limitom.", "getgoapi": "Pripojte GoAPI pomocou API kľúča.", - "groq": "Bezplatná úroveň: 30 RPM / 14,4K RPD — bez kreditnej karty", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Získajte API kľúč na adrese haiper.ai/haiper-api", "heroku": "Pripojte Heroku AI pomocou API kľúča.", "hcnsec": "Získajte API kľúč na adrese api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Každý segment = jeden bezplatný pool · deduplikované podľa poolov, poctivé počítanie (žiadne nafúknuté stropy limitov).", "boost": "Odomknite o ~{tokens} viac/mes. jednorazovým dobitím 10 $ na OpenRouter (50 → 1000 požiadaviek/deň)", "uncapped": "Trvalo zadarmo, bez zverejneného limitu (obmedzená rýchlosť) — skutočný prístup, nezapočítaný v hlavnom prehľade:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model označený ako obmedzený podmienkami ToS} few {# modely označené ako obmedzené podmienkami ToS} other {# modelov označených ako obmedzené podmienkami ToS}} — rozhodnutie je na vás", "provider": "Poskytovateľ", "model": "Model", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 0408b0806b..42716bd720 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json för gateway-modellupptäckten", "ccOnboardingCopy": "Kopiera", "ccOnboardingCopied": "Kopierad", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code förutsätter ett 200K kontextfönster för alla modell-ID:n som det inte känner igen. För en modell med ett annat verkligt fönster, lägg till CLAUDE_CODE_AUTO_COMPACT_WINDOW precis under den så att automatisk komprimering inte aktiveras för tidigt.", "failedSave": "Misslyckades med att spara", "profileSyncTitle": "Automatisk synkning av CLI-profil", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Rabatterad API-proxy för 40+ modeller inklusive GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Hämta din API-nyckel på https://freeaiapikey.com/dashboard. Bas-URL: https://freeaiapikey.com/v1.", "freemodel-dev": "Få $300 i gratis API-krediter på https://freemodel.dev — ingen betalningsinformation krävs. OpenAI-kompatibel slutpunkt. GPT-5.4- och GPT-5.5-modeller tillgängliga.", "friendliai": "Gratisnivå för serverlös inferens — inget kreditkort krävs", - "gemini": "Gratis för alltid: 1 500 anrop/dag för Gemini 2.5 Flash — inget kreditkort, hämta nyckel på aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Anslut GigaChat (Sber) med en API-nyckel.", "gitlab": "Personlig åtkomsttoken för GitLab för det offentliga Code Suggestions-API:et. Konfigurera en egenvärd bas-URL när du inte använder gitlab.com.", "gitlawb-gmi": "Hämta din API-nyckel från instrumentpanelen för Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Anslut GLM Coding (Kina) med en API-nyckel.", "glmt": "Förinställd GLM-profil med högre tokenbudget, tänkande aktiverat och längre tidsgräns.", "getgoapi": "Anslut GoAPI med en API-nyckel.", - "groq": "Gratisnivå: 30 RPM / 14,4K RPD — inget kreditkort", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Hämta API-nyckel på haiper.ai/haiper-api", "heroku": "Anslut Heroku AI med en API-nyckel.", "hcnsec": "Hämta API-nyckel på api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Varje segment = en gratispool · pool-deduplicerad, ärlig räkning (inga uppblåsta hastighetsbegränsningstak).", "boost": "Lås upp ~{tokens} fler/mån med en engångspåfyllning på $10 hos OpenRouter (50 → 1000 förfrågn./dag)", "uncapped": "Permanent gratis, inget publicerat tak (hastighetsbegränsad) — verklig åtkomst, räknas inte i rubriken:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# modell} other {# modeller}} flaggad som ToS-begränsad — du bestämmer", "provider": "Leverantör", "model": "Modell", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 88b9200348..50462df6b9 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json kwa ugunduzi wa mfano wa lango", "ccOnboardingCopy": "Nakili", "ccOnboardingCopied": "Imepigwa nakala", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code inadhania dirisha la muktadha la 200K kwa kitambulisho chochote cha mfano ambacho hakitambui. Kwa mfano wenye dirisha halisi tofauti, ongeza CLAUDE_CODE_AUTO_COMPACT_WINDOW chini yake ili auto-compaction isifanye kazi mapema sana.", "failedSave": "Imeshindwa kuhifadhi", "profileSyncTitle": "Ulandanishaji wa kiotomatiki wa wasifu wa CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "Proksi ya API yenye punguzo kwa miundo 40+ ikijumuisha GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Pata ufunguo wako wa API kwenye https://freeaiapikey.com/dashboard. URL ya Msingi: https://freeaiapikey.com/v1.", "freemodel-dev": "Pata salio la bure la API la $300 kwenye https://freemodel.dev — hakuna maelezo ya malipo yanayohitajika. Endpoint inayoendana na OpenAI. Miundo ya GPT-5.4 na GPT-5.5 inapatikana.", "friendliai": "Kiwango cha bure cha makisio yasiyo na seva — hakuna kadi ya mkopo inayohitajika", - "gemini": "Bure milele: maombi 1,500/siku kwa Gemini 2.5 Flash — hakuna kadi ya mkopo, pata ufunguo kwenye aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Unganisha GigaChat (Sber) kwa kutumia ufunguo wa API.", "gitlab": "Tokeni ya ufikiaji wa kibinafsi ya GitLab kwa ajili ya API ya umma ya Code Suggestions. Sanidi URL ya msingi ya self-hosted wakati hutumii gitlab.com.", "gitlawb-gmi": "Pata ufunguo wako wa API kutoka kwenye dashibodi ya Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Unganisha GLM Coding (China) kwa kutumia ufunguo wa API.", "glmt": "Wasifu uliowekwa awali wa GLM wenye bajeti ya juu ya tokeni, kufikiri kumewashwa, na muda mrefu zaidi wa kuisha.", "getgoapi": "Unganisha GoAPI kwa kutumia ufunguo wa API.", - "groq": "Kiwango cha bure: 30 RPM / 14.4K RPD — hakuna kadi ya mkopo", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Pata ufunguo wa API kwenye haiper.ai/haiper-api", "heroku": "Unganisha Heroku AI kwa kutumia ufunguo wa API.", "hcnsec": "Pata ufunguo wa API kwenye api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Kila sehemu = pool moja ya bure · pool isiyo na marudio, hesabu ya uaminifu (hakuna dari zilizoongezwa za kikomo cha kasi).", "boost": "Fungua takriban ~{tokens} zaidi/mwezi kwa kuongeza salio la mara moja la $10 la OpenRouter (maombi 50 → 1000/siku)", "uncapped": "Bure kabisa, hakuna kikomo kilichochapishwa (kasi imedhibitiwa) — ufikiaji halisi, haujahesabiwa kwenye kichwa cha habari:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# mfano} other {# mifano}} imewekewa alama kama iliyozuiliwa na ToS — unaamua", "provider": "Mtoa huduma", "model": "Mfano", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 95da2289ce..05f3494c76 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway மாதிரி கண்டுபிடிப்புக்கு settings.json", "ccOnboardingCopy": "பதிப்பேற்று", "ccOnboardingCopied": "பிரதி எடுக்கப்பட்டது", - "ccOnboardingKeyPlaceholder": "<உங்கள் OmniRoute API விசை>", + "ccOnboardingKeyPlaceholder": "'<உங்கள் OmniRoute API விசை>'", "ccOnboardingWindowNote": "Claude Code எந்த மாதிரி அடையாளத்தை அடையாளம் காணவில்லை என்றால் 200K சூழல் ஜன்னலைக் கருதுகிறது. வேறு உண்மையான ஜன்னலுடன் உள்ள மாதிரிக்கு, CLAUDE_CODE_AUTO_COMPACT_WINDOW ஐ அதன் கீழே சேர்க்கவும், எனவே தானியங்கி சுருக்கம் மிகவும் விரைவாக செயல்படாது.", "failedSave": "சேமிப்பதில் தோல்வி", "profileSyncTitle": "CLI சுயவிவர தானியங்கு ஒத்திசைவு", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 உட்பட 40+ மாதிரிகளுக்கான தள்ளுபடி செய்யப்பட்ட API ப்ராக்ஸி. https://freeaiapikey.com/dashboard இல் உங்கள் API விசையைப் பெறவும். அடிப்படை URL: https://freeaiapikey.com/v1.", "freemodel-dev": "https://freemodel.dev இல் $300 இலவச API கிரெடிட்களைப் பெறவும் — கட்டணத் தகவல் தேவையில்லை. OpenAI-இணக்கமான எண்ட்பாயிண்ட். GPT-5.4 மற்றும் GPT-5.5 மாதிரிகள் கிடைக்கின்றன.", "friendliai": "சர்வர்லெஸ் இன்ஃபெரன்ஸிற்கான இலவச அடுக்கு — கிரெடிட் கார்டு தேவையில்லை", - "gemini": "எப்போதும் இலவசம்: Gemini 2.5 Flash-க்கு ஒரு நாளைக்கு 1,500 கோரிக்கைகள் — கிரெடிட் கார்டு தேவையில்லை, aistudio.google.com இல் விசையைப் பெறவும்", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "GigaChat (Sber) ஐ ஒரு API விசையுடன் இணைக்கவும்.", "gitlab": "பொது குறியீடு பரிந்துரைகள் API க்கான GitLab தனிப்பட்ட அணுகல் டோக்கன். gitlab.com ஐப் பயன்படுத்தாதபோது சுய-ஹோஸ்ட் செய்யப்பட்ட அடிப்படை URL ஐ உள்ளமைக்கவும்.", "gitlawb-gmi": "Gitlawb Opengateway டாஷ்போர்டிலிருந்து உங்கள் API விசையைப் பெறவும்.", @@ -6175,7 +6175,7 @@ "glm-cn": "GLM Coding (China) ஐ ஒரு API விசையுடன் இணைக்கவும்.", "glmt": "அதிக டோக்கன் பட்ஜெட், சிந்தனை இயக்கப்பட்டது மற்றும் நீண்ட காலாவதி நேரத்துடன் கூடிய முன்னமைக்கப்பட்ட GLM சுயவிவரம்.", "getgoapi": "GoAPI ஐ ஒரு API விசையுடன் இணைக்கவும்.", - "groq": "இலவச அடுக்கு: 30 RPM / 14.4K RPD — கிரெடிட் கார்டு தேவையில்லை", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api இல் API விசையைப் பெறவும்", "heroku": "Heroku AI ஐ ஒரு API விசையுடன் இணைக்கவும்.", "hcnsec": "api.hcnsec.cn இல் API விசையைப் பெறுக", @@ -13133,6 +13133,7 @@ "segmentHint": "ஒவ்வொரு பகுதியும் = ஒரு இலவச பூல் · பூல்-நகல் நீக்கப்பட்டது, நேர்மையான எண்ணிக்கை (அதிகரித்த விகித-வரம்பு உச்சவரம்புகள் இல்லை).", "boost": "ஒரு முறை $10 OpenRouter டாப்-அப் மூலம் மாதத்திற்கு மேலும் ~{tokens} ஐ அன்லாக் செய்யவும் (50 → 1000 கோரிக்கைகள்/நாள்)", "uncapped": "நிரந்தரமாக இலவசம், வெளியிடப்பட்ட வரம்பு இல்லை (விகிதம் வரையறுக்கப்பட்டது) — உண்மையான அணுகல், தலைப்புச் செய்தியில் கணக்கிடப்படவில்லை:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# மாடல்} other {# மாடல்கள்}} சேவை விதிமுறைகள் (ToS) கட்டுப்படுத்தப்பட்டதாகக் குறிக்கப்பட்டுள்ளது — நீங்களே முடிவு செய்யுங்கள்", "provider": "வழங்குநர்", "model": "மாடல்", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 3526a50b41..6aee70dfa5 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway మోడల్ డిస్కవరీ కోసం settings.json", "ccOnboardingCopy": "కాపీ", "ccOnboardingCopied": "కాపీ చేయబడింది", - "ccOnboardingKeyPlaceholder": "<మీ OmniRoute API కీ>", + "ccOnboardingKeyPlaceholder": "'<మీ OmniRoute API కీ>'", "ccOnboardingWindowNote": "Claude Code గుర్తించని ఏ మోడల్ ఐడికి 200K సందర్భం కిటికీని అనుకుంటుంది. వేరే వాస్తవ కిటికీ ఉన్న మోడల్ కోసం, ఆటో-కంపాక్షన్ చాలా త్వరగా జరగకుండా ఉండటానికి దాని కింద CLAUDE_CODE_AUTO_COMPACT_WINDOWని జోడించండి.", "failedSave": "సేవ్ చేయడం విఫలమైంది", "profileSyncTitle": "CLI ప్రొఫైల్ ఆటో-సింక్", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 తో సహా 40+ మోడల్‌ల కోసం డిస్కౌంట్ పొందిన API ప్రాక్సీ. https://freeaiapikey.com/dashboard వద్ద మీ API కీని పొందండి. బేస్ URL: https://freeaiapikey.com/v1.", "freemodel-dev": "https://freemodel.dev వద్ద $300 ఉచిత API క్రెడిట్‌లను పొందండి — చెల్లింపు సమాచారం అవసరం లేదు. OpenAI-అనుకూల ఎండ్‌పాయింట్. GPT-5.4 మరియు GPT-5.5 మోడల్‌లు అందుబాటులో ఉన్నాయి.", "friendliai": "సర్వర్‌లెస్ ఇన్ఫరెన్స్ కోసం ఉచిత టైర్ — క్రెడిట్ కార్డ్ అవసరం లేదు", - "gemini": "ఎప్పటికీ ఉచితం: Gemini 2.5 Flash కోసం రోజుకు 1,500 అభ్యర్థనలు — క్రెడిట్ కార్డ్ అవసరం లేదు, aistudio.google.com వద్ద కీని పొందండి", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "API కీతో GigaChat (Sber) ని కనెక్ట్ చేయండి.", "gitlab": "పబ్లిక్ Code Suggestions API కోసం GitLab వ్యక్తిగత యాక్సెస్ టోకెన్. gitlab.com ని ఉపయోగించనప్పుడు సెల్ఫ్-హోస్టెడ్ బేస్ URLని కాన్ఫిగర్ చేయండి.", "gitlawb-gmi": "Gitlawb Opengateway డ్యాష్‌బోర్డ్ నుండి మీ API కీని పొందండి.", @@ -6175,7 +6175,7 @@ "glm-cn": "API కీతో GLM Coding (China) ని కనెక్ట్ చేయండి.", "glmt": "ఎక్కువ టోకెన్ బడ్జెట్, థింకింగ్ ఎనేబుల్ చేయబడిన మరియు ఎక్కువ టైమ్‌అవుట్‌తో కూడిన ప్రీసెట్ GLM ప్రొఫైల్.", "getgoapi": "API కీతో GoAPI ని కనెక్ట్ చేయండి.", - "groq": "ఉచిత టైర్: 30 RPM / 14.4K RPD — క్రెడిట్ కార్డ్ అవసరం లేదు", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api వద్ద API కీని పొందండి", "heroku": "API కీతో Heroku AI ని కనెక్ట్ చేయండి.", "hcnsec": "api.hcnsec.cn వద్ద API కీని పొందండి", @@ -13133,6 +13133,7 @@ "segmentHint": "ప్రతి విభాగం = ఒక ఉచిత పూల్ · పూల్-డూప్లికేట్ తీసివేసినది, నిజాయితీ గల లెక్కింపు (ఎక్కువ చేసి చూపిన రేట్-పరిమితి గరిష్టాలు లేవు).", "boost": "ఒకేసారి $10 OpenRouter టాప్-అప్‌తో నెలకు మరో ~{tokens} అన్‌లాక్ చేయండి (రోజుకు 50 → 1000 అభ్యర్థనలు)", "uncapped": "శాశ్వతంగా ఉచితం, ప్రచురించిన పరిమితి లేదు (రేట్-పరిమితం చేయబడింది) — నిజమైన యాక్సెస్, హెడ్‌లైన్‌లో లెక్కించబడదు:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# మోడల్} other {# మోడల్స్}} ToS-పరిమితం చేయబడినట్లుగా ఫ్లాగ్ చేయబడ్డాయి — మీరే నిర్ణయించుకోండి", "provider": "ప్రొవైడర్", "model": "మోడల్", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index e19eb88c20..b8ea2db512 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json สำหรับการค้นพบโมเดลเกตเวย์", "ccOnboardingCopy": "คัดลอก", "ccOnboardingCopied": "คัดลอกแล้ว", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code สมมติว่ามีหน้าต่างบริบท 200K สำหรับรหัสโมเดลใด ๆ ที่มันไม่รู้จัก สำหรับโมเดลที่มีหน้าต่างจริงที่แตกต่างกัน ให้เพิ่ม CLAUDE_CODE_AUTO_COMPACT_WINDOW ลงไปใต้โมเดลนั้นเพื่อไม่ให้การบีบอัดอัตโนมัติทำงานเร็วเกินไป", "failedSave": "บันทึกไม่สำเร็จ", "profileSyncTitle": "การซิงค์โปรไฟล์ CLI อัตโนมัติ", @@ -6166,7 +6166,7 @@ "freeaiapikey": "พร็อกซี API ราคาพิเศษสำหรับโมเดลมากกว่า 40 โมเดล รวมถึง GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 รับคีย์ API ของคุณได้ที่ https://freeaiapikey.com/dashboard URL ฐาน: https://freeaiapikey.com/v1", "freemodel-dev": "รับเครดิต API ฟรี $300 ที่ https://freemodel.dev — ไม่ต้องใช้ข้อมูลการชำระเงิน ปลายทางที่เข้ากันได้กับ OpenAI มีโมเดล GPT-5.4 และ GPT-5.5 ให้บริการ", "friendliai": "ระดับการใช้งานฟรีสำหรับการอนุมานแบบ Serverless — ไม่ต้องใช้บัตรเครดิต", - "gemini": "ฟรีตลอดชีพ: 1,500 คำขอ/วันสำหรับ Gemini 2.5 Flash — ไม่ต้องใช้บัตรเครดิต รับคีย์ได้ที่ aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "เชื่อมต่อ GigaChat (Sber) ด้วยคีย์ API", "gitlab": "โทเค็นการเข้าถึงส่วนตัวของ GitLab สำหรับ Code Suggestions API สาธารณะ กำหนดค่า URL ฐานแบบโฮสต์เองเมื่อไม่ได้ใช้งาน gitlab.com", "gitlawb-gmi": "รับคีย์ API ของคุณจากแดชบอร์ด Gitlawb Opengateway", @@ -6175,7 +6175,7 @@ "glm-cn": "เชื่อมต่อ GLM Coding (China) ด้วยคีย์ API", "glmt": "โปรไฟล์ GLM ที่ตั้งค่าไว้ล่วงหน้าพร้อมงบประมาณโทเค็นที่สูงขึ้น เปิดใช้งานการคิด และหมดเวลาการทำงานที่นานขึ้น", "getgoapi": "เชื่อมต่อ GoAPI ด้วยคีย์ API", - "groq": "ระดับการใช้งานฟรี: 30 RPM / 14.4K RPD — ไม่ต้องใช้บัตรเครดิต", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "รับคีย์ API ได้ที่ haiper.ai/haiper-api", "heroku": "เชื่อมต่อ Heroku AI ด้วยคีย์ API", "hcnsec": "รับ API key ได้ที่ api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "แต่ละส่วน = หนึ่งพูลฟรี · ลดข้อมูลซ้ำในพูล, นับตามจริง (ไม่มีการเพิ่มเพดานจำกัดอัตราการใช้งานเกินจริง)", "boost": "ปลดล็อกเพิ่มอีกประมาณ ~{tokens}/เดือน ด้วยการเติมเงิน OpenRouter $10 ครั้งเดียว (50 → 1000 คำขอ/วัน)", "uncapped": "ฟรีถาวร ไม่มีขีดจำกัดที่เผยแพร่ (จำกัดอัตราการใช้งาน) — เข้าถึงได้จริง ไม่นับรวมในหัวข้อหลัก:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# โมเดล} other {# โมเดล}} ถูกทำเครื่องหมายว่าจำกัดตามข้อกำหนดการให้บริการ — คุณเป็นผู้ตัดสินใจ", "provider": "ผู้ให้บริการ", "model": "โมเดล", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 12b3973933..9875c62e73 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway modeli keşfi için settings.json", "ccOnboardingCopy": "Kopyala", "ccOnboardingCopied": "Kopyalandı", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code, tanımadığı herhangi bir model kimliği için 200K bağlam penceresi varsayıyor. Farklı bir gerçek pencereye sahip bir model için, otomatik sıkıştırmanın çok erken başlamaması için hemen altına CLAUDE_CODE_AUTO_COMPACT_WINDOW ekleyin.", "failedSave": "Kaydedilemedi", "profileSyncTitle": "CLI profili otomatik senkronizasyonu", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 dahil 40'tan fazla model için indirimli API proxy'si. API anahtarınızı https://freeaiapikey.com/dashboard adresinden alın. Temel URL: https://freeaiapikey.com/v1.", "freemodel-dev": "https://freemodel.dev adresinden 300$ ücretsiz API kredisi alın — ödeme bilgisi gerekmez. OpenAI uyumlu uç nokta. GPT-5.4 ve GPT-5.5 modelleri mevcuttur.", "friendliai": "Sunucusuz çıkarım için ücretsiz katman — kredi kartı gerekmez", - "gemini": "Sonsuza kadar ücretsiz: Gemini 2.5 Flash için günlük 1.500 istek — kredi kartı gerekmez, anahtarı aistudio.google.com adresinden alın", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "GigaChat'i (Sber) bir API anahtarı ile bağlayın.", "gitlab": "Genel Code Suggestions API'si için GitLab kişisel erişim belirteci. gitlab.com kullanmadığınızda barındırılan bir temel URL yapılandırın.", "gitlawb-gmi": "API anahtarınızı Gitlawb Opengateway panelinden alın.", @@ -6175,7 +6175,7 @@ "glm-cn": "GLM Coding'i (Çin) bir API anahtarı ile bağlayın.", "glmt": "Daha yüksek token bütçesi, düşünme etkinleştirilmiş ve daha uzun zaman aşımına sahip önceden ayarlanmış GLM profili.", "getgoapi": "GoAPI'yi bir API anahtarı ile bağlayın.", - "groq": "Ücretsiz katman: 30 RPM / 14.4K RPD — kredi kartı gerekmez", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "API anahtarını haiper.ai/haiper-api adresinden alın", "heroku": "Heroku AI'ı bir API anahtarı ile bağlayın.", "hcnsec": "API anahtarını api.hcnsec.cn adresinden alın", @@ -13133,6 +13133,7 @@ "segmentHint": "Her segment = bir ücretsiz havuz · havuzdan tekilleştirilmiş, dürüst sayım (şişirilmiş istek sınırı tavanları yok).", "boost": "Tek seferlik 10 $'lık OpenRouter bakiye yüklemesi ile ayda yaklaşık ~{tokens} daha fazlasının kilidini açın (50 → 1000 istek/gün)", "uncapped": "Kalıcı olarak ücretsiz, yayınlanmış bir sınır yok (istek sınırlı) — gerçek erişim, başlıkta sayılmaz:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# model}} Hizmet Şartları kısıtlamalı olarak işaretlendi — karar sizin", "provider": "Sağlayıcı", "model": "Model", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index fd9a0732d3..3acb5c19e9 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "settings.json для виявлення моделі шлюзу", "ccOnboardingCopy": "Копіювати", "ccOnboardingCopied": "Скопійовано", - "ccOnboardingKeyPlaceholder": "<ваш ключ API OmniRoute>", + "ccOnboardingKeyPlaceholder": "'<ваш ключ API OmniRoute>'", "ccOnboardingWindowNote": "Claude Code припускає вікно контексту 200K для будь-якого ідентифікатора моделі, який він не розпізнає. Для моделі з іншим реальним вікном додайте CLAUDE_CODE_AUTO_COMPACT_WINDOW безпосередньо під ним, щоб автоматичне стиснення не спрацьовувало занадто рано.", "failedSave": "Не вдалося зберегти", "profileSyncTitle": "Автосинхронізація профілів CLI", @@ -6166,7 +6166,7 @@ "freeaiapikey": "API-проксі зі знижкою для понад 40 моделей, включаючи GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Отримайте свій API-ключ на https://freeaiapikey.com/dashboard. Базова URL-адреса: https://freeaiapikey.com/v1.", "freemodel-dev": "Отримайте $300 безкоштовних API-кредитів на https://freemodel.dev — платіжна інформація не потрібна. Сумісна з OpenAI кінцева точка. Доступні моделі GPT-5.4 та GPT-5.5.", "friendliai": "Безкоштовний тариф для безсерверного виведення — кредитна картка не потрібна", - "gemini": "Безкоштовно назавжди: 1500 зап/день для Gemini 2.5 Flash — без кредитної картки, отримайте ключ на aistudio.google.com", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "Підключіть GigaChat (Сбер) за допомогою API-ключа.", "gitlab": "Особистий токен доступу GitLab для публічного API Code Suggestions. Налаштуйте власну базову URL-адресу, якщо не використовуєте gitlab.com.", "gitlawb-gmi": "Отримайте свій API-ключ на панелі керування Gitlawb Opengateway.", @@ -6175,7 +6175,7 @@ "glm-cn": "Підключіть GLM Coding (Китай) за допомогою API-ключа.", "glmt": "Попередньо встановлений профіль GLM із більшим бюджетом токенів, увімкненим мисленням та довшим таймаутом.", "getgoapi": "Підключіть GoAPI за допомогою API-ключа.", - "groq": "Безкоштовний тариф: 30 RPM / 14.4K RPD — без кредитної картки", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "Отримайте API-ключ на haiper.ai/haiper-api", "heroku": "Підключіть Heroku AI за допомогою API-ключа.", "hcnsec": "Отримайте API-ключ на api.hcnsec.cn", @@ -13133,6 +13133,7 @@ "segmentHint": "Кожен сегмент = один безкоштовний пул · дедуплікований пул, чесний підрахунок (без завищених лімітів частоти запитів).", "boost": "Розблокуйте ще ~{tokens}/міс за допомогою одноразового поповнення OpenRouter на $10 (50 → 1000 зап./день)", "uncapped": "Постійно безкоштовно, без опублікованого ліміту (з обмеженням частоти) — реальний доступ, не враховується в заголовку:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# модель позначена як обмежена ToS — рішення за вами} few {# моделі позначені як обмежені ToS — рішення за вами} many {# моделей позначено як обмежені ToS — рішення за вами} other {# моделі позначено як обмежені ToS — рішення за вами}}", "provider": "Провайдер", "model": "Модель", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 02dba16c45..61baa9bfb6 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway ماڈل کی دریافت کے لیے settings.json", "ccOnboardingCopy": "کاپی", "ccOnboardingCopied": "نقل کیا گیا", - "ccOnboardingKeyPlaceholder": "<آپ کا OmniRoute API کلید>", + "ccOnboardingKeyPlaceholder": "'<آپ کا OmniRoute API کلید>'", "ccOnboardingWindowNote": "Claude Code کسی بھی ماڈل ID کے لیے 200K سیاق و سباق کی ونڈو فرض کرتا ہے جسے یہ نہیں پہچانتا۔ اگر کسی ماڈل کی حقیقی ونڈو مختلف ہو تو اس کے نیچے CLAUDE_CODE_AUTO_COMPACT_WINDOW شامل کریں تاکہ خودکار کمپیکشن بہت جلد نہ ہو۔", "failedSave": "محفوظ کرنے میں ناکامی", "profileSyncTitle": "CLI پروفائل کی خودکار مطابقت پذیری", @@ -6166,7 +6166,7 @@ "freeaiapikey": "GPT-5، Claude Opus 4.6، Claude Sonnet 4.6، Qwen 3.5 سمیت 40+ ماڈلز کے لیے رعایتی API پراکسی۔ اپنی API کی https://freeaiapikey.com/dashboard پر حاصل کریں۔ بیس URL: https://freeaiapikey.com/v1۔", "freemodel-dev": "https://freemodel.dev پر $300 کے مفت API کریڈٹس حاصل کریں — ادائیگی کی معلومات درکار نہیں۔ OpenAI-compatible اینڈ پوائنٹ۔ GPT-5.4 اور GPT-5.5 ماڈلز دستیاب ہیں۔", "friendliai": "سرور لیس انفیرنس کے لیے مفت ٹیر — کسی کریڈٹ کارڈ کی ضرورت نہیں", - "gemini": "ہمیشہ کے لیے مفت: Gemini 2.5 Flash کے لیے 1,500 درخواستیں/دن — کوئی کریڈٹ کارڈ نہیں، aistudio.google.com پر کی حاصل کریں", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "GigaChat (Sber) کو ایک API کی کے ساتھ منسلک کریں۔", "gitlab": "عوامی Code Suggestions API کے لیے GitLab پرسنل ایکسیس ٹوکن۔ جب gitlab.com استعمال نہ کر رہے ہوں تو ایک سیلف ہوسٹڈ بیس URL کنفیگر کریں۔", "gitlawb-gmi": "Gitlawb Opengateway ڈیش بورڈ سے اپنی API کی حاصل کریں۔", @@ -6175,7 +6175,7 @@ "glm-cn": "GLM Coding (China) کو ایک API کی کے ساتھ منسلک کریں۔", "glmt": "زیادہ ٹوکن بجٹ، تھنکنگ فعال، اور طویل ٹائم آؤٹ کے ساتھ پہلے سے سیٹ کردہ GLM پروفائل۔", "getgoapi": "GoAPI کو ایک API کی کے ساتھ منسلک کریں۔", - "groq": "مفت ٹیر: 30 RPM / 14.4K RPD — کوئی کریڈٹ کارڈ نہیں", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "haiper.ai/haiper-api پر API کی حاصل کریں", "heroku": "Heroku AI کو ایک API کی کے ساتھ منسلک کریں۔", "hcnsec": "api.hcnsec.cn پر API کی حاصل کریں", @@ -13133,6 +13133,7 @@ "segmentHint": "ہر حصہ = ایک مفت پول · پول ڈی ڈپلیکیٹڈ، ایماندارانہ گنتی (بغیر کسی بڑھی ہوئی ریٹ لمٹ کی حد کے)۔", "boost": "ایک بار $10 OpenRouter ٹاپ اپ کے ساتھ مزید ~{tokens}/ماہ ان لاک کریں (50 → 1000 req/day)", "uncapped": "مستقل طور پر مفت، کوئی شائع شدہ حد نہیں (ریٹ لمیٹڈ) — حقیقی رسائی، ہیڈ لائن میں شمار نہیں:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# ماڈل} other {# ماڈلز}} ToS-محدود کے طور پر نشان زد — آپ فیصلہ کریں", "provider": "فراہم کنندہ", "model": "ماڈل", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index cf7235f61b..45fb862ce3 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -2711,7 +2711,7 @@ "ccOnboardingTitle": "settings.json cho việc khám phá mô hình qua gateway", "ccOnboardingCopy": "Sao chép", "ccOnboardingCopied": "Đã sao chép", - "ccOnboardingKeyPlaceholder": "", + "ccOnboardingKeyPlaceholder": "''", "ccOnboardingWindowNote": "Claude Code mặc định coi mọi id mô hình mà nó không nhận ra là có cửa sổ ngữ cảnh 200K. Với mô hình có cửa sổ thực tế khác, hãy thêm CLAUDE_CODE_AUTO_COMPACT_WINDOW ngay bên dưới để việc nén ngữ cảnh tự động không kích hoạt quá sớm.", "failedSave": "Không thể lưu", "profileSyncTitle": "Tự động đồng bộ hồ sơ CLI", @@ -6170,7 +6170,7 @@ "freeaiapikey": "Proxy API giảm giá cho hơn 40 mô hình, gồm GPT-5, Claude Opus 4.6, Claude Sonnet 4.6 và Qwen 3.5. Lấy khóa API tại https://freeaiapikey.com/dashboard. URL cơ sở: https://freeaiapikey.com/v1.", "freemodel-dev": "Nhận 300 USD tín dụng API miễn phí tại https://freemodel.dev — không cần thông tin thanh toán. Endpoint tương thích OpenAI. Có GPT-5.4 và GPT-5.5.", "friendliai": "Gói miễn phí cho suy luận serverless — không cần thẻ tín dụng", - "gemini": "Miễn phí vĩnh viễn: 1.500 yêu cầu/ngày cho Gemini 2.5 Flash — không cần thẻ tín dụng, lấy khóa tại aistudio.google.com", + "gemini": "Gói miễn phí qua Google AI Studio; hạn mức theo từng mô hình không còn được công bố (xem trang hạn mức trong AI Studio) — không cần thẻ tín dụng.", "gigachat": "Kết nối GigaChat (Sber) bằng khóa API.", "gitlab": "Personal access token GitLab cho API Code Suggestions công khai. Cấu hình URL cơ sở tự lưu trữ khi không dùng gitlab.com.", "gitlawb-gmi": "Lấy khóa API của bạn từ Gitlawb Opengateway dashboard.", @@ -6179,7 +6179,7 @@ "glm-cn": "Kết nối GLM Coding (China) bằng khóa API.", "glmt": "Hồ sơ GLM đặt sẵn với ngân sách token cao hơn, bật thinking và thời gian chờ dài hơn.", "getgoapi": "Kết nối GoAPI bằng khóa API.", - "groq": "Gói miễn phí: 30 RPM / 14,4 nghìn RPD — không cần thẻ tín dụng", + "groq": "Gói miễn phí: giới hạn theo từng mô hình (200K token/ngày cho mỗi mô hình chat; xem console.groq.com/docs/rate-limits để biết RPM/RPD) — không cần đăng ký phương thức thanh toán.", "haiper": "Lấy khóa API tại haiper.ai/haiper-api", "heroku": "Kết nối Heroku AI bằng khóa API.", "hcnsec": "Lấy khóa API tại api.hcnsec.cn", @@ -13144,6 +13144,7 @@ "segmentHint": "Mỗi đoạn là một nhóm miễn phí · đã khử trùng lặp theo nhóm, đếm đúng thực tế (không thổi phồng trần giới hạn tốc độ).", "boost": "Mở khóa thêm khoảng {tokens}/tháng bằng một lần nạp $10 vào OpenRouter (50 → 1000 yêu cầu/ngày)", "uncapped": "Miễn phí vĩnh viễn, không công bố giới hạn (bị giới hạn tốc độ) — quyền truy cập thực, không tính vào tổng nổi bật:", + "gated": "~{tokens}/tháng nữa nằm sau bước xác minh danh tính theo khu vực (ví dụ: xác thực tên thật ở Trung Quốc đại lục) — hạn mức thật, không tính vào con số chính:", "tosRestricted": "{count, plural, one {# model} other {# models}} flagged as ToS-restricted — you decide", "provider": "Nhà cung cấp", "model": "Mô hình", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e53f191cf9..282545b274 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "网关模型发现的 settings.json", "ccOnboardingCopy": "复制", "ccOnboardingCopied": "已复制", - "ccOnboardingKeyPlaceholder": "<你的 OmniRoute API 密钥>", + "ccOnboardingKeyPlaceholder": "'<你的 OmniRoute API 密钥>'", "ccOnboardingWindowNote": "Claude Code 对所有不认识的模型 ID 都假设 200K 上下文窗口。如果模型的实际窗口不同,请在下方添加 CLAUDE_CODE_AUTO_COMPACT_WINDOW,这样自动压缩就不会过早触发。", "failedSave": "保存失败", "profileSyncTitle": "CLI 配置文件自动同步", @@ -6166,7 +6166,7 @@ "freeaiapikey": "适用于 40+ 种模型的折扣 API 代理,包括 GPT-5、Claude Opus 4.6、Claude Sonnet 4.6、Qwen 3.5。在 https://freeaiapikey.com/dashboard 获取您的 API 密钥。Base URL: https://freeaiapikey.com/v1.", "freemodel-dev": "在 https://freemodel.dev 获取 $300 免费 API 额度 — 无需支付信息。兼容 OpenAI 的端点。提供 GPT-5.4 和 GPT-5.5 模型。", "friendliai": "无服务器推理免费层 — 无需信用卡", - "gemini": "永久免费:Gemini 2.5 Flash 每天 1,500 次请求 — 无需信用卡,在 aistudio.google.com 获取密钥", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "使用 API 密钥连接 GigaChat (Sber)。", "gitlab": "用于公共 Code Suggestions API 的 GitLab 个人访问令牌。不使用 gitlab.com 时请配置自托管 Base URL。", "gitlawb-gmi": "从 Gitlawb Opengateway 控制面板获取您的 API 密钥。", @@ -6175,7 +6175,7 @@ "glm-cn": "使用 API 密钥连接 GLM Coding (China)。", "glmt": "预设 GLM 配置文件,具有更高的 Token 预算、启用思考功能以及更长的超时时间。", "getgoapi": "使用 API 密钥连接 GoAPI。", - "groq": "免费层:30 RPM / 14.4K RPD — 无需信用卡", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "在 haiper.ai/haiper-api 获取 API 密钥", "heroku": "使用 API 密钥连接 Heroku AI。", "hcnsec": "在 api.hcnsec.cn 获取 API 密钥", @@ -13133,6 +13133,7 @@ "segmentHint": "每个分段 = 一个免费池 · 池去重,真实统计(无虚高的速率限制上限)。", "boost": "一次性充值 $10 OpenRouter 即可每月多解锁约 ~{tokens}(50 → 1000 次请求/天)", "uncapped": "永久免费,无公开上限(受速率限制)— 实际可用,未计入总览:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# 个模型} other {# 个模型}}被标记为受 ToS 限制 — 由您决定", "provider": "提供者", "model": "模型", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 2cddce94b2..e6c513e06e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2710,7 +2710,7 @@ "ccOnboardingTitle": "gateway 模型發現的 settings.json", "ccOnboardingCopy": "複製", "ccOnboardingCopied": "已複製", - "ccOnboardingKeyPlaceholder": "<您的 OmniRoute API 金鑰>", + "ccOnboardingKeyPlaceholder": "'<您的 OmniRoute API 金鑰>'", "ccOnboardingWindowNote": "Claude Code 假設對於任何它不認識的模型 ID,使用 200K 的上下文窗口。對於具有不同實際窗口的模型,請在其下方添加 CLAUDE_CODE_AUTO_COMPACT_WINDOW,以便自動壓縮不會過早觸發。", "failedSave": "無法儲存", "profileSyncTitle": "CLI 設定檔自動同步", @@ -6166,7 +6166,7 @@ "freeaiapikey": "40+ 種模型的折扣 API 代理,包括 GPT-5、Claude Opus 4.6、Claude Sonnet 4.6、Qwen 3.5。在 https://freeaiapikey.com/dashboard 取得 API 金鑰。基本 URL:https://freeaiapikey.com/v1。", "freemodel-dev": "在 https://freemodel.dev 取得 $300 美元免費 API 額度 — 無需付款資訊。OpenAI 相容端點。提供 GPT-5.4 和 GPT-5.5 模型。", "friendliai": "無伺服器推論的免費方案 — 無需信用卡", - "gemini": "永久免費:Gemini 2.5 Flash 每天 1,500 次請求 — 無需信用卡,在 aistudio.google.com 取得金鑰", + "gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.", "gigachat": "使用 API 金鑰連線 GigaChat(Sber)。", "gitlab": "用於公開 Code Suggestions API 的 GitLab 個人存取權杖。不使用 gitlab.com 時,請設定自託管的基本 URL。", "gitlawb-gmi": "從 Gitlawb Opengateway 儀表板取得 API 金鑰。", @@ -6175,7 +6175,7 @@ "glm-cn": "使用 API 金鑰連線 GLM Coding(中國)。", "glmt": "預設 GLM 設定檔,具有較高的 token 預算、啟用思考功能,以及更長的超時時間。", "getgoapi": "使用 API 金鑰連線 GoAPI。", - "groq": "免費方案:每分鐘 30 次 / 每天 14,400 次請求 — 無需信用卡", + "groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", "haiper": "在 haiper.ai/haiper-api 取得 API 金鑰", "heroku": "使用 API 金鑰連線 Heroku AI。", "hcnsec": "在 api.hcnsec.cn 取得 API 金鑰", @@ -13133,6 +13133,7 @@ "segmentHint": "每個區段 = 一個免費池 · 池間去重、誠實計數(無膨脹的速率限制上限)。", "boost": "一次性 $10 OpenRouter 充值即可解鎖約 {tokens}/月(50 → 1000 請求/天)", "uncapped": "永久免費,無公佈上限(有速率限制)——真實存取,不計入標題數字:", + "gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:", "tosRestricted": "{count, plural, one {# model} other {# models}} flagged as ToS-restricted — you decide", "provider": "提供者", "model": "模型", diff --git a/src/lib/cli-helper/config-generator/claude.ts b/src/lib/cli-helper/config-generator/claude.ts index 2b2490690f..645da8fc04 100644 --- a/src/lib/cli-helper/config-generator/claude.ts +++ b/src/lib/cli-helper/config-generator/claude.ts @@ -16,9 +16,13 @@ export function generateClaudeConfig(options: { const model = options.model || "claude-3-5-sonnet-20241022"; const config = { - baseUrl: `${base}/v1`, - authToken: options.apiKey, - models: [{ id: model }], + model, + env: { + ANTHROPIC_BASE_URL: base, + ANTHROPIC_AUTH_TOKEN: options.apiKey, + ANTHROPIC_MODEL: model, + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", + }, }; return JSON.stringify(config, null, 2); diff --git a/src/lib/credentialHealth/cache.ts b/src/lib/credentialHealth/cache.ts index e3283cb616..85bd9431c9 100644 --- a/src/lib/credentialHealth/cache.ts +++ b/src/lib/credentialHealth/cache.ts @@ -175,29 +175,80 @@ export function getAllCredentialHealth(): Record return result; } -/** - * Get cache summary stats for health API. - */ -export function getCredentialHealthSummary(): { +export interface CredentialHealthSummary { total: number; healthy: number; failed: number; unknown: number; stale: number; -} { - const all = getAllCredentialHealth(); - const entries = Object.values(all); - const now = Date.now(); +} - return { - total: entries.length, - healthy: entries.filter((e) => e.status === "active").length, - failed: entries.filter((e) => e.status === "error").length, - unknown: entries.filter((e) => e.status === "unknown").length, - stale: entries.filter((e) => now - e.lastTested.getTime() > STALE_THRESHOLD_MS).length, +/** + * Snapshot credential health for GET /api/monitoring/health. + * + * Never probes upstream and never expires entries on read. Expired / old + * rows stay in the counts so a scrape can return immediately while the + * background scheduler refreshes them (#12532). + */ +export function getCachedCredentialHealthSummary(): CredentialHealthSummary { + const state = getCacheState(); + const now = Date.now(); + let total = 0; + let healthy = 0; + let failed = 0; + let unknown = 0; + let stale = 0; + + for (const entry of state.cache.values()) { + total += 1; + if (entry.status.status === "active") healthy += 1; + else if (entry.status.status === "error") failed += 1; + else unknown += 1; + if (now - entry.status.lastTested.getTime() > STALE_THRESHOLD_MS || now > entry.expiresAt) { + stale += 1; + } + } + + return { total, healthy, failed, unknown, stale }; +} + +/** + * Get cache summary stats for health API. + * Monitoring scrapes must use the stale-safe snapshot (no live probes). + */ +export function getCredentialHealthSummary(): CredentialHealthSummary { + return getCachedCredentialHealthSummary(); +} + +/** Test-only: drop every cached credential-health row. */ +export function __test_resetCredentialHealthCache(): void { + globalThis.__omnirouteCredentialCache = { + initialized: false, + cache: new Map(), }; } +/** Test-only: insert a cache row, including expired / stale timestamps. */ +export function __test_putCredentialHealth(entry: { + connectionId: string; + provider: string; + status: "active" | "error" | "unknown"; + lastTested: Date; + expiresAt?: number; +}): void { + const state = getCacheState(); + state.cache.set(entry.connectionId, { + status: { + connectionId: entry.connectionId, + provider: entry.provider, + status: entry.status, + lastTested: entry.lastTested, + consecutiveFailures: 0, + }, + expiresAt: entry.expiresAt ?? Date.now() + DEFAULT_TTL_MS, + }); +} + /** * Mark cache as initialized (called by scheduler on startup). */ diff --git a/src/lib/credentialHealth/scheduler.ts b/src/lib/credentialHealth/scheduler.ts index d5922e87d2..dbbeb1d842 100644 --- a/src/lib/credentialHealth/scheduler.ts +++ b/src/lib/credentialHealth/scheduler.ts @@ -17,13 +17,12 @@ * - Resets to default on success */ +import { setImmediate as yieldToEventLoop } from "node:timers/promises"; + import { testSingleConnection } from "@/app/api/providers/[id]/test/route"; import { getProviderConnections } from "@/lib/db/providers"; import { getCachedSettings } from "@/lib/db/readCache"; -import { - setCredentialHealth, - initCredentialCache, -} from "@/lib/credentialHealth/cache"; +import { setCredentialHealth, initCredentialCache } from "@/lib/credentialHealth/cache"; import { isCredentialProbeInconclusive, resolveInconclusiveProbeRecheckDelayMs, @@ -386,6 +385,9 @@ export async function sweep(): Promise { } for (const batch of batches) { + // Yield so GET /healthz and cached /api/monitoring/health can drain + // while this background sweep talks to providers (#12532). + await yieldToEventLoop(); await Promise.allSettled( batch.map((conn) => testConnection(conn.id, conn.provider, getConnIntervalMs(conn, globalIntervalMs)) diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index eeb1f70a0e..9ffa78159d 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -78,6 +78,13 @@ interface CacheEntry { value: TValue; } +interface CreateApiKeyOptions { + modelAccessMode?: ModelAccessMode; + allowedModels?: string[]; + allowedCombos?: string[]; + allowedConnections?: string[]; +} + export type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; interface ApiKeyMetadata { @@ -233,9 +240,7 @@ function assertExclusiveLeaseKeyPolicy( allowedConnections: readonly string[] ): void { if (scopes.includes(EXCLUSIVE_LEASE_SCOPE) && allowedConnections.length === 0) { - throw new ApiKeyPolicyInvariantError( - "lease:exclusive requires explicit allowedConnections" - ); + throw new ApiKeyPolicyInvariantError("lease:exclusive requires explicit allowedConnections"); } } @@ -346,7 +351,7 @@ async function getModelPermissionCandidates(modelId: string): Promise providerOrAlias, providerScopedModel, resolveProviderId, - getProviderAlias, + getProviderAlias ); } return Array.from(candidates); @@ -364,7 +369,7 @@ async function getModelPermissionCandidates(modelId: string): Promise } async function getPublishedModelLookupTarget( - modelId: string, + modelId: string ): Promise<{ providerId: string; modelId: string } | null> { const cleanModelId = stripExtendedContextSuffix(modelId.trim()); if (!cleanModelId) return null; @@ -393,7 +398,7 @@ async function getPublishedModelLookupTarget( function ensureApiKeyColumn( db: ApiKeysDbLike, columnNames: Set, - column: (typeof API_KEY_COLUMN_FALLBACKS)[number], + column: (typeof API_KEY_COLUMN_FALLBACKS)[number] ): void { if (columnNames.has(column.name)) return; db.exec(`ALTER TABLE api_keys ADD COLUMN ${column.definition}`); @@ -433,13 +438,13 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { _stmtGetAllKeys = db.prepare("SELECT * FROM api_keys ORDER BY created_at"); _stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?"); _stmtValidateKey = db.prepare( - "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?", + "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, model_access_mode, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?", + "SELECT id, name, machine_id, model_access_mode, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtInsertKey = db.prepare( - "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO api_keys (id, name, key, machine_id, model_access_mode, allowed_models, allowed_combos, allowed_connections, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); _stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?"); } @@ -497,7 +502,7 @@ export async function getApiKeys(limit?: number, offset?: number) { camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); camelRow.cacheDefaultMode = parseCacheDefaultMode((camelRow as JsonRecord).cacheDefaultMode); camelRow.disableNonPublicModels = parseDisableNonPublicModels( - (camelRow as JsonRecord).disableNonPublicModels, + (camelRow as JsonRecord).disableNonPublicModels ); camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand); camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled); @@ -556,7 +561,7 @@ export async function getExclusiveLeaseConnectionIds(): Promise> { * inactive, banned, or hard-lease key, and it never widens a key's allowedModels. */ export async function pickApiKeyForInternalUse( - purpose: "combo-health-check" | "cloud-sync-verify" | "internal-probe" = "internal-probe", + purpose: "combo-health-check" | "cloud-sync-verify" | "internal-probe" = "internal-probe" ): Promise { try { const keys = (await getApiKeys()) as Array<{ @@ -579,7 +584,7 @@ export async function pickApiKeyForInternalUse( // 1. Management-scoped key (preferred for any internal probe). const manageKey = keys.find( - (k) => isUsable(k) && Array.isArray(k.scopes) && k.scopes.includes("manage"), + (k) => isUsable(k) && Array.isArray(k.scopes) && k.scopes.includes("manage") ); if (manageKey?.key) return manageKey.key; @@ -634,7 +639,7 @@ export async function getApiKeyById(id: string) { camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); camelRow.cacheDefaultMode = parseCacheDefaultMode((camelRow as JsonRecord).cacheDefaultMode); camelRow.disableNonPublicModels = parseDisableNonPublicModels( - (camelRow as JsonRecord).disableNonPublicModels, + (camelRow as JsonRecord).disableNonPublicModels ); camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand); camelRow.chaosModeEnabled = parseChaosModeEnabled((camelRow as JsonRecord).chaosModeEnabled); @@ -662,12 +667,19 @@ export async function createApiKey( name: string, machineId: string, scopes: string[] = [], - options: { allowedConnections?: string[] } = {} + options: CreateApiKeyOptions = {} ) { if (!machineId) { throw new Error("machineId is required"); } const allowedConnections = options.allowedConnections ?? []; + const modelAccess = normalizeApiKeyPermissionsUpdate({ + modelAccessMode: options.modelAccessMode, + allowedModels: options.allowedModels, + }); + const modelAccessMode = modelAccess.modelAccessMode ?? "all"; + const allowedModels = modelAccess.allowedModels ?? []; + const allowedCombos = options.allowedCombos ?? [ALL_COMBOS_ACCESS_RULE]; assertExclusiveLeaseKeyPolicy(scopes, allowedConnections); const db = getDbInstance() as ApiKeysDbLike; @@ -681,9 +693,9 @@ export async function createApiKey( name: name, key: result.key, machineId: machineId, - modelAccessMode: "all" as const, - allowedModels: [], // Empty array means all models allowed - allowedCombos: [ALL_COMBOS_ACCESS_RULE], // Explicit wildcard means all combos allowed + modelAccessMode, + allowedModels, + allowedCombos, allowedConnections, noLog: false, allowUsageCommand: false, @@ -697,14 +709,15 @@ export async function createApiKey( apiKey.name, apiKey.key, apiKey.machineId, - "[]", + apiKey.modelAccessMode, + JSON.stringify(apiKey.allowedModels), JSON.stringify(apiKey.allowedCombos), JSON.stringify(allowedConnections), 0, apiKey.createdAt, apiKey.key.slice(0, 12), await hashKey(apiKey.key), - JSON.stringify(scopes), + JSON.stringify(scopes) ); setNoLog(apiKey.id, false); @@ -726,7 +739,7 @@ export async function regenerateApiKey(id: string) { // Update in DB const updateStmt = db.prepare( - "UPDATE api_keys SET key = ?, key_hash = ?, key_prefix = ? WHERE id = ?", + "UPDATE api_keys SET key = ?, key_hash = ?, key_prefix = ? WHERE id = ?" ); updateStmt.run(newKey, newHash, newPrefix, id); @@ -747,7 +760,7 @@ export async function regenerateApiKey(id: string) { export async function updateApiKeyPermissions( id: string, - update: string[] | ApiKeyPermissionsUpdate, + update: string[] | ApiKeyPermissionsUpdate ) { const db = getDbInstance() as ApiKeysDbLike; getPreparedStatements(db); @@ -1050,7 +1063,9 @@ export async function updateApiKeyPermissions( return false; } assertExclusiveLeaseKeyPolicy(parseStringList(row.scopes), normalized.allowedConnections); - const upd = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params); + const upd = db + .prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`) + .run(params); changedRows = upd.changes ?? 0; db.exec("COMMIT"); } catch (err) { @@ -1162,7 +1177,7 @@ export async function revokeApiKey(id: string): Promise { const result = db .prepare( - "UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id", + "UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id" ) .run({ id, ts: new Date().toISOString() }); @@ -1291,7 +1306,7 @@ export async function validateApiKey(key: string | null | undefined) { revokedAt: row.revoked_at, }), "EX", - 3600, // 1 hour cache + 3600 // 1 hour cache ); } } catch { @@ -1308,7 +1323,7 @@ export async function validateApiKey(key: string | null | undefined) { * Get API key metadata with caching for performance */ export async function getApiKeyMetadata( - key: string | null | undefined, + key: string | null | undefined ): Promise { if (!key || typeof key !== "string") return null; @@ -1415,10 +1430,10 @@ export async function getApiKeyMetadata( blockedModels: parseAllowedModels(record.blocked_models ?? record.blockedModels), allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos), allowedConnections: parseAllowedConnections( - record.allowed_connections ?? record.allowedConnections, + record.allowed_connections ?? record.allowedConnections ), allowedQuotas: parseAllowedQuotas( - (record as JsonRecord).allowed_quotas ?? (record as JsonRecord).allowedQuotas, + (record as JsonRecord).allowed_quotas ?? (record as JsonRecord).allowedQuotas ), noLog: parseNoLog(record.no_log ?? record.noLog), autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve), @@ -1440,26 +1455,26 @@ export async function getApiKeyMetadata( proxyId: typeof record.proxy_id === "string" && record.proxy_id.trim() !== "" ? record.proxy_id : null, allowedEndpoints: parseStringList( - (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints, + (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints ), streamDefaultMode: parseStreamDefaultMode( - (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode, + (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode ), cacheDefaultMode: parseCacheDefaultMode( (record as JsonRecord).cache_default_mode ?? (record as JsonRecord).cacheDefaultMode ), disableNonPublicModels: parseDisableNonPublicModels( (record as JsonRecord).disable_non_public_models ?? - (record as JsonRecord).disableNonPublicModels, + (record as JsonRecord).disableNonPublicModels ), allowUsageCommand: parseAllowUsageCommand( - (record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand, + (record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand ), chaosModeEnabled: parseChaosModeEnabled( - (record as JsonRecord).chaos_mode_enabled ?? (record as JsonRecord).chaosModeEnabled, + (record as JsonRecord).chaos_mode_enabled ?? (record as JsonRecord).chaosModeEnabled ), compressionEnabled: parseCompressionEnabled( - (record as JsonRecord).compression_enabled ?? (record as JsonRecord).compressionEnabled, + (record as JsonRecord).compression_enabled ?? (record as JsonRecord).compressionEnabled ), ...parseApiKeyUsageLimitFields(record as JsonRecord), }; @@ -1485,7 +1500,7 @@ export async function getApiKeyMetadata( */ export async function isModelAllowedForKey( key: string | null | undefined, - modelId: string | null | undefined, + modelId: string | null | undefined ) { // If no key provided, allow (request may be using different auth method like JWT) // If no modelId provided, deny (invalid request) diff --git a/src/lib/db/models/activeSyncedCatalog.ts b/src/lib/db/models/activeSyncedCatalog.ts index 18ade8260b..d20e361695 100644 --- a/src/lib/db/models/activeSyncedCatalog.ts +++ b/src/lib/db/models/activeSyncedCatalog.ts @@ -13,6 +13,24 @@ export type ActiveSyncedCatalog = { models: SyncedAvailableModel[]; }; +/** + * Fail-open membership check for explicit combo members against a live catalog. + * `null` means no authoritative catalog is synced yet (unchanged behavior). + */ +export function catalogContainsModel( + catalog: ActiveSyncedCatalog, + modelId: string +): boolean | null { + if (!catalog.authoritative) return null; + const trimmed = modelId.trim(); + if (!trimmed) return false; + const ids = new Set(catalog.models.map((model) => model.id)); + if (ids.has(trimmed)) return true; + const slash = trimmed.indexOf("/"); + if (slash > 0 && ids.has(trimmed.slice(slash + 1))) return true; + return false; +} + export type ProviderCatalogReconciliation = { providers: string[]; excludedProviders: string[]; diff --git a/src/lib/db/repositories/sqliteComboRepository.ts b/src/lib/db/repositories/sqliteComboRepository.ts index 4263f51034..1a623ea136 100644 --- a/src/lib/db/repositories/sqliteComboRepository.ts +++ b/src/lib/db/repositories/sqliteComboRepository.ts @@ -11,6 +11,7 @@ import type { import { normalizeComboRecord } from "@/lib/combos/steps"; import { validateComboInvariant } from "@/lib/combos/invariants"; import { getDbInstance } from "../core"; +import { deleteLKGPRowsByComboName } from "../settings/lkgp"; type JsonRecord = Record; @@ -349,8 +350,27 @@ export async function reorderCombos(comboIds: string[]): Promise { + const combo = db.prepare("SELECT name FROM combos WHERE id = ?").get(id) as + { name?: string } | undefined; + const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id); + if (result.changes === 0) return { deleted: false, lkgpKeys: [] as string[] }; + return { + deleted: true, + lkgpKeys: combo?.name ? deleteLKGPRowsByComboName(combo.name) : ([] as string[]), + }; + }); + + const { deleted, lkgpKeys } = deleteTransaction(); + if (!deleted) return false; + + if (lkgpKeys.length > 0) { + const { invalidateCachedLKGP } = await import("../readCache"); + for (const key of lkgpKeys) { + invalidateCachedLKGP(key); + } + } + return true; } diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts index 9c55d8edff..dce175dc92 100644 --- a/src/lib/db/responsesContinuationStore.ts +++ b/src/lib/db/responsesContinuationStore.ts @@ -81,7 +81,8 @@ export function resolvePreviousResponseState( const { artifact, state } = readCallArtifact(row.artifact_relpath); if (state !== "ready" || !artifact?.pipeline) return null; - const clientRawRequest = artifact.pipeline.clientRawRequest as { body?: unknown } | undefined; + const clientRawRequest = artifact.pipeline.clientRawRequest as + { body?: unknown; effectiveInput?: unknown } | undefined; const clientResponse = artifact.pipeline.clientResponse as { output?: unknown; summary?: { output?: unknown } } | undefined; @@ -94,7 +95,22 @@ export function resolvePreviousResponseState( // unconditionally unresolvable for every translate-mode/auto-routed // connection (previous_response_not_found on every attempt, regardless of // whether the id was real and the artifact was otherwise 'ready'). - const input = isPlainRecord(clientRawRequest?.body) ? clientRawRequest.body.input : undefined; + // + // effectiveInput first, body.input as a compat fallback for artifacts + // logged before this field existed: `body` is captureDeferredClientRawBody's + // deliberately pre-reconstruction snapshot of the raw client bytes. For a + // turn that was ITSELF a continuation, that's just the client's own trimmed + // delta, not the full input that actually dispatched -- chaining off it + // compounds into a progressively truncated reconstruction a few hops deep + // (live incident 2026-09-03: a malformed request with no leading + // system/user message, rejected by the upstream provider). effectiveInput + // is captured AFTER reconstruction runs (chat.ts) and is what this function + // must chain off so a multi-hop continuation stays accurate. + const input = Array.isArray(clientRawRequest?.effectiveInput) + ? clientRawRequest.effectiveInput + : isPlainRecord(clientRawRequest?.body) + ? clientRawRequest.body.input + : undefined; // A streaming clientResponse is clientPayloadCollector.build()'s output, which // always nests the caller's summary under `.summary` (see // createStructuredSSECollector in streamPayloadCollector.ts) -- a non-streaming diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 769c6d08ce..8b4aa803af 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -840,6 +840,8 @@ export { setLKGP, clearAllLKGP, clearLKGP, + deleteLKGPByComboName, + deleteLKGPRowsByComboName, deleteLKGPByConnectionIds, } from "./settings/lkgp"; diff --git a/src/lib/db/settings/lkgp.ts b/src/lib/db/settings/lkgp.ts index a57208bfeb..0100c5bd18 100644 --- a/src/lib/db/settings/lkgp.ts +++ b/src/lib/db/settings/lkgp.ts @@ -4,6 +4,34 @@ import { getDbInstance } from "../core"; +/** + * Escape SQLite `LIKE` wildcards so a combo name containing `%` or `_` cannot + * widen the prefix match into unrelated combos' pins. + */ +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +export function deleteLKGPRowsByComboName(comboName: string): string[] { + if (!comboName) return []; + + const db = getDbInstance(); + const prefix = `${comboName}:`; + const rows = db + .prepare("SELECT key FROM key_value WHERE namespace = 'lkgp' AND key LIKE ? ESCAPE '\\'") + .all(`${escapeLikePattern(prefix)}%`) as Array<{ key?: string }>; + + const staleKeys = rows.map((row) => row?.key).filter((key): key is string => Boolean(key)); + if (staleKeys.length === 0) return []; + + const deleteStatement = db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp' AND key = ?"); + for (const key of staleKeys) { + deleteStatement.run(key); + } + + return staleKeys; +} + export interface LKGPRecord { provider: string; connectionId?: string; @@ -67,6 +95,27 @@ export async function clearLKGP(comboName: string, modelId: string): Promise { + const staleKeys = deleteLKGPRowsByComboName(comboName); + + if (staleKeys.length === 0) return 0; + + const { invalidateCachedLKGP } = await import("../readCache"); + for (const key of staleKeys) { + invalidateCachedLKGP(key); + } + + return staleKeys.length; +} + /** * Delete persisted LKGP pins whose connectionId references a removed provider * connection (#8887). A pin persisted by `setLKGP()` carries the connection it diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index 7d6ee77727..7bf961f2af 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -320,9 +320,12 @@ export async function createEmbeddingResponse( ); } if ("allExpired" in credentials && credentials.allExpired) { + const expiredStatus = (credentials as { expiredStatus?: string }).expiredStatus; + const quota = expiredStatus === "credits_exhausted"; + const reason = quota ? "credits exhausted" : "authentication expired"; return errorResponse( - HTTP_STATUS.UNAUTHORIZED, - `[${provider}] All ${credentials.expiredCount || 1} connection(s) authentication expired — please reconnect in the dashboard` + quota ? HTTP_STATUS.PAYMENT_REQUIRED : HTTP_STATUS.UNAUTHORIZED, + `[${provider}] All ${credentials.expiredCount || 1} connection(s) ${reason} — please reconnect in the dashboard` ); } } else if (provider === "ollama-local" || provider === "lmstudio") { diff --git a/src/lib/guardrails/credentialMasker.ts b/src/lib/guardrails/credentialMasker.ts index d5529f84f6..6ac88f8fb3 100644 --- a/src/lib/guardrails/credentialMasker.ts +++ b/src/lib/guardrails/credentialMasker.ts @@ -1,5 +1,9 @@ -import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base"; +import { CREDENTIAL_PATTERNS } from "@omniroute/open-sse/utils/credentialPatterns.ts"; import { getSettings } from "@/lib/db/settings"; +import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base"; + +export { CREDENTIAL_PATTERNS }; +export type { CredentialPattern } from "@omniroute/open-sse/utils/credentialPatterns.ts"; /** * CredentialMaskerGuardrail — redacts well-known API-key / secret-token patterns @@ -11,88 +15,6 @@ import { getSettings } from "@/lib/db/settings"; * Future: per-pipeline / per-provider scoping via GuardrailContext. */ -export interface CredentialPattern { - name: string; - regex: RegExp; - replacement: string; -} - -export const CREDENTIAL_PATTERNS: CredentialPattern[] = [ - // ── LLM provider keys ────────────────────────────────────────────────── - { name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" }, - { name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" }, - { - name: "anthropic", - regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g, - replacement: "[REDACTED:anthropic]", - }, - { - name: "anthropic_alt", - regex: /sk-ant-[A-Za-z0-9_-]{20,}/g, - replacement: "[REDACTED:anthropic]", - }, - { name: "google", regex: /AIza[0-9A-Za-z_-]{35}/g, replacement: "[REDACTED:google]" }, - { name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" }, - { name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" }, - // ── VCS / SaaS tokens ────────────────────────────────────────────────── - { name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" }, - { name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" }, - { name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" }, - { name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" }, - { name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" }, - { name: "postman", regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g, replacement: "[REDACTED:postman]" }, - { - name: "discord", - regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g, - replacement: "[REDACTED:discord]", - }, - // ── Payments ─────────────────────────────────────────────────────────── - { - name: "stripe", - regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g, - replacement: "[REDACTED:stripe]", - }, - { - name: "square", - regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g, - replacement: "[REDACTED:square]", - }, - // ── Cloud / infra ────────────────────────────────────────────────────── - { name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" }, - { name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" }, - { - name: "sendgrid", - regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, - replacement: "[REDACTED:sendgrid]", - }, - { name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" }, - // ── Crypto / identity ────────────────────────────────────────────────── - { - name: "private_key", - regex: - /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g, - replacement: "[REDACTED:private_key]", - }, - { - name: "jwt", - regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, - replacement: "[REDACTED:jwt]", - }, - // ── Connection strings (creds embedded in URI) ───────────────────────── - { - name: "connection_string", - regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g, - replacement: "[REDACTED:connection_string]", - }, - // ── Header-style secrets ─────────────────────────────────────────────── - { - name: "auth_header", - regex: - /((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi, - replacement: "$1[REDACTED:auth_header]", - }, -]; - export interface CredentialRedactionResult { text: string; detections: Array<{ type: string; count: number }>; diff --git a/src/lib/guardrails/videoBridgeSnapshotRedaction.ts b/src/lib/guardrails/videoBridgeSnapshotRedaction.ts index 8324b4be90..2815a770c8 100644 --- a/src/lib/guardrails/videoBridgeSnapshotRedaction.ts +++ b/src/lib/guardrails/videoBridgeSnapshotRedaction.ts @@ -105,10 +105,16 @@ interface ClientRawRequestLike { endpoint: unknown; body: unknown; headers?: unknown; + effectiveInput?: unknown; } interface RequestLoggerLike { - logClientRawRequest: (endpoint: unknown, body: unknown, headers?: unknown) => void; + logClientRawRequest: ( + endpoint: unknown, + body: unknown, + headers?: unknown, + effectiveInput?: unknown + ) => void; } /** @@ -130,7 +136,8 @@ export function logClientRawRequestRedacted( videoBridgeObserved ? redactVideoTranscriptFieldsForLog(clientRawRequest.body) : clientRawRequest.body, - clientRawRequest.headers + clientRawRequest.headers, + clientRawRequest.effectiveInput ); } diff --git a/src/lib/logPayloads.ts b/src/lib/logPayloads.ts index 5aa2f9eb9c..5fdef8c675 100644 --- a/src/lib/logPayloads.ts +++ b/src/lib/logPayloads.ts @@ -1,3 +1,8 @@ +import { + sanitizeErrorMessage, + sanitizeUpstreamDetails, +} from "@omniroute/open-sse/utils/errorSanitization.ts"; +import { projectResponsesFailureOutput } from "@omniroute/open-sse/utils/responsesFailureOutput.ts"; import { sanitizePII } from "./piiSanitizer"; const SENSITIVE_KEYS = new Set([ @@ -35,6 +40,21 @@ const SENSITIVE_KEYS = new Set([ "runtimeKey", ]); +const SENSITIVE_CHALLENGE_KEYS = new Set([ + "recaptchav3token", + "recaptchatoken", + "turnstiletoken", + "prooftoken", + "resumetoken", + "preparetoken", +]); + +function isSensitivePayloadKey(key: string): boolean { + if (SENSITIVE_KEYS.has(key)) return true; + const normalizedKey = key.replace(/[-_]/g, "").toLowerCase(); + return SENSITIVE_CHALLENGE_KEYS.has(normalizedKey); +} + type JsonRecord = Record; const ENCRYPTED_REASONING_KEY = "encrypted_content"; @@ -60,6 +80,283 @@ export function omitEncryptedReasoningFromLogChunks(chunks: string[]): string[] return found ? [omitted] : chunks; } +const ERROR_SUBTREE_KEYS = new Set([ + "error", + "errors", + "warning", + "warnings", + "errormessage", + "warningmessage", + "errordescription", + "warningdescription", + "lasterror", +]); + +function isErrorSubtreeKey(key: string): boolean { + return ERROR_SUBTREE_KEYS.has(key.replace(/[-_]/g, "").toLowerCase()); +} + +function sanitizeErrorSubtreeValue(value: unknown): unknown { + if (typeof value === "string") return sanitizeErrorMessage(value); + try { + if (value instanceof Error) { + return { + name: sanitizeErrorMessage(value.name) || "Error", + message: sanitizeErrorMessage(value.message), + }; + } + return sanitizeUpstreamDetails(value); + } catch { + return "[REDACTED]"; + } +} + +type ErrorSubtreeProjection = { value: unknown; found: boolean }; + +function projectErrorSubtreesForLog( + value: unknown, + seen = new WeakSet(), + forceResponsesFailure = false, + protocolResponseObject = false +): ErrorSubtreeProjection { + if (forceResponsesFailure && typeof value === "string") { + return { value: sanitizeErrorMessage(value) || "[REDACTED]", found: true }; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("{") || trimmed.startsWith("[")) && + STREAM_ERROR_ENVELOPE_RE.test(trimmed) + ) { + try { + const parsed: unknown = JSON.parse(trimmed); + const projected = isDiscriminatedStreamError(parsed) + ? { value: sanitizeErrorSubtreeValue(parsed), found: true } + : projectErrorSubtreesForLog(parsed, seen); + if (projected.found) { + const serialized = JSON.stringify(projected.value); + if (typeof serialized === "string") return { value: serialized, found: true }; + } + } catch { + return { value: sanitizeErrorMessage(value) || "[REDACTED]", found: true }; + } + } + return { value, found: false }; + } + if (value === null || value === undefined || typeof value !== "object") { + return { value, found: false }; + } + if (isOpaqueBinary(value)) return { value, found: false }; + if (isDiscriminatedStreamError(value)) { + return { value: sanitizeErrorSubtreeValue(value), found: true }; + } + const declaresResponsesFailure = isResponsesFailureEvent(value); + const responsesFailure = forceResponsesFailure || declaresResponsesFailure; + if (seen.has(value)) return { value: "[circular]", found: false }; + seen.add(value); + + if (Array.isArray(value)) { + try { + let found = false; + const projected = value.map((entry) => { + const result = projectErrorSubtreesForLog(entry, seen, responsesFailure, false); + found ||= result.found; + return result.value; + }); + return { value: projected, found }; + } finally { + seen.delete(value); + } + } + + try { + let found = responsesFailure; + const projected: JsonRecord = {}; + for (const [key, entryValue] of Object.entries(value)) { + if (isErrorSubtreeKey(key) || (responsesFailure && isResponseFailureMessageKey(key))) { + projected[key] = sanitizeErrorSubtreeValue(entryValue); + found = true; + continue; + } + // Responses failures may attach diagnostics under neutral key names. Keep + // projecting through that envelope, while preserving partial model output + // as content rather than treating it as an error message. + const normalizedKey = key.replace(/[-_]/g, "").toLowerCase(); + const preservePartialOutput = + responsesFailure && + normalizedKey === "output" && + (protocolResponseObject || declaresResponsesFailure); + if (preservePartialOutput) { + projected[key] = projectResponsesFailureOutput( + entryValue, + (_field, stringValue) => sanitizeErrorMessage(stringValue) || "[REDACTED]" + ); + found = true; + continue; + } + const childIsProtocolResponse = + normalizedKey === "response" && + (declaresResponsesFailure || (forceResponsesFailure && !protocolResponseObject)); + const result = projectErrorSubtreesForLog( + entryValue, + seen, + responsesFailure, + childIsProtocolResponse + ); + projected[key] = result.value; + found ||= result.found; + } + return { value: projected, found }; + } catch { + return { value: "[REDACTED]", found: false }; + } finally { + seen.delete(value); + } +} + +const STREAM_ERROR_DISCRIMINATOR_KEYS = ["type", "event", "kind", "status"] as const; +const STREAM_ERROR_DISCRIMINATORS = new Set(["error", "warning"]); +const RESPONSES_FAILURE_DISCRIMINATORS = new Set(["response.failed"]); +const RESPONSE_FAILURE_MESSAGE_KEYS = new Set(["message", "detail", "details", "description"]); +const STREAM_ERROR_ENVELOPE_RE = + /["'](?:error|errors|warning|warnings|last_error|lastError|errorMessage|warningMessage)["']\s*:|["'](?:type|event|kind)["']\s*:\s*["'](?:error|warning|response\.(?:failed|completed))["']|["']status["']\s*:\s*["']failed["']/i; + +function isResponseFailureMessageKey(key: string): boolean { + return RESPONSE_FAILURE_MESSAGE_KEYS.has(key.replace(/[-_]/g, "").toLowerCase()); +} + +function isResponsesFailureEvent(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + try { + const record = value as JsonRecord; + const directFailure = STREAM_ERROR_DISCRIMINATOR_KEYS.some((key) => { + const discriminator = record[key]; + return ( + typeof discriminator === "string" && + RESPONSES_FAILURE_DISCRIMINATORS.has(discriminator.trim().toLowerCase()) + ); + }); + if (directFailure) return true; + + const status = record.status; + if (typeof status === "string" && status.trim().toLowerCase() === "failed") return true; + + const nestedResponse = record.response; + if (!nestedResponse || typeof nestedResponse !== "object" || Array.isArray(nestedResponse)) { + return false; + } + const nestedStatus = (nestedResponse as JsonRecord).status; + return typeof nestedStatus === "string" && nestedStatus.trim().toLowerCase() === "failed"; + } catch { + return true; + } +} + +function isDiscriminatedStreamError(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + try { + const record = value as JsonRecord; + return STREAM_ERROR_DISCRIMINATOR_KEYS.some((key) => { + const discriminator = record[key]; + return ( + typeof discriminator === "string" && + STREAM_ERROR_DISCRIMINATORS.has(discriminator.trim().toLowerCase()) + ); + }); + } catch { + return true; + } +} + +function sanitizeStreamErrorPayload( + rawPayload: string, + forceError: boolean, + forceResponsesFailure = false +): { found: boolean; value: string } { + try { + const parsed: unknown = JSON.parse(rawPayload); + if (forceError || isDiscriminatedStreamError(parsed)) { + const projected = sanitizeErrorSubtreeValue(parsed); + const serialized = JSON.stringify(projected); + return { + found: true, + value: typeof serialized === "string" ? serialized : "[REDACTED]", + }; + } + + const projected = projectErrorSubtreesForLog( + parsed, + new WeakSet(), + forceResponsesFailure + ); + if (!projected.found) return { found: false, value: rawPayload }; + return { found: true, value: JSON.stringify(projected.value) }; + } catch { + if (!forceError && !forceResponsesFailure && !STREAM_ERROR_ENVELOPE_RE.test(rawPayload)) { + return { found: false, value: rawPayload }; + } + return { + found: true, + value: sanitizeErrorMessage(rawPayload) || "[REDACTED]", + }; + } +} + +/** + * Sanitize error/warning records captured as fragmented SSE or NDJSON text. + * Prefixes are matched at the start of a line so unrelated `metadata:` fields + * cannot be mistaken for SSE `data:` frames. + */ +export function sanitizeErrorFramesFromLogChunks(chunks: string[]): string[] { + const combined = chunks.map((chunk) => chunk.replace(STREAM_CHUNK_TIMESTAMP_RE, "")).join(""); + let found = false; + let errorEventActive = false; + let responsesFailureEventActive = false; + const projectedLines = combined.split("\n").map((line) => { + if (line.trim().length === 0) { + errorEventActive = false; + responsesFailureEventActive = false; + return line; + } + + const eventMatch = line.match(/^\s*event:\s*([^\s]+)\s*$/i); + if (eventMatch) { + const eventName = eventMatch[1].toLowerCase(); + errorEventActive = STREAM_ERROR_DISCRIMINATORS.has(eventName); + responsesFailureEventActive = RESPONSES_FAILURE_DISCRIMINATORS.has(eventName); + return line; + } + + const dataMatch = line.match(/^(\s*data:)([ \t]?)(.*)$/); + if (dataMatch) { + const rawPayload = dataMatch[3].trim(); + if (!rawPayload || rawPayload === "[DONE]") return line; + const projected = sanitizeStreamErrorPayload( + rawPayload, + errorEventActive, + responsesFailureEventActive + ); + if (!projected.found) return line; + found = true; + return `${dataMatch[1]}${dataMatch[2]}${projected.value}`; + } + + if (errorEventActive || responsesFailureEventActive) { + found = true; + return sanitizeErrorMessage(line) || "[REDACTED]"; + } + + const trimmed = line.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return line; + const projected = sanitizeStreamErrorPayload(trimmed, false); + if (!projected.found) return line; + found = true; + return `${line.slice(0, line.length - line.trimStart().length)}${projected.value}`; + }); + + return found ? [projectedLines.join("\n")] : chunks; +} + /** * True for any binary/opaque byte view (Uint8Array, Buffer, DataView, other * typed arrays). `Array.isArray()` returns false for these, so callers that @@ -125,7 +422,7 @@ export function redactPayload(payload: unknown): unknown { const redacted: JsonRecord = {}; for (const [key, value] of Object.entries(payload)) { - if (SENSITIVE_KEYS.has(key)) { + if (isSensitivePayloadKey(key)) { redacted[key] = "[REDACTED]"; } else if (typeof value === "string" && value.startsWith("Bearer ")) { redacted[key] = "Bearer [REDACTED]"; @@ -162,7 +459,19 @@ export function sanitizePayloadPII(payload: unknown): unknown { export function protectPayloadForLog(payload: unknown): unknown { if (payload === null || payload === undefined) return null; const normalized = normalizePayloadForLog(payload); - const reasoningOmitted = omitEncryptedReasoningForLog(normalized); + const errorProjected = projectErrorSubtreesForLog(normalized).value; + const reasoningOmitted = omitEncryptedReasoningForLog(errorProjected); + const piiSanitized = sanitizePayloadPII(reasoningOmitted); + return redactPayload(piiSanitized); +} + +/** Project every string leaf because the payload is known to represent a failed response. */ +export function protectErrorPayloadForLog(payload: unknown): unknown { + if (payload === null || payload === undefined) return null; + const normalized = normalizePayloadForLog(payload); + if (isOpaqueBinary(normalized)) return describeOpaqueBinary(normalized); + const errorProjected = sanitizeErrorSubtreeValue(normalized); + const reasoningOmitted = omitEncryptedReasoningForLog(errorProjected); const piiSanitized = sanitizePayloadPII(reasoningOmitted); return redactPayload(piiSanitized); } diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 0b8a65a75c..68c63a4e46 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -9,6 +9,9 @@ import { GITHUB_COPILOT_CHAT_USER_AGENT, GITHUB_COPILOT_EDITOR_VERSION, } from "@omniroute/open-sse/config/providerHeaderProfiles.ts"; +// userAgent / editorVersion on GITHUB_CONFIG are captured-pin snapshots for +// lockstep tests. Request construction must call getGitHubCopilotChatUserAgent() +// (#12417) — see providers/github.ts and providers/ghe-copilot.ts. import { GROK_BUILD_DEVICE_CODE_URL, GROK_BUILD_OAUTH_ISSUER, diff --git a/src/lib/oauth/providers/claude.ts b/src/lib/oauth/providers/claude.ts index 2c6b48c673..fbe3af836a 100644 --- a/src/lib/oauth/providers/claude.ts +++ b/src/lib/oauth/providers/claude.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import { CLAUDE_CONFIG } from "../constants/oauth"; -import { CLAUDE_CODE_VERSION } from "@omniroute/open-sse/executors/claudeIdentity.ts"; +import { getClaudeCodeVersion } from "@omniroute/open-sse/executors/claudeIdentity.ts"; const BOOTSTRAP_FETCH_TIMEOUT_MS = 10_000; @@ -14,7 +14,7 @@ async function fetchClaudeBootstrap(accessToken) { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", - "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, + "User-Agent": `claude-cli/${getClaudeCodeVersion()} (external, cli)`, "anthropic-beta": "oauth-2025-04-20", }, signal: ctrl.signal, diff --git a/src/lib/oauth/providers/ghe-copilot.ts b/src/lib/oauth/providers/ghe-copilot.ts index 38f0703cfa..35218e7c05 100644 --- a/src/lib/oauth/providers/ghe-copilot.ts +++ b/src/lib/oauth/providers/ghe-copilot.ts @@ -1,3 +1,4 @@ +import { getGitHubCopilotChatUserAgent } from "@omniroute/open-sse/config/providerHeaderProfiles.ts"; import { GHE_COPILOT_CONFIG } from "../constants/oauth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; @@ -72,7 +73,7 @@ export const gheCopilot = { Authorization: `Bearer ${tokens.access_token}`, Accept: "application/json", "X-GitHub-Api-Version": GHE_COPILOT_CONFIG.apiVersion, - "User-Agent": GHE_COPILOT_CONFIG.userAgent, + "User-Agent": getGitHubCopilotChatUserAgent(), }, }); const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; @@ -81,7 +82,7 @@ export const gheCopilot = { Authorization: `Bearer ${tokens.access_token}`, Accept: "application/json", "X-GitHub-Api-Version": GHE_COPILOT_CONFIG.apiVersion, - "User-Agent": GHE_COPILOT_CONFIG.userAgent, + "User-Agent": getGitHubCopilotChatUserAgent(), }, }); const userInfo = userRes.ok ? await userRes.json() : {}; diff --git a/src/lib/oauth/providers/github.ts b/src/lib/oauth/providers/github.ts index b3f237dea0..2a2b1739f2 100644 --- a/src/lib/oauth/providers/github.ts +++ b/src/lib/oauth/providers/github.ts @@ -1,3 +1,4 @@ +import { getGitHubCopilotChatUserAgent } from "@omniroute/open-sse/config/providerHeaderProfiles.ts"; import { GITHUB_CONFIG } from "../constants/oauth"; export const github = { @@ -56,7 +57,7 @@ export const github = { Authorization: `Bearer ${tokens.access_token}`, Accept: "application/json", "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, + "User-Agent": getGitHubCopilotChatUserAgent(), }, }); const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; @@ -66,7 +67,7 @@ export const github = { Authorization: `Bearer ${tokens.access_token}`, Accept: "application/json", "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, + "User-Agent": getGitHubCopilotChatUserAgent(), }, }); const userInfo = userRes.ok ? await userRes.json() : {}; diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index cbf6686aa9..c9472feb5c 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -1,6 +1,7 @@ // Outbound fetch wrappers for provider validation: proxy-fallback, SSRF-aware proxy targeting, and -// error→result mapping. Extracted from validation.ts (god-file decomposition). Behavior is -// byte-identical to the original inline defs. +// error→result mapping. Extracted from validation.ts (god-file decomposition) and kept as the +// common boundary for sanitizing validation failures. +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; import { SAFE_OUTBOUND_FETCH_PRESETS, SafeOutboundFetchError, @@ -11,6 +12,28 @@ import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts"; +export type ProjectedProviderValidationResult = { + [K in keyof T]: K extends "error" | "warning" ? string | null : T[K]; +} & { + error?: string | null; + warning?: string | null; +}; + +export function projectProviderValidationResultForPublicResponse< + T extends { error?: unknown; warning?: unknown }, +>(result: T): ProjectedProviderValidationResult; +export function projectProviderValidationResultForPublicResponse( + result: Record +): Record { + const projected: Record = { ...result }; + for (const field of ["error", "warning"] as const) { + if (!Object.prototype.hasOwnProperty.call(result, field)) continue; + const value = result[field]; + projected[field] = value === null || value === undefined ? null : sanitizeErrorMessage(value); + } + return projected; +} + /** * Wrapped fetch call that auto-retries with a proxy when the direct connection * fails. This happens transparently so individual validators don't need to @@ -156,17 +179,30 @@ export function toWebCookieValidationErrorResult(provider: string, error: unknow } export function toValidationErrorResult(error: unknown) { - const message = error instanceof Error ? error.message : String(error || "Validation failed"); - const statusCode = getSafeOutboundFetchErrorStatus(error); + let rawMessage: unknown = error || "Validation failed"; + try { + if (error instanceof Error) rawMessage = error.message; + } catch { + rawMessage = "Validation failed"; + } + const message = sanitizeErrorMessage(rawMessage); + let statusCode: number | null = null; + let timeout = false; + let securityBlocked = false; + try { + statusCode = getSafeOutboundFetchErrorStatus(error); + timeout = error instanceof SafeOutboundFetchError && error.code === "TIMEOUT"; + securityBlocked = isSecurityBlockError(error); + } catch { + // Classification is advisory; hostile accessors must not escape the safe error boundary. + } return { valid: false, error: message || "Validation failed", unsupported: false as const, ...(statusCode ? { statusCode } : {}), - ...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT" - ? { timeout: true } - : {}), - ...(isSecurityBlockError(error) ? { securityBlocked: true } : {}), + ...(timeout ? { timeout: true } : {}), + ...(securityBlocked ? { securityBlocked: true } : {}), }; } diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 8665e20c75..0bb278aa0f 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -7,6 +7,7 @@ * Pattern follows callLogs.js (T-15 decomposition). */ import { v4 as uuidv4 } from "uuid"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; import { getDbInstance, isCloud, isBuildPhase } from "./db/core"; import { ensureProxyLogsColumns } from "./db/schemaColumns"; @@ -99,7 +100,10 @@ function loadFromDb() { console.log(`[proxyLogger] Loaded ${proxyLogs.length} proxy logs from SQLite`); } } catch (err: any) { - console.warn("[proxyLogger] Failed to load from DB:", err.message); + console.warn( + "[proxyLogger] Failed to load from DB:", + sanitizeErrorMessage(err) || "Proxy log hydration failed" + ); } } @@ -113,10 +117,7 @@ loadFromDb(); /** Read at call time so tests can toggle it between imports. */ export function isProxyLogIncludeIps(): boolean { - return ( - process.env.PROXY_LOG_INCLUDE_IPS === "true" || - process.env.PROXY_LOG_INCLUDE_IPS === "1" - ); + return process.env.PROXY_LOG_INCLUDE_IPS === "true" || process.env.PROXY_LOG_INCLUDE_IPS === "1"; } /** @@ -152,6 +153,10 @@ export function formatProxyEgressConsoleLine(params: { // ──────────────── Log a proxy event ──────────────── export function logProxyEvent(entry: ProxyLogInput) { + const safeError = + entry.error === null || entry.error === undefined || entry.error === "" + ? null + : sanitizeErrorMessage(entry.error) || "Proxy request failed"; const log: ProxyLogEntry = { id: uuidv4(), timestamp: new Date().toISOString(), @@ -164,7 +169,7 @@ export function logProxyEvent(entry: ProxyLogInput) { clientIp: entry.clientIp ?? entry.publicIp ?? null, egressIp: entry.egressIp ?? null, latencyMs: entry.latencyMs || 0, - error: entry.error || null, + error: safeError, connectionId: entry.connectionId || null, comboId: entry.comboId || null, account: entry.account || null, @@ -236,15 +241,17 @@ export function flushProxyLogsSync() { // 1. If Redis driver is active, asynchronously publish batch to Redis Stream/Channel if (process.env.QUOTA_STORE_DRIVER === "redis" || process.env.QUOTA_STORE_REDIS_URL) { try { - import("@/lib/quota/redisQuotaStore").then(({ getRedisQuotaStore }) => { - const store = getRedisQuotaStore(process.env.QUOTA_STORE_REDIS_URL || ""); - const client = (store as any)?.client; - if (client && typeof client.publish === "function") { - for (const entry of batch) { - client.publish("omniroute:proxy_logs", JSON.stringify(entry)).catch(() => {}); + import("@/lib/quota/redisQuotaStore") + .then(({ getRedisQuotaStore }) => { + const store = getRedisQuotaStore(process.env.QUOTA_STORE_REDIS_URL || ""); + const client = (store as any)?.client; + if (client && typeof client.publish === "function") { + for (const entry of batch) { + client.publish("omniroute:proxy_logs", JSON.stringify(entry)).catch(() => {}); + } } - } - }).catch(() => {}); + }) + .catch(() => {}); } catch { /* ignore redis pub errors */ } @@ -289,7 +296,10 @@ export function flushProxyLogsSync() { transaction(batch); } catch (err: any) { - console.warn("[proxyLogger] Failed to write proxy log batch to disk:", err?.message || err); + console.warn( + "[proxyLogger] Failed to write proxy log batch to disk:", + sanitizeErrorMessage(err) || "Proxy log persistence failed" + ); } } @@ -351,7 +361,10 @@ export function clearProxyLogs() { const db = getDbInstance(); db.prepare("DELETE FROM proxy_logs").run(); } catch (err: any) { - console.warn("[proxyLogger] Failed to clear DB:", err.message); + console.warn( + "[proxyLogger] Failed to clear DB:", + sanitizeErrorMessage(err) || "Proxy log cleanup failed" + ); } } } diff --git a/src/lib/quota/connectionRecovery.ts b/src/lib/quota/connectionRecovery.ts index e7c09be1bb..26952a262d 100644 --- a/src/lib/quota/connectionRecovery.ts +++ b/src/lib/quota/connectionRecovery.ts @@ -63,6 +63,7 @@ export interface RecoverableConnectionInput { testStatus?: string | null; rateLimitedUntil?: string | null; lastErrorAt?: string | null; + lastErrorType?: string | null; } function normalizeStatus(value: string | null | undefined): string { @@ -158,6 +159,37 @@ export function isRecoverableCooldownConnection( * * Pure — `nowMs` and `reprobeMs` are injected so callers/tests control the clock. */ + +const EXPIRED_REPROBE_BLOCKLIST = new Set([ + "account_deactivated", + "invalid_grant", + "unrecoverable_refresh_error", + "provider_deprecated", + "no_refresh_token", +]); + +/** + * Re-probe `expired` after the same window as credits_exhausted. + * API-key 401s and OAuth races were persisted as expired and then never + * retried (combo pre-skip + health-check skip). Do not reopen a real + * deactivation / invalid_grant. + */ +export function isExpiredReprobeCandidate( + connection: RecoverableConnectionInput | null | undefined, + nowMs: number, + reprobeMs: number = DEFAULT_CREDITS_REPROBE_MS +): boolean { + if (!connection || typeof connection.id !== "string" || connection.id.length === 0) { + return false; + } + if (normalizeStatus(connection.testStatus) !== "expired") return false; + const err = (connection.lastErrorType || "").trim().toLowerCase(); + if (EXPIRED_REPROBE_BLOCKLIST.has(err)) return false; + const sinceMs = cooldownUntilMs(connection.lastErrorAt || connection.rateLimitedUntil || ""); + if (!Number.isFinite(sinceMs) || sinceMs <= 0) return true; + return nowMs - sinceMs >= reprobeMs; +} + export function isCreditsExhaustedReprobeCandidate( connection: RecoverableConnectionInput | null | undefined, nowMs: number, @@ -188,7 +220,8 @@ export function selectRecoverableConnections isRecoverableCooldownConnection(connection, nowMs) || - isCreditsExhaustedReprobeCandidate(connection, nowMs) + isCreditsExhaustedReprobeCandidate(connection, nowMs) || + isExpiredReprobeCandidate(connection, nowMs) ); } @@ -245,6 +278,7 @@ export async function runConnectionRecoveryTick( testStatus: typeof row.testStatus === "string" ? row.testStatus : null, rateLimitedUntil: typeof row.rateLimitedUntil === "string" ? row.rateLimitedUntil : null, lastErrorAt: typeof row.lastErrorAt === "string" ? row.lastErrorAt : null, + lastErrorType: typeof row.lastErrorType === "string" ? row.lastErrorType : null, })); }); connections = await load(); diff --git a/src/lib/radar/applyFeed.ts b/src/lib/radar/applyFeed.ts index 6bfcf3e846..a041cd9de3 100644 --- a/src/lib/radar/applyFeed.ts +++ b/src/lib/radar/applyFeed.ts @@ -42,6 +42,8 @@ export interface MergedEntry { poolKey: string | null; tos: "ok" | "caution" | "ambiguous" | "avoid" | "unknown"; trainsOnPrompts?: boolean; + /** Set when the quota only opens after a region-bound identity check; counted apart. */ + eligibilityGate?: "regional-identity"; /** Whether the entry is enabled for use. Defaults to true. */ enabled?: boolean; /** @@ -113,6 +115,8 @@ export interface FeedModel { metadataEvidenceUrls?: string[]; trainsOnPrompts: boolean | null; tosRisk: MergedEntry["tos"]; + /** Absent = the feed does not know; null = explicitly no gate. */ + eligibilityGate?: "regional-identity" | null; setup: { keyUrl: string | null; steps: RadarLocalizedText[]; @@ -299,6 +303,11 @@ function mergeOne( if (!overriddenKeys.has("trainsOnPrompts")) { result.trainsOnPrompts = feed.trainsOnPrompts ?? undefined; } + // Absent means "this feed predates the field": keep whatever the baseline says. + // An explicit null is the feed clearing the gate. + if (!overriddenKeys.has("eligibilityGate") && feed.eligibilityGate !== undefined) { + result.eligibilityGate = feed.eligibilityGate ?? undefined; + } if (!overriddenKeys.has("creditTokens")) { // Feed doesn't have creditTokens; keep baseline } @@ -329,6 +338,7 @@ function mergeOne( if (overrides.poolKey !== undefined) result.poolKey = overrides.poolKey; if (overrides.tos !== undefined) result.tos = overrides.tos; if (overrides.trainsOnPrompts !== undefined) result.trainsOnPrompts = overrides.trainsOnPrompts; + if (overrides.eligibilityGate !== undefined) result.eligibilityGate = overrides.eligibilityGate; if (overrides.enabled !== undefined) result.enabled = overrides.enabled; if (overrides.contextWindow !== undefined) result.contextWindow = overrides.contextWindow; if (overrides.capabilities !== undefined) result.capabilities = overrides.capabilities; @@ -368,6 +378,7 @@ function feedModelToMerged( creditTokens: overrides?.creditTokens ?? 0, freeType: overrides?.freeType ?? feed.freeType, poolKey: overrides?.poolKey ?? feedBudgetToPoolKey(feed.budget), + eligibilityGate: overrides?.eligibilityGate ?? feed.eligibilityGate ?? undefined, tos: overrides?.tos ?? feed.tosRisk, trainsOnPrompts: overrides?.trainsOnPrompts ?? feed.trainsOnPrompts ?? undefined, enabled: feed.enabled ? (overrides?.enabled ?? true) : false, diff --git a/src/lib/radar/feedSchema.ts b/src/lib/radar/feedSchema.ts index e8895f6cc7..7c2e42ccb9 100644 --- a/src/lib/radar/feedSchema.ts +++ b/src/lib/radar/feedSchema.ts @@ -182,6 +182,8 @@ const ModelV1Schema = z.object({ capabilities: CapabilitiesV1Schema, trainsOnPrompts: z.boolean().nullable(), tosRisk: TosRiskEnum, + /** Real quota, but behind a regional identity verification (counted apart on the client). */ + eligibilityGate: z.enum(["regional-identity"]).nullable().optional(), setup: SetupSchema, enabled: z.boolean(), }); diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index af0c80d3c9..d8f2d88cf5 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -86,6 +86,7 @@ export function baselineToMergedEntries(budgets: typeof FREE_MODEL_BUDGETS): Mer poolKey: b.poolKey ?? null, tos: b.tos, trainsOnPrompts: b.trainsOnPrompts, + eligibilityGate: b.eligibilityGate, enabled: true, origin: "baseline" as const, })); diff --git a/src/lib/skills/executor.ts b/src/lib/skills/executor.ts index 692716d485..ac958f1a54 100644 --- a/src/lib/skills/executor.ts +++ b/src/lib/skills/executor.ts @@ -1,3 +1,8 @@ +import { + sanitizeErrorMessage, + sanitizeUpstreamDetails, +} from "@omniroute/open-sse/utils/errorSanitization.ts"; + import { skillRegistry } from "./registry"; import { SkillExecution, SkillStatus, SkillHandler } from "./types"; import { builtinSkills } from "./builtins"; @@ -8,6 +13,169 @@ import { logger } from "../../../open-sse/utils/logger.ts"; const log = logger("SKILLS_EXECUTOR"); +function toSafeSkillErrorMessage(value: unknown): string { + try { + const raw = value instanceof Error ? value.message : value; + return sanitizeErrorMessage(raw) || "Skill execution failed"; + } catch { + return "Skill execution failed"; + } +} + +const SKILL_FAILURE_DISCRIMINATORS = new Set(["error", "failed", "failure"]); + +function isSkillErrorKey(key: string): boolean { + const normalizedKey = key.replace(/[-_]/g, "").toLowerCase(); + return ( + normalizedKey === "error" || + normalizedKey === "errors" || + normalizedKey === "warning" || + normalizedKey === "warnings" + ); +} + +function isFailureDiscriminator(value: unknown): boolean { + return typeof value === "string" && SKILL_FAILURE_DISCRIMINATORS.has(value.trim().toLowerCase()); +} + +function isSkillFailureOutput(output: Record): boolean { + try { + const status = output.status; + return ( + output.success === false || + (typeof status === "number" && Number.isFinite(status) && status >= 400) || + isFailureDiscriminator(status) || + isFailureDiscriminator(output.type) || + isFailureDiscriminator(output.event) || + isFailureDiscriminator(output.kind) + ); + } catch { + return true; + } +} + +type SensitiveSkillReferences = { + objects: WeakSet; + strings: Set; +}; + +function markSensitiveSkillReference(value: unknown, sensitive: SensitiveSkillReferences): void { + if (typeof value === "string") { + sensitive.strings.add(value); + return; + } + if (!value || typeof value !== "object" || sensitive.objects.has(value)) return; + + sensitive.objects.add(value); + try { + for (const entry of Object.values(value as Record)) { + markSensitiveSkillReference(entry, sensitive); + } + } catch { + // A revoked proxy or throwing getter is unsafe to expose at the boundary. + } +} + +function collectSensitiveSkillReferences( + value: unknown, + sensitive: SensitiveSkillReferences, + visited: WeakSet +): void { + if (!value || typeof value !== "object" || visited.has(value)) return; + visited.add(value); + + try { + for (const [key, entry] of Object.entries(value as Record)) { + if (isSkillErrorKey(key)) { + markSensitiveSkillReference(entry, sensitive); + } else { + collectSensitiveSkillReferences(entry, sensitive, visited); + } + } + } catch { + markSensitiveSkillReference(value, sensitive); + } +} + +type SkillProjectionContext = { + active: WeakSet; + projected: WeakMap; + sensitive: SensitiveSkillReferences; +}; + +function projectNestedSkillErrorSubtrees(value: unknown, context: SkillProjectionContext): unknown { + if (typeof value === "string") { + return context.sensitive.strings.has(value) ? sanitizeErrorMessage(value) : value; + } + if (!value || typeof value !== "object") return value; + if (context.active.has(value)) return "[circular]"; + if (context.projected.has(value)) return context.projected.get(value); + + if (context.sensitive.objects.has(value)) { + const safeValue = sanitizeUpstreamDetails(value); + context.projected.set(value, safeValue); + return safeValue; + } + + context.active.add(value); + if (Array.isArray(value)) { + const projected: unknown[] = []; + context.projected.set(value, projected); + for (const entry of value) projected.push(projectNestedSkillErrorSubtrees(entry, context)); + context.active.delete(value); + return projected; + } + + const projected: Record = {}; + context.projected.set(value, projected); + for (const [key, entry] of Object.entries(value as Record)) { + projected[key] = isSkillErrorKey(key) + ? sanitizeUpstreamDetails(entry) + : projectNestedSkillErrorSubtrees(entry, context); + } + context.active.delete(value); + return projected; +} + +function skillFailureMessage(output: Record): string { + try { + for (const candidate of [output.message, output.reason, output.statusText, output.error]) { + if (typeof candidate === "string" || candidate instanceof Error) { + return toSafeSkillErrorMessage(candidate); + } + } + } catch { + // Fall through to the stable public message. + } + return "Skill execution failed"; +} + +export function projectSkillOutputForBoundary( + output: Record +): Record { + try { + if (isSkillFailureOutput(output)) { + const projected = sanitizeUpstreamDetails(output); + return projected && typeof projected === "object" && !Array.isArray(projected) + ? (projected as Record) + : { success: false, error: "Skill execution failed" }; + } + + const sensitive: SensitiveSkillReferences = { + objects: new WeakSet(), + strings: new Set(), + }; + collectSensitiveSkillReferences(output, sensitive, new WeakSet()); + return projectNestedSkillErrorSubtrees(output, { + active: new WeakSet(), + projected: new WeakMap(), + sensitive, + }) as Record; + } catch { + return { success: false, error: "Skill execution failed" }; + } +} + class SkillExecutor { private static instance: SkillExecutor; private handlers: Map = new Map(); @@ -99,9 +267,14 @@ class SkillExecutor { const result = await this.executeWithTimeout( handler(input, { apiKeyId: context.apiKeyId, sessionId: context.sessionId || "" }) ); - output = result; + const resultIsFailure = isSkillFailureOutput(result); + output = projectSkillOutputForBoundary(result); + if (resultIsFailure) { + errorMessage = skillFailureMessage(result); + status = SkillStatus.ERROR; + } } catch (err) { - errorMessage = err instanceof Error ? err.message : String(err); + errorMessage = toSafeSkillErrorMessage(err); status = SkillStatus.ERROR; } @@ -131,7 +304,7 @@ class SkillExecutor { }; } catch (err) { const durationMs = Date.now() - startTime; - const errorMessage = err instanceof Error ? err.message : String(err); + const errorMessage = toSafeSkillErrorMessage(err); db.prepare( `UPDATE skill_executions SET status = ?, error_message = ?, duration_ms = ? WHERE id = ?` diff --git a/src/lib/skills/interception.ts b/src/lib/skills/interception.ts index 16b0146728..43c83c2d25 100644 --- a/src/lib/skills/interception.ts +++ b/src/lib/skills/interception.ts @@ -1,14 +1,29 @@ -import { skillExecutor } from "./executor"; +import { projectSkillOutputForBoundary, skillExecutor } from "./executor"; import { skillRegistry } from "./registry"; import { builtinSkills } from "./builtins"; import { memoryBuiltinHandlers, MEMORY_BUILTIN_TOOL_NAMES } from "./memoryBuiltins"; import { detectProvider, decodeSkillToolName } from "./injection"; import { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webSearchFallback.ts"; import { OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webFetchInterception.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; import { logger } from "../../../open-sse/utils/logger.ts"; const log = logger("SKILLS_INTERCEPTION"); +function toSafeSkillErrorMessage(value: unknown): string { + try { + const raw = value instanceof Error ? value.message : value; + return sanitizeErrorMessage(raw) || "Skill execution failed"; + } catch { + return "Skill execution failed"; + } +} + +function projectSkillResultForPublicResponse(result: unknown): unknown { + if (!result || typeof result !== "object" || Array.isArray(result)) return result; + return projectSkillOutputForBoundary(result as Record); +} + interface ToolCall { id: string; name: string; @@ -130,7 +145,7 @@ export async function interceptToolCalls( return { id: call.id, - result, + result: projectSkillResultForPublicResponse(result), }; } @@ -151,11 +166,12 @@ export async function interceptToolCalls( sessionId: context.sessionId, }); - const result = + const result = projectSkillResultForPublicResponse( execution.output ?? - (execution.errorMessage - ? { error: execution.errorMessage } - : { error: "Skill execution returned no output" }); + (execution.errorMessage + ? { error: toSafeSkillErrorMessage(execution.errorMessage) } + : { error: "Skill execution returned no output" }) + ); log.info("skills.interception.execution_complete", { toolName: call.name, @@ -167,14 +183,15 @@ export async function interceptToolCalls( result, }; } catch (err) { + const safeError = toSafeSkillErrorMessage(err); log.error("skills.interception.execution_failed", { toolName: call.name, callId: call.id, - err: err instanceof Error ? err.message : String(err), + err: safeError, }); return { id: call.id, - result: { error: err instanceof Error ? err.message : String(err) }, + result: { error: safeError }, }; } }) diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 2820a28e23..eff72dfd9c 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -564,18 +564,11 @@ export async function checkConnection(conn) { } } - // #8182: skip terminal connections (credits_exhausted / banned / expired). - // These can never self-heal via a token refresh — probing them wastes - // CPU and network on every sweep cycle. Mirrors isTerminalConnectionStatus - // in src/sse/services/auth.ts and TERMINAL_CONNECTION_STATUSES in - // src/lib/quota/connectionRecovery.ts. - // - // #5326 exception: a GitHub Copilot access-token-only connection parked in - // "expired" with errorCode "no_refresh_token" is NOT actually terminal — it's - // the exact target of the self-heal below (canClearGitHubNoRefreshTokenState), - // which clears that stale status back to "active" once the Copilot sub-token - // proves usable. Treating it as terminal here made that self-heal unreachable, - // leaving healthy Copilot connections stuck at "expired" forever. + // #8182: skip banned/expired (dead credentials). credits_exhausted is a + // renewing window — keep sweeping so OAuth refresh can clear a false mark. + // #5326: GitHub Copilot access-token-only "expired" + no_refresh_token is + // the self-heal target below (canClearGitHubNoRefreshTokenState). Treating + // it as terminal made that heal unreachable and stuck healthy Copilot rows. const isRecoverableGithubCopilotNoRefresh = conn.testStatus === "expired" && conn.errorCode === "no_refresh_token" && @@ -596,7 +589,8 @@ export async function checkConnection(conn) { conn.testStatus === "expired" && conn.lastErrorType !== "account_deactivated" && getExpiredRetryCount(conn) < EXPIRED_RETRY_MAX; - const terminalStatuses = new Set(["credits_exhausted", "banned", "expired"]); + // Skip only banned/expired. Combo pre-skip still hides exhausted rows. + const terminalStatuses = new Set(["banned", "expired"]); if ( typeof conn.testStatus === "string" && terminalStatuses.has(conn.testStatus.toLowerCase()) && diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 43a5028b9d..5f0e3a03fc 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -8,6 +8,7 @@ import fs from "node:fs"; import path from "node:path"; import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; import { getDbInstance } from "../db/core"; import { getRequestDetailLogByCallLogId } from "../db/detailedLogs"; import { shouldPersistToDisk } from "./migrations"; @@ -21,7 +22,11 @@ import { getObservedReasoning, } from "./tokenAccounting"; import { isNoLog } from "../compliance/noLog"; -import { protectPayloadForLog, parseStoredPayload } from "../logPayloads"; +import { + parseStoredPayload, + protectErrorPayloadForLog, + protectPayloadForLog, +} from "../logPayloads"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; import { CALL_LOGS_DIR, @@ -335,7 +340,10 @@ function readLegacyLogFromDisk(entry: { return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8")); } } catch (error) { - console.error("[callLogs] Failed to read legacy disk log:", (error as Error).message); + console.error( + "[callLogs] Failed to read legacy disk log:", + sanitizeErrorMessage(error) || "Legacy call log read failed" + ); } return null; @@ -447,10 +455,19 @@ async function saveCallLogOperation(entry: any): Promise { const noLogEnabled = Boolean(entry.noLog) || (apiKeyId ? isNoLog(apiKeyId) : false); const protectedRequestBody = noLogEnabled ? null : protectPayloadForLog(entry.requestBody); - const protectedResponseBody = noLogEnabled ? null : protectPayloadForLog(entry.responseBody); + const responseStatus = Number(entry.status); + const failedResponse = Number.isFinite(responseStatus) && responseStatus >= 400; + const protectedResponseBody = noLogEnabled + ? null + : failedResponse + ? protectErrorPayloadForLog(entry.responseBody) + : protectPayloadForLog(entry.responseBody); const protectedPipelinePayloads = noLogEnabled ? null - : protectPipelinePayloads(entry.pipelinePayloads ?? entry.pipeline ?? null); + : protectPipelinePayloads( + entry.pipelinePayloads ?? entry.pipeline ?? null, + failedResponse ? responseStatus : undefined + ); const protectedError = sanitizeErrorForLog(entry.error); const account = await resolveAccountName(entry.connectionId || null); @@ -582,7 +599,10 @@ async function saveCallLogOperation(entry: any): Promise { scheduleCallLogRotation(); } catch (error) { - console.error("[callLogs] Failed to save call log:", (error as Error).message); + console.error( + "[callLogs] Failed to save call log:", + sanitizeErrorMessage(error) || "Call log persistence failed" + ); } } diff --git a/src/lib/usage/callLogs/format.ts b/src/lib/usage/callLogs/format.ts index 40054c3a37..63068e73bc 100644 --- a/src/lib/usage/callLogs/format.ts +++ b/src/lib/usage/callLogs/format.ts @@ -1,7 +1,16 @@ import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; import { classifyProviderError } from "@omniroute/open-sse/services/errorClassifier.ts"; +import { + sanitizeErrorMessage, + sanitizeUpstreamDetails, +} from "@omniroute/open-sse/utils/errorSanitization.ts"; import { sanitizePII } from "../../piiSanitizer"; -import { omitEncryptedReasoningFromLogChunks, protectPayloadForLog } from "../../logPayloads"; +import { + omitEncryptedReasoningFromLogChunks, + protectErrorPayloadForLog, + protectPayloadForLog, + sanitizeErrorFramesFromLogChunks, +} from "../../logPayloads"; import type { CallLogDetailState } from "../callLogArtifacts"; // #7879: re-export the canonical helper so existing consumers of this module // keep importing `toNumber` from here unchanged. @@ -44,15 +53,24 @@ export function normalizeDetailState(value: unknown): CallLogDetailState { export function sanitizeErrorForLog(error: unknown): unknown { if (error === null || error === undefined) return null; - if (typeof error === "string") return sanitizePII(error).text; - if (error instanceof Error) { - return { - message: sanitizePII(error.message).text, - stack: sanitizePII(error.stack || "").text || undefined, - name: error.name, - }; + if (typeof error === "string") { + return sanitizePII(sanitizeErrorMessage(error)).text; + } + try { + if (error instanceof Error) { + const message = sanitizePII(sanitizeErrorMessage(error.message)).text; + const stack = sanitizePII(sanitizeErrorMessage(error.stack || "")).text; + const name = sanitizeErrorMessage(error.name) || "Error"; + return { + message, + ...(stack ? { stack } : {}), + name, + }; + } + return protectPayloadForLog(sanitizeUpstreamDetails(error)); + } catch { + return "[REDACTED]"; } - return protectPayloadForLog(error); } export function toStoredErrorSummary(error: unknown): string | null { @@ -70,7 +88,10 @@ export function toStoredErrorSummary(error: unknown): string | null { } } -export function protectPipelinePayloads(payloads: unknown): RequestPipelinePayloads | null { +export function protectPipelinePayloads( + payloads: unknown, + responseStatus?: unknown +): RequestPipelinePayloads | null { if (!payloads || typeof payloads !== "object") return null; const protectedPayloads: RequestPipelinePayloads = {}; @@ -84,7 +105,9 @@ export function protectPipelinePayloads(payloads: unknown): RequestPipelinePaylo .filter(([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0) .map(([stage, chunkValue]) => [ stage, - omitEncryptedReasoningFromLogChunks(chunkValue as string[]), + sanitizeErrorFramesFromLogChunks( + omitEncryptedReasoningFromLogChunks(chunkValue as string[]) + ), ]) ); if (Object.keys(compacted).length > 0) { @@ -95,6 +118,21 @@ export function protectPipelinePayloads(payloads: unknown): RequestPipelinePaylo continue; } + if (key === "providerResponse" || key === "clientResponse") { + const response = asRecord(value); + const status = Number(response.status ?? responseStatus); + if (Number.isFinite(status) && status >= 400 && status <= 599) { + const projectedResponse = + "body" in response + ? { ...response, body: protectErrorPayloadForLog(response.body) } + : protectErrorPayloadForLog(value); + protectedPayloads[key as "providerResponse" | "clientResponse"] = protectPayloadForLog( + projectedResponse + ) as RequestPipelinePayloads["providerResponse"]; + continue; + } + } + protectedPayloads[key as keyof RequestPipelinePayloads] = protectPayloadForLog(value) as never; } diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 3d9b0dfa68..a6fb9a7d3a 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -9,6 +9,7 @@ import { getDbInstance } from "../db/core"; import { protectPayloadForLog } from "../logPayloads"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; import { resolveOrphanedUsageAccountIdentity, resolveUsageAccountIdentity, @@ -128,7 +129,7 @@ function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingReq normalized.status = Number.isFinite(status) ? status : null; } if (metadata.error !== undefined) { - normalized.error = toStringOrNull(metadata.error) || null; + normalized.error = sanitizeErrorMessage(toStringOrNull(metadata.error)) || null; } if (metadata.errorCode !== undefined) { normalized.errorCode = toStringOrNull(metadata.errorCode) || null; @@ -154,6 +155,7 @@ declare global { details: Record>; }; pendingById: Map; + pendingIdByCorrelation: Map; } | undefined; } @@ -173,6 +175,7 @@ const pendingState = (globalThis.__omnirouteUsageHistoryPendingState ??= { details: Object.create(null) as Record>, }, pendingById: new Map(), + pendingIdByCorrelation: new Map(), }); const pendingRequests = pendingState.pendingRequests; @@ -183,6 +186,21 @@ const pendingRequests = pendingState.pendingRequests; */ const pendingById = pendingState.pendingById; +// Live incident: a combo dispatch calls trackPendingRequest once PER TARGET +// ATTEMPT (open-sse/handlers/chatCore.ts's single "started" call site, hit +// again on every fallback), each generating its OWN fresh id. A dashboard tab +// polling /api/logs/ for the FIRST attempt goes stale the moment that +// attempt finalizes and the combo silently retries with a different target +// under a different id -- the tab has no way to discover the new id, and the +// request keeps streaming (successfully) with nobody watching it live. Since +// correlationId is already stable across every attempt of one client request +// (see the trackPendingRequest call site's `correlationId` metadata field), +// reusing the SAME pending id for every attempt sharing a correlationId keeps +// one dashboard tab's poll target valid across combo fallbacks. Bounded by +// PENDING_SWEEP_INTERVAL_MS's existing reaper cycle (see sweepStalePendingRequests) +// so this never grows unboundedly with one-shot correlation ids. +const pendingIdByCorrelation = pendingState.pendingIdByCorrelation; + const DEFAULT_MAX_PENDING_REQUEST_AGE_MS = 60 * 60 * 1000; const MAX_PENDING_DETAILS = 5000; const PENDING_SWEEP_INTERVAL_MS = 5 * 60 * 1000; @@ -249,6 +267,20 @@ export function sweepStalePendingRequests( for (const detail of oldest) remove(detail); } + // pendingIdByCorrelation entries are correlation ids, never reused across + // separate client requests, so nothing else ever removes them — same + // age/cap sweep as pendingById above, or the map grows unboundedly. + for (const [correlationId, entry] of pendingIdByCorrelation) { + if (now - entry.touchedAt > maxAgeMs) pendingIdByCorrelation.delete(correlationId); + } + if (pendingIdByCorrelation.size > MAX_PENDING_DETAILS) { + const overflow = pendingIdByCorrelation.size - MAX_PENDING_DETAILS; + const oldest = [...pendingIdByCorrelation.entries()] + .sort((a, b) => a[1].touchedAt - b[1].touchedAt) + .slice(0, overflow); + for (const [correlationId] of oldest) pendingIdByCorrelation.delete(correlationId); + } + return removed; } @@ -308,11 +340,23 @@ export function trackPendingRequest( pendingRequests.details[connectionId][modelKey] = []; } const now = Date.now(); + // Reuse the same pending id across every target attempt of one client + // request (see pendingIdByCorrelation's module-level comment) so a + // dashboard tab's live poll survives a combo fallback to a different + // target instead of silently going stale. Concurrent speculative + // attempts (combo.ts's zeroLatencyOptimizationsEnabled hedging) can + // race two "started" calls for the same correlationId — the second + // simply overwrites the id-keyed view of the first's still-live entry, + // no worse than today's per-attempt id (which loses tracking entirely + // once any attempt finalizes) and self-corrects on the next attempt. + const reusableId = normalizedMetadata.correlationId + ? pendingIdByCorrelation.get(normalizedMetadata.correlationId)?.id + : undefined; const newDetail = { // crypto RNG (not Math.random) to satisfy CodeQL js/insecure-randomness — // this pending-request id flows into attempt logging; it's a correlation // id, not a security secret. - id: `${now}-${globalThis.crypto.randomUUID().slice(0, 6)}`, + id: reusableId ?? `${now}-${globalThis.crypto.randomUUID().slice(0, 6)}`, model, provider, connectionId, @@ -321,6 +365,9 @@ export function trackPendingRequest( }; pendingRequests.details[connectionId][modelKey].push(newDetail); pendingById.set(newDetail.id, newDetail); + if (normalizedMetadata.correlationId) { + pendingIdByCorrelation.set(normalizedMetadata.correlationId, { id: newDetail.id, touchedAt: now }); + } return newDetail.id; } else if (!started && nextCount >= 0) { if (pendingRequests.details[connectionId]?.[modelKey]?.length) { @@ -519,6 +566,7 @@ export function clearPendingRequests() { Record >; pendingById.clear(); + pendingIdByCorrelation.clear(); clearCompletedDetails(); } diff --git a/src/lib/usage/usageStats.ts b/src/lib/usage/usageStats.ts index 2bc459fef7..833eb6ef31 100644 --- a/src/lib/usage/usageStats.ts +++ b/src/lib/usage/usageStats.ts @@ -318,6 +318,10 @@ export async function getUsageStats() { } const pendingRequests = getPendingRequests(); + const publicPendingRequests = { + byModel: pendingRequests.byModel, + byAccount: pendingRequests.byAccount, + }; const stats: { totalRequests: number; @@ -329,7 +333,7 @@ export async function getUsageStats() { byAccount: Record; byApiKey: Record; last10Minutes: UsageBucket[]; - pending: ReturnType; + pending: Pick, "byModel" | "byAccount">; activeRequests: ActiveRequest[]; } = { totalRequests: 0, @@ -341,7 +345,7 @@ export async function getUsageStats() { byAccount: {}, byApiKey: {}, last10Minutes: [], - pending: pendingRequests, + pending: publicPendingRequests, activeRequests: [], }; diff --git a/src/shared/constants/claudeCodeClient.ts b/src/shared/constants/claudeCodeClient.ts index dd72f6246e..50be40b740 100644 --- a/src/shared/constants/claudeCodeClient.ts +++ b/src/shared/constants/claudeCodeClient.ts @@ -3,6 +3,10 @@ * * Keep this leaf dependency-free so server executors, compatibility bridges, * and client-facing identity presets can share one source of truth. + * + * `CLAUDE_CODE_CLIENT_VERSION` is the captured pin. Runtime callers that + * advertise the version on the wire must go through getClaudeCodeClientVersion() + * so operators can bump past Anthropic's model gate without a rebuild (#12417). */ export const CLAUDE_CODE_CLIENT_VERSION = "2.1.258"; export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "1e2"; @@ -12,6 +16,27 @@ export const CLAUDE_CODE_RUNTIME_VERSION = "v26.3.0"; export type ClaudeCodeEntrypoint = "cli" | "sdk-cli"; +const CLAUDE_VERSION_OVERRIDE_ENV = "CLAUDE_CODE_CLIENT_VERSION"; +const SAFE_HEADER_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/; + +function getSafeEnvValue(name: string, pattern: RegExp): string | null { + const raw = typeof process === "undefined" ? undefined : process.env?.[name]; + if (typeof raw !== "string") return null; + const normalized = raw.trim(); + if (!normalized || !pattern.test(normalized)) { + return null; + } + return normalized; +} + +export function getClaudeCodeClientVersion(): string { + return getSafeEnvValue(CLAUDE_VERSION_OVERRIDE_ENV, SAFE_HEADER_TOKEN_PATTERN) || CLAUDE_CODE_CLIENT_VERSION; +} + +export function getClaudeCodeClientBillingVersion(): string { + return `${getClaudeCodeClientVersion()}.${CLAUDE_CODE_CLIENT_BUILD_REVISION}`; +} + export function getClaudeCodeUserAgent(entrypoint: ClaudeCodeEntrypoint): string { - return `claude-cli/${CLAUDE_CODE_CLIENT_VERSION} (external, ${entrypoint})`; + return `claude-cli/${getClaudeCodeClientVersion()} (external, ${entrypoint})`; } diff --git a/src/shared/constants/headers.ts b/src/shared/constants/headers.ts index a4b6b50ff2..136be95bf2 100644 --- a/src/shared/constants/headers.ts +++ b/src/shared/constants/headers.ts @@ -14,5 +14,6 @@ export const OMNIROUTE_RESPONSE_HEADERS = { responseCost: "X-OmniRoute-Response-Cost", tokensIn: "X-OmniRoute-Tokens-In", tokensOut: "X-OmniRoute-Tokens-Out", + tokensPerSecond: "X-OmniRoute-Tokens-Per-Second", version: "X-OmniRoute-Version", } as const; diff --git a/src/shared/constants/pricing/inference-hosts.ts b/src/shared/constants/pricing/inference-hosts.ts index e09715b942..3551548bdd 100644 --- a/src/shared/constants/pricing/inference-hosts.ts +++ b/src/shared/constants/pricing/inference-hosts.ts @@ -340,26 +340,29 @@ export const DEFAULT_PRICING_INFERENCE = { cache_creation: 0, }, }, + // #11773: Developer-tier $/1M from cerebras.ai/pricing (2026-09-03). + // Signup is a one-time $5 credit, not a $0 token grant — keep paid rates + // so classifyTier cannot treat Cerebras as the free routing tier. cerebras: { - "gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, - "gemma-4-31b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, - "zai-glm-4.7": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, - "llama-3.3-70b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "gpt-oss-120b": { input: 0.35, output: 0.75, cached: 0, reasoning: 0, cache_creation: 0 }, + "gemma-4-31b": { input: 0.4, output: 0.8, cached: 0, reasoning: 0, cache_creation: 0 }, + "zai-glm-4.7": { input: 2.25, output: 2.75, cached: 0, reasoning: 0, cache_creation: 0 }, + "llama-3.3-70b": { input: 0.85, output: 1.2, cached: 0, reasoning: 0, cache_creation: 0 }, "llama-4-scout-17b-16e-instruct": { - input: 0, - output: 0, + input: 0.2, + output: 0.2, cached: 0, reasoning: 0, cache_creation: 0, }, "qwen-3-235b-a22b-instruct-2507": { - input: 0, - output: 0, + input: 0.6, + output: 1.2, cached: 0, reasoning: 0, cache_creation: 0, }, - "qwen-3-32b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "qwen-3-32b": { input: 0.4, output: 0.8, cached: 0, reasoning: 0, cache_creation: 0 }, }, nvidia: { "nvidia/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts index 71609ef544..e22a0e4330 100644 --- a/src/shared/constants/providers/apikey/frontier-labs.ts +++ b/src/shared/constants/providers/apikey/frontier-labs.ts @@ -92,7 +92,8 @@ export const APIKEY_PROVIDERS_FRONTIER = { textIcon: "GQ", website: "https://groq.com", hasFree: true, - freeNote: "Free tier: 30 RPM / 14.4K RPD — no credit card", + freeNote: + "Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.", serviceKinds: ["llm", "imageToText"], }, blackbox: { diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 7f09204e3d..c1f87a6d75 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -15,7 +15,8 @@ export const APIKEY_PROVIDERS_GATEWAYS = { color: "#6366F1", textIcon: "1M", website: "https://1min.ai", - authHint: "Create an API key at https://docs.1min.ai/docs/api/create-api-key, then paste it here.", + authHint: + "Create an API key at https://docs.1min.ai/docs/api/create-api-key, then paste it here.", apiHint: "1min.ai uses a proprietary chat API (single prompt string + SSE) instead of OpenAI chat/completions. OmniRoute flattens OpenAI messages into a labeled prompt and translates the SSE stream.", passthroughModels: true, @@ -47,7 +48,8 @@ export const APIKEY_PROVIDERS_GATEWAYS = { website: "https://freebuff.com", hasFree: true, serviceKinds: ["llm"], - authHint: "Enter Freebuff / Codebuff Auth Token (obtained via CLI login or automated harvester).", + authHint: + "Enter Freebuff / Codebuff Auth Token (obtained via CLI login or automated harvester).", freeNote: "Free Codebuff / Freebuff AI models.", apiHint: "Token is authenticated against Codebuff upstream session pool.", passthroughModels: true, @@ -1327,9 +1329,10 @@ export const APIKEY_PROVIDERS_GATEWAYS = { passthroughModels: true, website: "https://bynara.id", hasFree: true, - freeNote: "Free tier is a shared 5M tokens/day pool; some models are gated behind credit/plan.", + freeNote: + "Free plan: one 7M tokens/day bucket per account (15 req/min) across the plan's 8 models; others need credit.", authHint: - "Get a free API key via NaraRouter's Telegram channel, then paste it here as a Bearer token.", + "Create a free NaraRouter account, link your Telegram (required before /v1 answers), then paste the key here as a Bearer token.", apiHint: "OpenAI-compatible endpoint at https://router.bynara.id/v1. Free-tier models are pinned; others need credit.", }, diff --git a/src/shared/constants/providers/apikey/inference-hosts.ts b/src/shared/constants/providers/apikey/inference-hosts.ts index 84cd65ad41..730ccd64f3 100644 --- a/src/shared/constants/providers/apikey/inference-hosts.ts +++ b/src/shared/constants/providers/apikey/inference-hosts.ts @@ -86,7 +86,12 @@ export const APIKEY_PROVIDERS_INFERENCE = { textIcon: "CB", website: "https://inference.cerebras.ai", hasFree: true, - freeNote: "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.", + // #11773: Cerebras retired the no-card 1M tokens/day trial. Live + // cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit that + // requires a payment method and expires after 30 days — LongCat-shaped + // (hasFree stays true; not a recurring grant). + freeNote: + "One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier.", }, nvidia: { id: "nvidia", diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts index b30ad4cad7..f838e92224 100644 --- a/src/shared/middleware/chatBodyAdmission.ts +++ b/src/shared/middleware/chatBodyAdmission.ts @@ -945,14 +945,7 @@ function rebuildRequest(request: Request, body: Uint8Array): Request { } as RequestInit & { duplex: "half" }); } -/** - * Reserve heavyweight capacity and ingest the body with a hard byte bound before JSON - * parsing. Missing/invalid Content-Length is sniffed only up to the heavyweight threshold; - * a lease is acquired atomically before retaining bytes at or beyond that threshold. - * - * Internal self-loop sub-requests (vision-bridge describe calls) bypass the lease - * reservation — they run inside a parent request that already holds the lease. - */ +/** Reserve heavyweight capacity and ingest the body with a hard byte bound. */ export async function admitChatRequest( request: Request, options: { @@ -961,6 +954,7 @@ export async function admitChatRequest( largeBodyBytes?: number; hardMaxBytes?: number; queueMs?: number; + heapPressureCheck?: () => boolean; } = {} ): Promise { const sessionId = options.sessionId ?? resolveSessionId(request); @@ -1028,15 +1022,16 @@ export async function admitChatRequest( return { admit: false, response: bodyExceedsBudgetResponse(controller.maxInflightBytes) }; } + const heapPressureCheck = options.heapPressureCheck ?? defaultHeapPressureCheck; let lease: ChatAdmissionLease | null = null; + // #10437: busy primary + healthy heap uses tryAcquireHealthyHeadroom; else queue/shed. + // Bodies at/above OMNIROUTE_CHAT_LARGE_BODY_BYTES take this same heavyweight lease. const reserve = async (bytes = 0): Promise => { if (lease) return true; - const countLease = await controller.acquireHeavyWithin( - queueMs, - request.signal, - bytes, - sessionId - ); + const countLease = + controller.tryAcquireHeavy() ?? + (!heapPressureCheck() ? controller.tryAcquireHealthyHeadroom() : null) ?? + (await controller.acquireHeavyWithin(queueMs, request.signal, bytes, sessionId)); if (!countLease) return false; // Additive ingest byte-budget gate (#503-fanout), layered on top of the diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 49cb628bb0..67d9b4b70e 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -254,8 +254,7 @@ async function isComboAllowedForKey( } function quotaPolicyResponse(message: string, code: string): Response { - const body = buildErrorBody(HTTP_STATUS.FORBIDDEN, message); - body.error.code = code; + const body = buildErrorBody(HTTP_STATUS.FORBIDDEN, message, undefined, { code }); return new Response(JSON.stringify(body), { status: HTTP_STATUS.FORBIDDEN, headers: { "Content-Type": "application/json" }, diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts index 02e4e67811..c84917f0cd 100644 --- a/src/shared/utils/circuitBreaker.ts +++ b/src/shared/utils/circuitBreaker.ts @@ -102,6 +102,30 @@ export function isLocalExecutionError(error: unknown): boolean { return LOCAL_EXECUTION_PATTERNS.some((p) => p.test(message)); } +/** + * Anthropic/Claude model-capacity overload (HTTP 529, body "Overloaded", or a + * STREAM_EARLY_EOF that wraps that body as 502). This is one model being + * capacity-throttled, not a whole-provider outage — the same account still + * serves sibling models. Must not trip the provider circuit breaker. + * + * Accepts an error object/string OR a numeric HTTP status (529). Callers + * pass both `error` and `status` at the two breaker predicates. + * + * Live incident 2026-09-03: STREAM_EARLY_EOF: Overloaded opened `claude` and + * a single-target combo then pre-skipped with ALL_TARGETS_SKIPPED in ~43ms. + */ +export function isModelCapacityOverloadError(error: unknown): boolean { + if (error === 529) return true; + if (typeof error === "number") return false; + if (!error) return false; + const errObj = typeof error === "object" ? (error as Record) : null; + if (errObj && (errObj.status === 529 || errObj.statusCode === 529)) return true; + const message = + typeof error === "string" ? error : typeof errObj?.message === "string" ? errObj.message : ""; + if (!message) return false; + return /\boverloaded(?:_error)?\b/i.test(message); +} + export const STATE = { CLOSED: "CLOSED", DEGRADED: "DEGRADED", diff --git a/src/shared/utils/terminalStatus.ts b/src/shared/utils/terminalStatus.ts index 1b74768b9a..b2e46ed614 100644 --- a/src/shared/utils/terminalStatus.ts +++ b/src/shared/utils/terminalStatus.ts @@ -1,17 +1,33 @@ import { updateProviderConnection } from "@/lib/db/providers"; import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; -type Patch = { testStatus: string; isActive?: boolean; lastError?: string | null; errorCode?: string | null; lastErrorType?: string | null; lastErrorAt?: string | null }; -const TERMINAL = new Set(["banned","expired","deactivated","credits_exhausted"]); +type Patch = { + testStatus: string; + isActive?: boolean; + lastError?: string | null; + errorCode?: string | null; + lastErrorType?: string | null; + lastErrorAt?: string | null; +}; +const TERMINAL = new Set(["banned", "expired", "deactivated", "credits_exhausted"]); -export async function writeTerminalStatus(connectionId: string, patch: Patch, origin: "probe" | "production"): Promise { +export async function writeTerminalStatus( + connectionId: string, + patch: Patch, + origin: "probe" | "production" +): Promise { const isTerminal = TERMINAL.has(patch.testStatus.toLowerCase()); + const persistedLastError = + patch.lastError == null + ? null + : sanitizeErrorMessage(patch.lastError) || "Provider request failed"; // Double gate: AsyncLocalStorage probe + explicit origin "probe" — fail-safe ON const probeIsolated = await shouldIsolateProbeFailures(); if ((origin === "probe" || probeIsolated) && isTerminal) { // record-only: never remove from pool await updateProviderConnection(connectionId, { - lastError: patch.lastError ?? null, + lastError: persistedLastError, lastErrorAt: new Date().toISOString(), lastErrorType: patch.lastErrorType ?? null, errorCode: patch.errorCode ?? null, @@ -21,7 +37,7 @@ export async function writeTerminalStatus(connectionId: string, patch: Patch, or await updateProviderConnection(connectionId, { isActive: patch.isActive ?? (isTerminal ? false : undefined), testStatus: patch.testStatus, - lastError: patch.lastError ?? null, + lastError: persistedLastError, lastErrorAt: new Date().toISOString(), lastErrorType: patch.lastErrorType ?? null, errorCode: patch.errorCode ?? null, diff --git a/src/shared/validation/schemas/keys.ts b/src/shared/validation/schemas/keys.ts index 37741f56a9..1441c8851f 100644 --- a/src/shared/validation/schemas/keys.ts +++ b/src/shared/validation/schemas/keys.ts @@ -33,9 +33,28 @@ const requireExclusiveLeaseConnections = ( }); }; +const requireConsistentModelAccess = ( + value: { + modelAccessMode?: "all" | "restricted"; + allowedModels?: string[]; + }, + ctx: z.RefinementCtx +) => { + if (value.modelAccessMode === "all" && value.allowedModels && value.allowedModels.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "allowedModels must be empty when modelAccessMode is 'all'", + path: ["allowedModels"], + }); + } +}; + export const createKeySchema = z .object({ name: z.string().min(1, "Name is required").max(200), + modelAccessMode: z.enum(["all", "restricted"]).optional(), + allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(), + allowedCombos: z.array(z.string().trim().min(1).max(200)).max(500).optional(), noLog: z.boolean().optional(), allowUsageCommand: z.boolean().optional(), usageLimitEnabled: z.boolean().optional(), @@ -45,7 +64,10 @@ export const createKeySchema = z scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(), allowedConnections: z.array(z.string().uuid()).min(1).max(100).optional(), }) - .superRefine(requireExclusiveLeaseConnections); + .superRefine((value, ctx) => { + requireConsistentModelAccess(value, ctx); + requireExclusiveLeaseConnections(value, ctx); + }); export const createSyncTokenSchema = z.object({ name: z.string().trim().min(1, "Name is required").max(200), diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 7ad2131076..6c03803ba1 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -78,6 +78,7 @@ import { } from "@/lib/db/sessionAccountAffinity"; import { dispatchChatWithAffinityEviction } from "./chatDispatch"; import { getCachedSettings, getCombosCacheVersion } from "@/lib/db/readCache"; +import { comboCheckProvider, ghComboGate } from "./chat/githubLiveCatalogFilter.ts"; import { getCombos } from "@/lib/db/combos"; import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings"; import { @@ -752,6 +753,17 @@ async function handleChatImplementation( clientRawRequest = chatAdmission.resolveClientRawAfterAdmission(clientRawRequest, () => deferredClientRawBody.withClientBody((clientBody) => buildClientRawRequest(request, clientBody)) ); + // Sibling of clientRawRequest.body, not a replacement: .body stays the raw + // pre-reconstruction client bytes (see captureDeferredClientRawBody), while + // this is the `input` actually dispatched with -- after the + // previous_response_id reconstruction above ran, when it applies. A future + // continuation lookup against THIS response must resolve from this field, + // not the raw one. See the logClientRawRequest doc comment in requestLogger.ts. + if (clientRawRequest && Array.isArray((body as { input?: unknown }).input)) { + (clientRawRequest as { effectiveInput?: unknown }).effectiveInput = ( + body as { input: unknown[] } + ).input; + } // Guardrail pre-call pipeline — prompt injection, PII masking, and future custom rules. telemetry.startPhase("validate"); @@ -1025,18 +1037,10 @@ async function handleChatImplementation( if (isCommonChatGptWebRetirementError(error)) return false; throw error; } - // Apply the same prefix-override guard as handleSingleModelChat: - // if providerId is just the prefix already in the model string, use - // the fully-resolved modelInfo.provider for a precise credential check. - const provider = (() => { - if (!target?.providerId) return modelInfo.provider; - if (target.providerId === modelInfo.provider) return modelInfo.provider; - if (modelString.startsWith(target.providerId + "/")) return modelInfo.provider; - return target.providerId; - })(); - if (!provider) return true; // can't determine provider, let it try - + const provider = comboCheckProvider(modelString, modelInfo, target?.providerId); const resolvedModel = modelInfo.model || modelString; + const githubGate = await ghComboGate(comboPreselectedCredentials, provider, resolvedModel); + if (githubGate !== null) return githubGate; const hasForcedConnection = typeof target?.connectionId === "string" && target.connectionId.trim().length > 0; let allowedConnections = intersectAllowedConnectionIds( diff --git a/src/sse/handlers/chat/githubLiveCatalogFilter.ts b/src/sse/handlers/chat/githubLiveCatalogFilter.ts new file mode 100644 index 0000000000..4112e815fc --- /dev/null +++ b/src/sse/handlers/chat/githubLiveCatalogFilter.ts @@ -0,0 +1,71 @@ +/** + * GitHub live-catalog combo gate (#12137). + * + * Extracted from chat.ts so the frozen handler does not grow. Explicit combo + * members missing from an authoritative GitHub catalog are skipped; a catalog + * that is not synced yet fails open (same pattern as providerWildcard). + * + * The catalog promise is memoized per request-scope object so combo candidates + * share one getActiveSyncedCatalog fetch instead of re-hitting the DB. + */ +import { + catalogContainsModel, + getActiveSyncedCatalog, + type ActiveSyncedCatalog, +} from "@/lib/db/models/activeSyncedCatalog"; + +const catalogByScope = new WeakMap>>(); + +/** Prefix-override guard used by combo pre-check (same as handleSingleModelChat). */ +export function comboCheckProvider( + modelString: string, + modelInfo: { provider?: string }, + providerId?: string | null +): string | undefined { + if (!providerId) return modelInfo.provider; + if (providerId === modelInfo.provider) return modelInfo.provider; + if (modelString.startsWith(providerId + "/")) return modelInfo.provider; + return providerId; +} + +function loadGithubLiveCatalog( + scope: object, + providerId: string, + loadCatalog: (id: string) => Promise = getActiveSyncedCatalog +): Promise { + let byProvider = catalogByScope.get(scope); + if (!byProvider) { + byProvider = new Map(); + catalogByScope.set(scope, byProvider); + } + let pending = byProvider.get(providerId); + if (!pending) { + pending = loadCatalog(providerId); + byProvider.set(providerId, pending); + } + return pending; +} + +/** + * Combo pre-check for GitHub live-catalog membership. + * + * Returns: + * - `true` — allow immediately (provider could not be determined) + * - `false` — skip this combo member + * - `null` — not a GitHub skip; continue the remaining credential checks + */ +export async function ghComboGate( + scope: object, + provider: string | null | undefined, + resolvedModel: string, + loadCatalog?: (id: string) => Promise +): Promise { + if (!provider) return true; + if (provider !== "github" && provider !== "gh") return null; + const inLiveCatalog = catalogContainsModel( + await loadGithubLiveCatalog(scope, provider, loadCatalog), + resolvedModel + ); + if (inLiveCatalog === false) return false; + return null; +} diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index ac53afc178..b98ee96542 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -777,9 +777,11 @@ export function handleNoCredentials( } if (credentials?.allExpired) { // Every connection for this provider is in a terminal state (expired, - // banned, or credits_exhausted). Surface as 401 with a re-auth hint - // instead of the generic 400 "No credentials", so dashboards/CLIs can - // distinguish "never configured" from "needs to reconnect". + // banned, or credits_exhausted). Surface expired/banned as 401 with a + // re-auth hint instead of the generic 400 "No credentials", so + // dashboards/CLIs can distinguish "never configured" from "needs to + // reconnect". credits_exhausted is quota (HTTP 402), not invalid + // credentials — see #12441. const status = credentials.expiredStatus || "expired"; const count = credentials.expiredCount || 1; const reason = @@ -790,7 +792,12 @@ export function handleNoCredentials( : "authentication expired"; const message = `[${provider}] All ${count} connection(s) ${reason} — please reconnect in the dashboard`; log.warn("CHAT", message); - return errorResponse(HTTP_STATUS.UNAUTHORIZED, message); + // #12441: credits_exhausted is quota, not invalid credentials. Combo + // dispatch treats 401 as AUTH_LEVEL skip (#8133). Surface 402 so quota + // exhaustion follows the #1731 path instead of "authentication expired". + const httpStatus = + status === "credits_exhausted" ? HTTP_STATUS.PAYMENT_REQUIRED : HTTP_STATUS.UNAUTHORIZED; + return errorResponse(httpStatus, message); } if (!excludeConnectionId) { // Ported from upstream decolua/9router#336 (Ibrahim Ryan): surface as 404 diff --git a/src/sse/handlers/chatPredicates.ts b/src/sse/handlers/chatPredicates.ts index f1ccbbaab2..88911a41b5 100644 --- a/src/sse/handlers/chatPredicates.ts +++ b/src/sse/handlers/chatPredicates.ts @@ -1,6 +1,7 @@ import { isLocalStreamLifecycleError, isLocalExecutionError, + isModelCapacityOverloadError, } from "../../shared/utils/circuitBreaker"; import { isRequestScopedUpstreamFailure } from "./comboFailureLogging"; import { getTrustedLocalRateLimitResponse } from "@omniroute/open-sse/services/rateLimitManager/errors"; @@ -39,6 +40,8 @@ export function shouldTripProviderBreakerForResult( result.errorCode !== "proxy_unreachable" && result.errorCode !== "RATE_LIMIT_QUEUE_TIMEOUT" && result.errorCode !== "RATE_LIMIT_QUEUE_WEDGED" && + !isModelCapacityOverloadError(result.error) && + !isModelCapacityOverloadError(result.status) && PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status)) ); } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index b121876376..9cdd16a6e3 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -73,6 +73,7 @@ import { } from "@omniroute/open-sse/services/accountFallback.ts"; import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts"; import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts"; import { honorsRuleLockScope, isEgressBucketedLockScope, @@ -93,6 +94,7 @@ import { classifyProviderError, PROVIDER_ERROR_TYPES, } from "@omniroute/open-sse/services/errorClassifier.ts"; +import { resolveTerminalConnectionStatus } from "./authTerminalStatus.ts"; import { ALIBABA_FREE_DRAINED_LOCK_MS, getAlibabaBillingMode, @@ -325,71 +327,6 @@ function isTerminalConnectionStatusForModel( return true; } -// #8200: cookie-auth providers (perplexity-web, grok-web, ...) use a rotating browser -// session, not a static API key — a 401 means "session needs a refresh", not "dead". -function isRecoverableCookieAuth401( - provider: string | null, - providerErrorType: string | null -): boolean { - return ( - providerErrorType !== PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED && - provider != null && - resolveProviderId(provider) in WEB_COOKIE_PROVIDERS - ); -} -// #12242 (402 variant of #3027): a bare 402 on a passthrough/gateway -// provider that multiplexes many models behind one credential -// (kilo-gateway, ollama-cloud, etc.) is a PER-MODEL billing signal, not -// proof the credential itself is dead — free models on the same connection -// remain perfectly usable. Only terminalize the whole connection for a 402 -// when the provider is NOT a per-model-quota provider; the caller lets it -// fall through to the per-model lockout branch instead. -// `result.creditsExhausted` is a provider's own explicit classification -// (independent of HTTP status) and stays unconditionally terminal — it is -// not scoped by this check. -function isConnectionWideCreditsExhausted( - status: number, - result: { permanent?: boolean; creditsExhausted?: boolean }, - isPerModelQuotaProvider: boolean -): boolean { - return result.creditsExhausted || (status === 402 && !isPerModelQuotaProvider); -} -function resolveTerminalConnectionStatus( - status: number, - result: { permanent?: boolean; creditsExhausted?: boolean }, - providerErrorType: string | null = null, - provider: string | null = null, - isPerModelQuotaProvider = false -): string | null { - if (isConnectionWideCreditsExhausted(status, result, isPerModelQuotaProvider)) { - return "credits_exhausted"; - } - if ( - providerErrorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR || - providerErrorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED || - providerErrorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN || - // #1010: Cloudflare fingerprint rejection is the CDN refusing the CLIENT's - // signature, not the account's credentials — never a terminal account state. - // A different client on the same key succeeds (measured 2026-08-08: curl 200, - // urllib 403 on byte-identical body), so banning the account here would flip a - // healthy free pool to ALL_ACCOUNTS_INACTIVE after two such calls. - providerErrorType === PROVIDER_ERROR_TYPES.FINGERPRINT_REJECTION - ) { - return null; - } - if (result.permanent || providerErrorType === PROVIDER_ERROR_TYPES.FORBIDDEN) { - return "banned"; - } - if ( - (providerErrorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED || - providerErrorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED || - status === 401) && - !isRecoverableCookieAuth401(provider, providerErrorType) - ) { - return "expired"; - } - return null; -} export function resolveQuotaLimitPolicy( provider: string, providerSpecificData: JsonRecord @@ -2717,14 +2654,13 @@ export async function markAccountUnavailable( // the opt-in setting probeCanDisable restores the historical behavior. if (await shouldIsolateProbeFailures()) { await updateProviderConnection(connectionId, { - // lastError kept RAW (full text) — maximal probe visibility; the - // divergence vs the normal path's slice(0,100) is intentional. + // Persist safe wording only after classification has consumed the raw provider text. // backoffLevel is deliberately NOT written: a positive backoff // triggers the selection-time auto-decay (resetConnectionBackoff, // auth.ts getProviderCredentials) which wipes lastError back to // NULL on the next attempt — silently destroying the probe record. // The backoff is also routing state a probe must not touch (#9817). - lastError: errorText, + lastError: sanitizeErrorMessage(errorText) || "Provider request failed", lastErrorType: fallbackResult.reason || null, errorCode: status, lastErrorAt: new Date().toISOString(), @@ -3038,13 +2974,24 @@ export async function markAccountUnavailable( return { shouldFallback: true, cooldownMs: lockout.cooldownMs }; } - const terminalStatus = resolveTerminalConnectionStatus( + let terminalStatus = resolveTerminalConnectionStatus( status, result as { permanent?: boolean; creditsExhausted?: boolean }, providerErrorType, provider, - isPerModelQuotaProvider + isPerModelQuotaProvider, + errorText ); + // A still-valid access token after a successful refresh is not "expired". + // A follow-up 401 (timeout, hop, race) must cooldown, not park the account. + const tokenExpiryMs = Date.parse(String(conn?.tokenExpiresAt || conn?.expiresAt || "")); + if ( + terminalStatus === "expired" && + Number.isFinite(tokenExpiryMs) && + tokenExpiryMs > Date.now() + 60_000 + ) { + terminalStatus = null; + } const cachedQuotaResetAt = providerErrorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED || reason === RateLimitReason.QUOTA_EXHAUSTED @@ -3140,8 +3087,8 @@ export async function markAccountUnavailable( ); return { shouldFallback: true, cooldownMs: lockout.cooldownMs }; } - - const errorMsg = describeUpstreamFailure(errorText); + const errorMsg = + sanitizeErrorMessage(describeUpstreamFailure(errorText)) || "Provider request failed"; // T09: Codex per-scope lockout (do not block the whole account globally). if ( diff --git a/src/sse/services/authTerminalStatus.ts b/src/sse/services/authTerminalStatus.ts new file mode 100644 index 0000000000..b8afed537b --- /dev/null +++ b/src/sse/services/authTerminalStatus.ts @@ -0,0 +1,93 @@ +import { PROVIDER_ERROR_TYPES } from "@omniroute/open-sse/services/errorClassifier.ts"; +import { isCreditsExhausted } from "@omniroute/open-sse/services/accountFallback.ts"; +import { resolveProviderId, WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; + +// #8200: cookie-auth providers (perplexity-web, grok-web, ...) use a rotating browser +// session, not a static API key — a 401 means "session needs a refresh", not "dead". +export function isRecoverableCookieAuth401( + provider: string | null, + providerErrorType: string | null +): boolean { + return ( + providerErrorType !== PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED && + provider != null && + resolveProviderId(provider) in WEB_COOKIE_PROVIDERS + ); +} +// #12242 (402 variant of #3027): a bare 402 on a passthrough/gateway +// provider that multiplexes many models behind one credential +// (kilo-gateway, ollama-cloud, etc.) is a PER-MODEL billing signal, not +// proof the credential itself is dead — free models on the same connection +// remain perfectly usable. Only terminalize the whole connection for a 402 +// when the provider is NOT a per-model-quota provider; the caller lets it +// fall through to the per-model lockout branch instead. +// `result.creditsExhausted` is a provider's own explicit classification +// (independent of HTTP status) and stays unconditionally terminal — it is +// not scoped by this check. +export function isConnectionWideCreditsExhausted( + status: number, + result: { permanent?: boolean; creditsExhausted?: boolean }, + isPerModelQuotaProvider: boolean +): boolean { + return result.creditsExhausted || (status === 402 && !isPerModelQuotaProvider); +} + +/** Credits-depleted bodies park; renewing billing-cycle quota does not. */ +export function shouldParkCreditsExhausted( + status: number, + result: { permanent?: boolean; creditsExhausted?: boolean }, + isPerModelQuotaProvider: boolean, + errorText: string +): boolean { + return ( + isConnectionWideCreditsExhausted(status, result, isPerModelQuotaProvider) || + (!isPerModelQuotaProvider && isCreditsExhausted(errorText)) + ); +} + +function isNonTerminalProviderError(providerErrorType: string | null): boolean { + return ( + providerErrorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR || + providerErrorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED || + providerErrorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN || + // #1010: Cloudflare fingerprint rejection is the CDN refusing the CLIENT's + // signature, not the account's credentials — never a terminal account state. + providerErrorType === PROVIDER_ERROR_TYPES.FINGERPRINT_REJECTION + ); +} + +function isExpiredAuthFailure( + status: number, + providerErrorType: string | null, + provider: string | null +): boolean { + return ( + (providerErrorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED || + providerErrorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED || + status === 401) && + !isRecoverableCookieAuth401(provider, providerErrorType) + ); +} + +export function resolveTerminalConnectionStatus( + status: number, + result: { permanent?: boolean; creditsExhausted?: boolean }, + providerErrorType: string | null = null, + provider: string | null = null, + isPerModelQuotaProvider = false, + errorText: string = "" +): string | null { + if (shouldParkCreditsExhausted(status, result, isPerModelQuotaProvider, errorText)) { + return "credits_exhausted"; + } + if (isNonTerminalProviderError(providerErrorType)) { + return null; + } + if (result.permanent || providerErrorType === PROVIDER_ERROR_TYPES.FORBIDDEN) { + return "banned"; + } + if (isExpiredAuthFailure(status, providerErrorType, provider)) { + return "expired"; + } + return null; +} diff --git a/stryker.conf.json b/stryker.conf.json index 27e2825662..c8ff964e77 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -152,6 +152,7 @@ "tests/unit/circuit-breaker-registry-cap.test.ts", "tests/unit/circuit-breaker-resolved-5xx-12254.test.ts", "tests/unit/circuit-breaker-stream-controller-4602.test.ts", + "tests/unit/overloaded-not-provider-breaker.test.ts", "tests/unit/claude-code-parity.test.ts", "tests/unit/claude-effort-suffix-strip.test.ts", "tests/unit/claude-oauth-provider.test.ts", @@ -251,6 +252,7 @@ "tests/unit/executor-contract-violation-terminal.test.ts", "tests/unit/executor-devin-cli-agentic-acp.test.ts", "tests/unit/executor-web-cookie-sweep.test.ts", + "tests/unit/false-terminal-401-quota.test.ts", "tests/unit/format-provider-error-cause.test.ts", "tests/unit/forwarded-header-budget.test.ts", "tests/unit/fusion-vision-panel-3378.test.ts", diff --git a/tests/fixtures/oneminai-stream-error-boundary.fixture.ts b/tests/fixtures/oneminai-stream-error-boundary.fixture.ts new file mode 100644 index 0000000000..5af1b6be9c --- /dev/null +++ b/tests/fixtures/oneminai-stream-error-boundary.fixture.ts @@ -0,0 +1,620 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +assert.ok(process.env.DATA_DIR, "the parent harness must provide an isolated DATA_DIR"); +assert.ok( + process.env.OMNIROUTE_PLUGINS_DIR, + "the parent harness must provide an isolated OMNIROUTE_PLUGINS_DIR" +); + +const [ + { OneMinAiExecutor }, + { ensureStreamReadiness }, + dbCore, + settingsDb, + callLogs, + usageHistory, + accountSemaphore, + readCache, + { handleChatCore }, +] = await Promise.all([ + import("../../open-sse/executors/oneminai.ts"), + import("../../open-sse/utils/streamReadiness.ts"), + import("../../src/lib/db/core.ts"), + import("../../src/lib/db/settings.ts"), + import("../../src/lib/usage/callLogs.ts"), + import("../../src/lib/usage/usageHistory.ts"), + import("../../open-sse/services/accountSemaphore.ts"), + import("../../src/lib/db/readCache.ts"), + import("../../open-sse/handlers/chatCore.ts"), +]); + +const originalFetch = globalThis.fetch; +const encoder = new TextEncoder(); +const STREAM_URL = "https://api.1min.ai/api/chat-with-ai?isStreaming=true"; + +type PersistenceIdentity = { + model: string; + connectionId: string; +}; + +const PRE_CONTENT_IDENTITY: PersistenceIdentity = { + model: "gpt-4o-mini-onemin-pre-content-boundary", + connectionId: "onemin-stream-pre-content-boundary", +}; +const BATCHED_IDENTITY: PersistenceIdentity = { + model: "gpt-4o-mini-onemin-batched-boundary", + connectionId: "onemin-stream-batched-boundary", +}; +const PARTIAL_IDENTITY: PersistenceIdentity = { + model: "gpt-4o-mini-onemin-partial-boundary", + connectionId: "onemin-stream-partial-boundary", +}; + +function installFetchFactory(responseFactory: () => Response): () => number { + let calls = 0; + globalThis.fetch = async (input, init = {}) => { + calls += 1; + assert.equal(String(input), STREAM_URL, "the test must never permit another network target"); + assert.equal(init.method, "POST"); + assert.equal((init.headers as Record)["API-KEY"], "unit-test-key"); + + return responseFactory(); + }; + return () => calls; +} + +function createStreamingResponse(events: string[]): Response { + return new Response( + new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(encoder.encode(event)); + controller.close(); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); +} + +function installStreamingFetch(events: string[]): () => number { + return installFetchFactory(() => createStreamingResponse(events)); +} + +async function executeStreaming(events: string[]): Promise { + const getCalls = installStreamingFetch(events); + const result = await new OneMinAiExecutor().execute({ + model: "gpt-4o-mini", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { apiKey: "unit-test-key" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.equal(getCalls(), 1); + return result.response; +} + +function noopLog() { + return { debug() {}, info() {}, warn() {}, error() {} }; +} + +async function invokeStreamingChatCore( + identity: PersistenceIdentity, + onStreamFailure?: (failure: { + status: number; + message: string; + code?: string; + type?: string; + }) => void, + onRequestSuccess?: () => Promise | void +) { + await settingsDb.updateSettings({ call_log_pipeline_enabled: true }); + readCache.invalidateDbCache("settings"); + const body = { + model: identity.model, + stream: true, + messages: [{ role: "user", content: "hello" }], + }; + + return handleChatCore({ + body: structuredClone(body), + modelInfo: { provider: "oneminai", model: identity.model, extendedContext: false }, + credentials: { + apiKey: "unit-test-key", + connectionId: identity.connectionId, + providerSpecificData: {}, + }, + connectionId: identity.connectionId, + log: noopLog(), + clientRawRequest: { + endpoint: "/v1/chat/completions", + body: structuredClone(body), + headers: new Headers({ + accept: "text/event-stream", + "x-omniroute-session-id": identity.connectionId, + }), + }, + userAgent: identity.connectionId, + onRequestSuccess, + onStreamFailure, + } as never); +} + +async function waitFor(read: () => Promise, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await read(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return null; +} + +async function getOneMinCallLog(identity: PersistenceIdentity) { + assert.equal( + await callLogs.waitForCallLogSaves(5_000), + true, + "call-log persistence must drain before inspection" + ); + const rows = await callLogs.getCallLogs({ + provider: "oneminai", + model: identity.model, + limit: 20, + }); + const row = Array.isArray(rows) + ? rows.find( + (candidate) => + candidate.connectionId === identity.connectionId && + (candidate.model === identity.model || candidate.requestedModel === identity.model) + ) + : null; + return row ? callLogs.getCallLogById(row.id) : null; +} + +async function getOneMinUsage(identity: PersistenceIdentity) { + const rows = await usageHistory.getUsageHistory({ + provider: "oneminai", + model: identity.model, + }); + return rows.find((row) => row.connectionId === identity.connectionId) ?? null; +} + +async function assertUnusedPersistenceIdentity(identity: PersistenceIdentity) { + assert.equal( + await getOneMinCallLog(identity), + null, + `call-log identity must be unused before scenario: ${identity.connectionId}` + ); + assert.equal( + await getOneMinUsage(identity), + null, + `usage identity must be unused before scenario: ${identity.connectionId}` + ); +} + +async function readUntil( + reader: ReadableStreamDefaultReader, + marker: string +): Promise { + const decoder = new TextDecoder(); + let text = ""; + while (!text.includes(marker)) { + const { done, value } = await reader.read(); + assert.equal(done, false, `stream ended before ${marker}`); + if (value) text += decoder.decode(value, { stream: true }); + } + return text; +} + +async function readRemaining(reader: ReadableStreamDefaultReader): Promise { + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) return text + decoder.decode(); + if (value) text += decoder.decode(value, { stream: true }); + } +} + +test.afterEach(async () => { + const drained = await callLogs.waitForCallLogSaves(5_000); + globalThis.fetch = originalFetch; + usageHistory.clearPendingRequests(); + accountSemaphore.resetAll(); + assert.equal(drained, true, "all call-log saves must drain before the next test"); +}); + +test.after(async () => { + const drained = await callLogs.waitForCallLogSaves(5_000); + try { + await callLogs.closeCallLogSaves(5_000); + } finally { + globalThis.fetch = originalFetch; + usageHistory.clearPendingRequests(); + accountSemaphore.resetAll(); + dbCore.resetDbInstance(); + } + assert.equal(drained, true, "all call-log saves must drain before teardown"); +}); + +test("1min.ai pre-content stream errors stay errors and permit readiness fallback", async () => { + const rawMessage = + "quota lookup failed at /srv/omniroute/open-sse/executors/oneminai.ts:170\n" + + " at translateSseStream (/srv/omniroute/open-sse/executors/oneminai.ts:99:5)"; + const response = await executeStreaming([ + `event: error\ndata: ${JSON.stringify({ error: { message: rawMessage } })}\n\n`, + ]); + const clientCopy = response.clone(); + + const readiness = await ensureStreamReadiness(response, { + timeoutMs: 2_000, + provider: "oneminai", + model: "gpt-4o-mini", + }); + assert.equal(readiness.ok, false); + if (readiness.ok) assert.fail("an error-only stream must not become ready"); + assert.equal(readiness.response.status, 502); + const fallbackBody = await readiness.response.text(); + assert.match(fallbackBody, /STREAM_EARLY_EOF/); + assert.doesNotMatch(fallbackBody, /\/srv\/omniroute/); + assert.doesNotMatch(fallbackBody, /translateSseStream/); + + const clientText = await clientCopy.text(); + assert.match(clientText, /^data: \{"error":/); + assert.match(clientText, /quota lookup failed at /); + assert.match(clientText, /data: \[DONE\]/); + assert.doesNotMatch(clientText, /"role":"assistant"/); + assert.doesNotMatch(clientText, /"finish_reason":"stop"/); + assert.doesNotMatch(clientText, /\/srv\/omniroute/); + assert.doesNotMatch(clientText, /translateSseStream/); +}); + +test("chatCore turns a pre-content 1min.ai stream error into persisted HTTP 502", async () => { + await assertUnusedPersistenceIdentity(PRE_CONTENT_IDENTITY); + installStreamingFetch([ + `event: error\ndata: ${JSON.stringify({ + error: { + message: + "quota lookup failed at /srv/omniroute/open-sse/executors/oneminai.ts:230 api_key=pre-content-secret\nstack tail", + }, + })}\n\n`, + ]); + + const result = await invokeStreamingChatCore(PRE_CONTENT_IDENTITY); + assert.equal(result.success, false); + if (result.success) assert.fail("a pre-content error must not commit HTTP 200"); + assert.equal(result.status, 502); + assert.equal(result.response.status, 502); + const clientBody = await result.response.text(); + assert.match(clientBody, /STREAM_EARLY_EOF/); + assert.doesNotMatch(clientBody, /pre-content-secret/); + assert.doesNotMatch(clientBody, /\/srv\/omniroute/); + assert.doesNotMatch(clientBody, /stack tail/); + + const detail = await waitFor(() => getOneMinCallLog(PRE_CONTENT_IDENTITY)); + assert.ok(detail, "the failed pre-content attempt must be persisted"); + assert.equal(detail.status, 502); + const persisted = JSON.stringify(detail); + assert.doesNotMatch(persisted, /pre-content-secret/); + assert.doesNotMatch(persisted, /\/srv\/omniroute/); + assert.doesNotMatch(persisted, /stack tail/); + + const usage = await waitFor(() => getOneMinUsage(PRE_CONTENT_IDENTITY)); + assert.ok(usage, "the failed pre-content usage record must be persisted"); + assert.equal(usage.success, false); + assert.equal(usage.status, "502"); + assert.equal(usage.errorCode, "STREAM_EARLY_EOF"); +}); + +test("chatCore preserves batched 1min.ai content before its terminal stream error", async () => { + await assertUnusedPersistenceIdentity(BATCHED_IDENTITY); + installStreamingFetch([ + 'event: content\ndata: {"content":"batched partial one"}\n\n' + + 'event: content\ndata: {"content":"batched partial two"}\n\n' + + `event: error\ndata: ${JSON.stringify({ + message: + "provider failed at /srv/omniroute/open-sse/executors/oneminai.ts:230 api_key=batched-secret", + })}\n\n`, + ]); + const failures: Array<{ + status: number; + message: string; + code?: string; + type?: string; + }> = []; + const requestSuccessPhases: string[] = []; + + const result = await invokeStreamingChatCore( + BATCHED_IDENTITY, + (failure) => failures.push(failure), + async () => { + requestSuccessPhases.push("started"); + await new Promise((resolve) => setTimeout(resolve, 30)); + requestSuccessPhases.push("finished"); + } + ); + assert.equal(result.success, true, "batched real content must cross the readiness boundary"); + assert.deepEqual(requestSuccessPhases, ["started", "finished"]); + assert.ok(result.response.body); + const clientText = await result.response.text(); + const firstContentIndex = clientText.indexOf("batched partial one"); + const secondContentIndex = clientText.indexOf("batched partial two"); + const errorIndex = clientText.indexOf('"error":'); + const doneIndex = clientText.indexOf("data: [DONE]"); + + assert.ok(firstContentIndex >= 0, "the first queued content delta must not be discarded"); + assert.ok(secondContentIndex >= 0, "the second queued content delta must not be discarded"); + assert.ok(firstContentIndex < secondContentIndex, "batched content must retain upstream order"); + assert.ok(secondContentIndex < errorIndex, "all batched content must precede its terminal error"); + assert.ok( + errorIndex < doneIndex, + `the terminal error must precede [DONE]: ${JSON.stringify(clientText)}` + ); + assert.match(clientText, /"finish_reason":"error"/); + assert.doesNotMatch(clientText, /"finish_reason":"stop"/); + assert.doesNotMatch(clientText, /response\.failed/); + assert.doesNotMatch(clientText, /batched-secret/); + assert.doesNotMatch(clientText, /\/srv\/omniroute/); + assert.deepEqual(failures, [ + { + status: 502, + message: "1min.ai upstream stream failed", + code: "stream_pipeline_error", + type: "stream_error", + }, + ]); + + const pending = usageHistory.getPendingRequests(); + assert.deepEqual(Object.keys(pending.byModel), []); + assert.deepEqual(Object.keys(pending.byAccount), []); + + const completed = [...usageHistory.getCompletedDetails().values()]; + assert.equal(completed.length, 1); + assert.equal(completed[0].status, 502); + assert.equal(completed[0].error, "1min.ai upstream stream failed"); + assert.equal(completed[0].errorCode, "stream_pipeline_error"); + + const detail = await waitFor(() => getOneMinCallLog(BATCHED_IDENTITY)); + assert.ok(detail, "the batched terminal stream failure must be persisted"); + assert.equal(detail.status, 502); + assert.equal(detail.error, "1min.ai upstream stream failed"); + const persisted = JSON.stringify(detail); + assert.doesNotMatch(persisted, /batched-secret/); + assert.doesNotMatch(persisted, /\/srv\/omniroute/); + + const usage = await waitFor(() => getOneMinUsage(BATCHED_IDENTITY)); + assert.ok(usage, "the batched terminal failure usage record must be persisted"); + assert.equal(usage.success, false); + assert.equal(usage.status, "502"); + assert.equal(usage.errorCode, "stream_pipeline_error"); +}); + +test("chatCore preserves partial 1min.ai content then finalizes and persists a stream failure", async () => { + await assertUnusedPersistenceIdentity(PARTIAL_IDENTITY); + let upstreamController: ReadableStreamDefaultController | null = null; + let cancelCalls = 0; + const getCalls = installFetchFactory( + () => + new Response( + new ReadableStream({ + start(controller) { + upstreamController = controller; + controller.enqueue( + encoder.encode('event: content\ndata: {"content":"partial answer"}\n\n') + ); + }, + cancel() { + cancelCalls += 1; + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ) + ); + const failures: Array<{ + status: number; + message: string; + code?: string; + type?: string; + }> = []; + + const result = await invokeStreamingChatCore(PARTIAL_IDENTITY, (failure) => + failures.push(failure) + ); + assert.equal(getCalls(), 1); + assert.equal(result.success, true, "real content must cross the readiness boundary"); + assert.ok(result.response.body); + const reader = result.response.body.getReader(); + let clientText = await readUntil(reader, "partial answer"); + + assert.ok(upstreamController); + upstreamController.enqueue( + encoder.encode( + `event: error\ndata: ${JSON.stringify({ + message: + "provider failed at /srv/omniroute/open-sse/executors/oneminai.ts:230 api_key=post-content-secret\nstack tail", + })}\n\n` + ) + ); + clientText += await readRemaining(reader); + + const roleIndex = clientText.indexOf('"role":"assistant"'); + const contentIndex = clientText.indexOf("partial answer"); + const errorIndex = clientText.indexOf('"error":'); + const doneIndex = clientText.indexOf("data: [DONE]"); + + assert.ok(roleIndex >= 0 && roleIndex < contentIndex, "the role must precede real content"); + assert.ok(contentIndex < errorIndex, "partial content must remain before the terminal error"); + assert.ok(errorIndex < doneIndex, "the pipeline error must precede [DONE]"); + assert.equal(clientText.match(/"role":"assistant"/g)?.length, 1); + assert.match(clientText, /"finish_reason":"error"/); + assert.match(clientText, /1min\.ai upstream stream failed/); + assert.doesNotMatch(clientText, /"finish_reason":"stop"/); + assert.doesNotMatch(clientText, /response\.failed/); + assert.doesNotMatch(clientText, /post-content-secret/); + assert.doesNotMatch(clientText, /\/srv\/omniroute/); + assert.doesNotMatch(clientText, /stack tail/); + + assert.equal(cancelCalls, 1, "the upstream source must be cancelled after its terminal error"); + assert.equal(failures.length, 1); + assert.deepEqual(failures[0], { + status: 502, + message: "1min.ai upstream stream failed", + code: "stream_pipeline_error", + type: "stream_error", + }); + const pending = usageHistory.getPendingRequests(); + assert.deepEqual(Object.keys(pending.byModel), []); + assert.deepEqual(Object.keys(pending.byAccount), []); + + const completed = [...usageHistory.getCompletedDetails().values()]; + assert.equal(completed.length, 1); + assert.equal(completed[0].status, 502); + assert.equal(completed[0].error, "1min.ai upstream stream failed"); + assert.equal(completed[0].errorCode, "stream_pipeline_error"); + + const detail = await waitFor(() => getOneMinCallLog(PARTIAL_IDENTITY)); + assert.ok(detail, "the post-content stream failure must be persisted"); + assert.equal(detail.status, 502); + assert.equal(detail.error, "1min.ai upstream stream failed"); + const persisted = JSON.stringify(detail); + assert.match(persisted, /1min\.ai upstream stream failed/); + assert.doesNotMatch(persisted, /post-content-secret/); + assert.doesNotMatch(persisted, /\/srv\/omniroute/); + assert.doesNotMatch(persisted, /stack tail/); + + const usage = await waitFor(() => getOneMinUsage(PARTIAL_IDENTITY)); + assert.ok(usage, "the post-content failure usage record must be persisted"); + assert.equal(usage.success, false); + assert.equal(usage.status, "502"); + assert.equal(usage.errorCode, "stream_pipeline_error"); +}); + +test("1min.ai error completion does not wait for an upstream cancel promise", async () => { + let cancelCalls = 0; + const getCalls = installFetchFactory( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode('event: error\ndata: {"message":"capacity unavailable"}\n\n') + ); + }, + cancel() { + cancelCalls += 1; + return new Promise(() => {}); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ) + ); + + const result = await new OneMinAiExecutor().execute({ + model: "gpt-4o-mini", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { apiKey: "unit-test-key" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + const clientText = await Promise.race([ + result.response.text(), + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("translated stream stayed pending on cancel")), 500) + ), + ]); + + assert.equal(getCalls(), 1); + assert.equal(cancelCalls, 1); + assert.match(clientText, /capacity unavailable/); + assert.match(clientText, /data: \[DONE\]/); +}); + +test("1min.ai propagates downstream cancellation without awaiting upstream cleanup", async () => { + let upstreamController: ReadableStreamDefaultController | null = null; + let cancelCalls = 0; + let markPullStarted: (() => void) | null = null; + const pullStarted = new Promise((resolve) => { + markPullStarted = resolve; + }); + const getCalls = installFetchFactory( + () => + new Response( + new ReadableStream({ + start(controller) { + upstreamController = controller; + controller.enqueue( + encoder.encode('event: content\ndata: {"content":"partial answer"}\n\n') + ); + }, + pull() { + markPullStarted?.(); + return new Promise(() => {}); + }, + cancel() { + cancelCalls += 1; + return new Promise(() => {}); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ) + ); + + const result = await new OneMinAiExecutor().execute({ + model: "gpt-4o-mini", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { apiKey: "unit-test-key" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + assert.ok(result.response.body); + const reader = result.response.body.getReader(); + + try { + const clientText = await readUntil(reader, "partial answer"); + assert.match(clientText, /"role":"assistant"/); + await pullStarted; + await Promise.race([ + reader.cancel("client disconnected"), + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("downstream cancellation stayed pending")), 500) + ), + ]); + + assert.equal(getCalls(), 1); + assert.equal(cancelCalls, 1, "downstream cancellation must reach the upstream reader once"); + assert.deepEqual(await reader.read(), { value: undefined, done: true }); + } finally { + try { + upstreamController?.close(); + } catch { + // The fixed path has already cancelled and closed the upstream stream. + } + } +}); + +test("1min.ai accepts the bounded error-string shape without exposing a success chunk", async () => { + const response = await executeStreaming([ + 'event: error\ndata: {"error":"billing temporarily unavailable"}\n\n', + ]); + const clientText = await response.text(); + + assert.match(clientText, /"error":\{"message":"billing temporarily unavailable"/); + assert.doesNotMatch(clientText, /"role":"assistant"/); + assert.doesNotMatch(clientText, /"finish_reason":"stop"/); +}); + +test("1min.ai replaces oversized stream-error payloads with a fixed public fallback", async () => { + const oversizedMessage = `private-prefix-${"x".repeat(70 * 1024)}`; + const response = await executeStreaming([ + `event: error\ndata: ${JSON.stringify({ message: oversizedMessage })}\n\n`, + ]); + const clientText = await response.text(); + + assert.match(clientText, /1min\.ai upstream stream failed/); + assert.ok(clientText.length < 1_024, "the oversized upstream payload must not be reflected"); + assert.doesNotMatch(clientText, /private-prefix/); + assert.doesNotMatch(clientText, /"role":"assistant"/); + assert.doesNotMatch(clientText, /"finish_reason":"stop"/); +}); diff --git a/tests/integration/api-keys.test.ts b/tests/integration/api-keys.test.ts index 4e82c15874..b394eee5da 100644 --- a/tests/integration/api-keys.test.ts +++ b/tests/integration/api-keys.test.ts @@ -12,6 +12,8 @@ process.env.CLOUD_URL = "http://cloud.example"; const core = await import("../../src/lib/db/core.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const apiKeyPolicy = await import("../../src/shared/utils/apiKeyPolicy.ts"); const { updateSettings } = await import("@/lib/db/settings"); const localDb = { updateSettings }; const compliance = await import("../../src/lib/compliance/index.ts"); @@ -134,6 +136,74 @@ test("POST /api/keys creates a key, preserves special characters, and persists n assert.equal(compliance.isNoLog(body.id), true); }); +test("POST /api/keys preserves creation-time ACL and enforces it (#12275)", async () => { + await enableManagementAuth(); + await createManagementKey(); + + const response = await listRoute.POST( + await makeManagementSessionRequest("http://localhost/api/keys", { + method: "POST", + body: { + name: "Restricted Create", + modelAccessMode: "restricted", + allowedModels: ["openai/gpt-4.1-mini"], + allowedCombos: ["focused-chat"], + }, + }) + ); + const body = (await response.json()) as { + id: string; + key: string; + modelAccessMode: string; + allowedModels: string[]; + allowedCombos: string[]; + }; + const stored = await apiKeysDb.getApiKeyById(body.id); + + await combosDb.createCombo({ + name: "focused-chat", + strategy: "priority", + config: { maxRetries: 0, retryDelayMs: 0 }, + models: ["openai/gpt-4.1-mini"], + }); + await combosDb.createCombo({ + name: "blocked-chat", + strategy: "priority", + config: { maxRetries: 0, retryDelayMs: 0 }, + models: ["anthropic/claude-sonnet-4-5"], + }); + + assert.equal(response.status, 201); + assert.equal(body.modelAccessMode, "restricted"); + assert.deepEqual(body.allowedModels, ["openai/gpt-4.1-mini"]); + assert.deepEqual(body.allowedCombos, ["focused-chat"]); + assert.equal(stored?.modelAccessMode, "restricted"); + assert.deepEqual(stored?.allowedModels, ["openai/gpt-4.1-mini"]); + assert.deepEqual(stored?.allowedCombos, ["focused-chat"]); + + const allowed = await apiKeyPolicy.enforceApiKeyPolicy( + makeRequest("http://localhost/api/v1/chat/completions", { token: body.key }), + "openai/gpt-4.1-mini" + ); + const denied = await apiKeyPolicy.enforceApiKeyPolicy( + makeRequest("http://localhost/api/v1/chat/completions", { token: body.key }), + "anthropic/claude-sonnet-4-5" + ); + const allowedCombo = await apiKeyPolicy.enforceApiKeyPolicy( + makeRequest("http://localhost/api/v1/chat/completions", { token: body.key }), + "focused-chat" + ); + const deniedCombo = await apiKeyPolicy.enforceApiKeyPolicy( + makeRequest("http://localhost/api/v1/chat/completions", { token: body.key }), + "blocked-chat" + ); + + assert.equal(allowed.rejection, null); + assert.equal(denied.rejection?.status, 403); + assert.equal(allowedCombo.rejection, null); + assert.equal(deniedCombo.rejection?.status, 403); +}); + test("POST /api/keys validates missing and oversized names", async () => { await enableManagementAuth(); await createManagementKey(); diff --git a/tests/integration/monitoring-health-cache.test.ts b/tests/integration/monitoring-health-cache.test.ts index 3df1d63b10..2b38924ff1 100644 --- a/tests/integration/monitoring-health-cache.test.ts +++ b/tests/integration/monitoring-health-cache.test.ts @@ -1,12 +1,12 @@ /** * Integration test for the short-TTL cache on GET /api/monitoring/health. * - * Health is a frequently-polled endpoint; rebuilding it every request (DB reads - * + status aggregation across subsystems) is wasteful under rapid polling. The - * route caches the payload for HEALTH_PAYLOAD_TTL_MS (1s) and invalidates it on - * DELETE (circuit-breaker reset). We assert the behavior via the payload's - * `timestamp` field, which is stamped at build time: identical timestamp ⇒ the - * cached payload was served; a fresh timestamp ⇒ it was rebuilt. + * Health is a frequently-polled endpoint; rebuilding it on the request path + * (DB reads + status aggregation) starves GET /healthz (#12532). The route + * caches the payload for HEALTH_PAYLOAD_TTL_MS (1s). After the first fill, + * expired entries are served immediately (stale-while-revalidate) and + * refreshed off the request path. DELETE (circuit-breaker reset) invalidates + * the cache so the next GET rebuilds. We assert via `timestamp`. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -20,7 +20,8 @@ process.env.REQUIRE_API_KEY = "false"; process.env.JWT_SECRET = "test-health-cache-secret"; await import("../../src/lib/db/core.ts"); -const { GET, DELETE } = await import("../../src/app/api/monitoring/health/route.ts"); +const { GET, DELETE, __test_resetMonitoringHealthPayloadCache } = + await import("../../src/app/api/monitoring/health/route.ts"); // GHSA-mvf8-qc78-5mxm: the detailed health payload (the one carrying `timestamp`) // is reserved for a management principal — GET now takes the Request and an @@ -52,19 +53,22 @@ async function healthTimestamp(): Promise { } test("GET within the TTL serves the cached payload (identical timestamp)", async () => { + __test_resetMonitoringHealthPayloadCache(); const t1 = await healthTimestamp(); const t2 = await healthTimestamp(); assert.equal(t2, t1, "a second GET within the TTL must return the cached payload"); }); -test("cache expires after the TTL — a fresh payload is built", async () => { +test("expired cache is served immediately (stale-while-revalidate)", async () => { + __test_resetMonitoringHealthPayloadCache(); const t1 = await healthTimestamp(); await new Promise((r) => setTimeout(r, 1100)); // TTL is 1000ms const t2 = await healthTimestamp(); - assert.notEqual(t2, t1, "after the 1s TTL the payload must be rebuilt"); + assert.equal(t2, t1, "after the 1s TTL the stale cached payload must be returned immediately"); }); test("DELETE (circuit-breaker reset) invalidates the cache immediately", async () => { + __test_resetMonitoringHealthPayloadCache(); const t1 = await healthTimestamp(); // populate cache const delRes = await DELETE(authedRequest("DELETE")); assert.ok(delRes.status < 400, `DELETE should succeed, got ${delRes.status}`); diff --git a/tests/unit/11024-n-instance-scale-out-docs.test.ts b/tests/unit/11024-n-instance-scale-out-docs.test.ts index 0189324e94..df209680c3 100644 --- a/tests/unit/11024-n-instance-scale-out-docs.test.ts +++ b/tests/unit/11024-n-instance-scale-out-docs.test.ts @@ -2,8 +2,14 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -const dockerGuide = readFileSync(new URL("../../docs/guides/DOCKER_GUIDE.md", import.meta.url), "utf8"); -const envDoc = readFileSync(new URL("../../docs/reference/ENVIRONMENT.md", import.meta.url), "utf8"); +const dockerGuide = readFileSync( + new URL("../../docs/guides/DOCKER_GUIDE.md", import.meta.url), + "utf8" +); +const envDoc = readFileSync( + new URL("../../docs/reference/ENVIRONMENT.md", import.meta.url), + "utf8" +); test("DOCKER_GUIDE documents N independent DATA_DIRs as the large-job scale-out (#11024)", () => { assert.match(dockerGuide, /## Scale-out: N independent processes/); @@ -19,8 +25,39 @@ test("DOCKER_GUIDE documents N independent DATA_DIRs as the large-job scale-out }); test("ENVIRONMENT.md points CHAT_MAX_HEAVY_IN_FLIGHT at per-process V8, not host RAM (#11024)", () => { - const row = envDoc.split("\n").find((line) => line.includes("`OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT`")); + const row = envDoc + .split("\n") + .find((line) => line.includes("`OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT`")); assert.ok(row); assert.match(row, /one process|per process|V8/i); assert.match(row, /DATA_DIR|#11024/); }); + +test("DOCKER_GUIDE documents the one-process long /v1/responses recipe (healthy-headroom, not max 2)", () => { + assert.match(dockerGuide, /One-process: more than two long/); + assert.match(dockerGuide, /tryAcquireHealthyHeadroom/); + assert.match(dockerGuide, /OMNIROUTE_CHAT_LARGE_BODY_BYTES/); + assert.match(dockerGuide, /40–50|40-50/); + assert.match(dockerGuide, /memory-budget/); + assert.match(dockerGuide, /#10110|#10437/); + assert.match(dockerGuide, /replicas > 1/); +}); + +test("ENVIRONMENT.md documents LARGE_BODY_BYTES healthy-headroom and no hard max-2", () => { + const large = envDoc + .split("\n") + .find((line) => line.startsWith("| `OMNIROUTE_CHAT_LARGE_BODY_BYTES`")); + const heavy = envDoc + .split("\n") + .find((line) => line.startsWith("| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT`")); + const headroom = envDoc + .split("\n") + .find((line) => line.startsWith("| `OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM`")); + assert.ok(large); + assert.ok(heavy); + assert.ok(headroom); + assert.match(large, /healthy-headroom|#10437/); + assert.match(large, /#10110|#7849/); + assert.match(heavy, /memory-budget|not a hard product max|not a hard “max 2”/i); + assert.match(headroom, /BYTE|admitChatRequest/); +}); diff --git a/tests/unit/12441-embeddings-credits-402.test.ts b/tests/unit/12441-embeddings-credits-402.test.ts new file mode 100644 index 0000000000..b3eed15ec4 --- /dev/null +++ b/tests/unit/12441-embeddings-credits-402.test.ts @@ -0,0 +1,41 @@ +/** + * #12441 — embeddings credential exhaustion with credits_exhausted must + * surface HTTP 402, matching handleNoCredentials. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-embed-credits-402-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "embed-credits-402-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("createEmbeddingResponse maps allExpired+credits_exhausted to HTTP 402", async () => { + await providersDb.createProviderConnection({ + provider: "mistral", + authType: "apikey", + apiKey: "mistral-exhausted-key", + isActive: true, + testStatus: "credits_exhausted", + }); + + const res = await createEmbeddingResponse({ + model: "mistral/mistral-embed", + input: "hello", + }); + const body = (await res.json()) as { error?: { message?: string } }; + + assert.equal(res.status, 402); + assert.match(String(body.error?.message || ""), /credits exhausted/i); +}); diff --git a/tests/unit/12441-quota-not-auth-skip.test.ts b/tests/unit/12441-quota-not-auth-skip.test.ts new file mode 100644 index 0000000000..c3b976fa35 --- /dev/null +++ b/tests/unit/12441-quota-not-auth-skip.test.ts @@ -0,0 +1,174 @@ +/** + * #12441 — credits-exhausted / quota bodies restated as HTTP 401 must not + * take the combo AUTH_LEVEL skip path (#8133 authentication expired). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + applyComboTargetExhaustion, + isQuotaOrCreditsError, + type ComboExhaustionSets, +} from "../../open-sse/services/combo/targetExhaustion.ts"; + +function emptySets(): ComboExhaustionSets { + return { + exhaustedProviders: new Set(), + exhaustedConnections: new Set(), + transientRateLimitedProviders: new Set(), + }; +} + +const log = { info() {}, warn() {}, error() {}, debug() {} }; + +function chutesTarget() { + return { + kind: "model", + executionKey: "ek", + modelStr: "chutes/moonshotai/Kimi-K3-TEE", + provider: "chutes", + providerId: null, + connectionId: "conn-chutes-1", + } as Parameters[0]; +} + +test("#12441 isQuotaOrCreditsError detects credits-exhausted 401 bodies", () => { + assert.equal( + isQuotaOrCreditsError( + "[chutes] All 3 connection(s) credits exhausted — please reconnect in the dashboard" + ), + true + ); + assert.equal( + isQuotaOrCreditsError( + "[claude] All 1 connection(s) authentication expired — please reconnect in the dashboard" + ), + false + ); +}); + +test("#12441 isQuotaOrCreditsError still matches quota text when structuredError.code is non-quota", () => { + assert.equal( + isQuotaOrCreditsError("generic upstream failure", { + code: "invalid_request", + type: "api_error", + message: "You've reached your usage limit for this billing cycle", + }), + true + ); + assert.equal( + isQuotaOrCreditsError( + "[chutes] All 3 connection(s) credits exhausted — please reconnect in the dashboard", + { code: "unauthorized", type: "auth_error" } + ), + true + ); + assert.equal( + isQuotaOrCreditsError("generic upstream failure", { + code: "unauthorized", + message: "authentication expired", + }), + false + ); +}); + +test("#12441 credits-exhausted HTTP 401 does not mark auth-level connection skip", () => { + const s = emptySets(); + const exhausted = applyComboTargetExhaustion(chutesTarget(), { + result: { status: 401 }, + fallbackResult: {}, + errorText: "[chutes] All 3 connection(s) credits exhausted — please reconnect in the dashboard", + rawModel: "moonshotai/Kimi-K3-TEE", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + requestScopedFailure: false, + sets: s, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + }); + assert.equal(exhausted, false); + assert.equal(s.exhaustedConnections.has("chutes:conn-chutes-1"), false); + assert.equal(s.exhaustedProviders.has("chutes"), false); +}); + +test("#12441 structuredError quota message with a non-quota code does not auth-skip", () => { + const s = emptySets(); + const exhausted = applyComboTargetExhaustion(chutesTarget(), { + result: { status: 401 }, + fallbackResult: {}, + errorText: "generic upstream failure", + rawModel: "moonshotai/Kimi-K3-TEE", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + requestScopedFailure: false, + sets: s, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + structuredError: { + code: "invalid_request", + message: "You've reached your usage limit for this billing cycle", + }, + }); + assert.equal(exhausted, false); + assert.equal(s.exhaustedConnections.has("chutes:conn-chutes-1"), false); +}); + +test("#12441 real authentication expired 401 still marks the connection", () => { + const s = emptySets(); + const exhausted = applyComboTargetExhaustion(chutesTarget(), { + result: { status: 401 }, + fallbackResult: {}, + errorText: + "[claude] All 1 connection(s) authentication expired — please reconnect in the dashboard", + rawModel: "claude-opus-4-8", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + requestScopedFailure: false, + sets: s, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + }); + assert.equal(exhausted, true); + assert.equal(s.exhaustedConnections.has("chutes:conn-chutes-1"), true); +}); + +function quotaTarget() { + return { + kind: "model", + executionKey: "ek", + modelStr: "test-dedup-provider/m1", + provider: "test-dedup-provider", + providerId: null, + connectionId: "conn-1", + } as Parameters[0]; +} + +test("#12441 credits-exhausted 401 on a non-passthrough provider takes quota skip (#1731) not auth skip (#8133)", () => { + const s = emptySets(); + const exhausted = applyComboTargetExhaustion(quotaTarget(), { + result: { status: 401 }, + fallbackResult: {}, + errorText: "[chutes] All 3 connection(s) credits exhausted — please reconnect in the dashboard", + rawModel: "m1", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + requestScopedFailure: false, + sets: s, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + }); + assert.equal(exhausted, true); + assert.equal( + s.exhaustedConnections.has("test-dedup-provider:conn-1"), + false, + "must not take the #8133 auth-level connection skip" + ); + assert.equal( + s.exhaustedProviders.has("test-dedup-provider"), + true, + "quota body restated as 401 must still follow #1731 provider skip" + ); +}); diff --git a/tests/unit/12627-catalog-inflight-timeout.test.ts b/tests/unit/12627-catalog-inflight-timeout.test.ts new file mode 100644 index 0000000000..62abeb4282 --- /dev/null +++ b/tests/unit/12627-catalog-inflight-timeout.test.ts @@ -0,0 +1,61 @@ +/** + * #12627 — a hung coalesced catalog rebuild must not pin later GET /v1/models clients. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-12627-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts"); + +function request() { + return new Request("http://localhost/v1/models"); +} + +function payload(body: string): catalogCache.CatalogPayload { + return { body, headers: { "content-type": "application/json" }, status: 200, cacheTTL: 60_000 }; +} + +const neverResolves = () => new Promise(() => {}); + +test.beforeEach(() => { + catalogCache.__resetCatalogBuilderRunsForTest(); + process.env.CATALOG_BUILD_TIMEOUT_MS = "40"; +}); + +test.afterEach(() => { + delete process.env.CATALOG_BUILD_TIMEOUT_MS; +}); + +test("#12627 cold hung rebuild times out instead of waiting forever", async () => { + await assert.rejects( + catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: {}, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise + ), + /catalog_build_timeout/ + ); +}); + +test("#12627 timeout serves last-good 200 when a prior build succeeded", async () => { + const first = await catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: {}, diagnosticHeaders: {} }, + async () => payload("good") + ); + assert.equal(await first.text(), "good"); + catalogCache.__expireCatalogCacheForTest(60_000); + + const second = await catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: {}, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise + ); + assert.equal(second.status, 200); + assert.equal(await second.text(), "good"); + assert.equal(second.headers.get("x-omniroute-catalog"), "last-good"); +}); diff --git a/tests/unit/autoCombo/strict-zero-cost-filter.test.ts b/tests/unit/autoCombo/strict-zero-cost-filter.test.ts index 59ab8c226c..6966ec6f94 100644 --- a/tests/unit/autoCombo/strict-zero-cost-filter.test.ts +++ b/tests/unit/autoCombo/strict-zero-cost-filter.test.ts @@ -50,7 +50,8 @@ const KEYLESS = { }; // A real quota-based entry with hardStopGuaranteed: true (added by this feature), // as a concrete single-connection candidate. -const QUOTA_SAFE = { provider: "groq", model: "llama-3.3-70b-versatile", connectionId: REAL_CONN }; +// 2026-09-02: was groq/llama-3.3-70b-versatile, retired from the Groq free tier on 2026-08-16. +const QUOTA_SAFE = { provider: "groq", model: "openai/gpt-oss-120b", connectionId: REAL_CONN }; // A real quota-based entry WITHOUT hardStopGuaranteed (agentrouter: one-time-initial, // no usage adapter, no documented "no credit card" claim — must never pass). const QUOTA_UNGUARANTEED = { @@ -69,7 +70,7 @@ const PAID = { provider: "openai", model: "gpt-4o", connectionId: REAL_CONN }; test("sanity: fixtures exist in the real catalog with the metadata these tests assume", () => { const groqEntry = FREE_MODEL_BUDGETS.find( - (m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile" + (m) => m.provider === "groq" && m.modelId === "openai/gpt-oss-120b" ); assert.equal(groqEntry?.hardStopGuaranteed, true, "groq must carry hardStopGuaranteed: true"); const arEntry = FREE_MODEL_BUDGETS.find( @@ -121,7 +122,7 @@ test("model absent from the free catalog is excluded even under a known provider // 5. quota SAFE + fresh + hardStop → PASS test("quota-based candidate with hardStopGuaranteed, fresh SAFE state above threshold passes", () => { const entry = FREE_MODEL_BUDGETS.find( - (m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile" + (m) => m.provider === "groq" && m.modelId === "openai/gpt-oss-120b" ); assert.deepEqual( evaluateCandidateConnections( @@ -137,7 +138,7 @@ test("quota-based candidate with hardStopGuaranteed, fresh SAFE state above thre // 6. quota exhausted → EXCLUDE test("EXHAUSTED status excludes even with a fresh checkedAt", () => { const entry = FREE_MODEL_BUDGETS.find( - (m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile" + (m) => m.provider === "groq" && m.modelId === "openai/gpt-oss-120b" ); const state = freshState({ status: "EXHAUSTED", remainingFreeAllowance: 0 }); assert.deepEqual( @@ -149,7 +150,7 @@ test("EXHAUSTED status excludes even with a fresh checkedAt", () => { // 7. usage adapter absent (no state resolvable) → EXCLUDE test("quota-based candidate with no resolvable state is excluded, not assumed safe", () => { const entry = FREE_MODEL_BUDGETS.find( - (m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile" + (m) => m.provider === "groq" && m.modelId === "openai/gpt-oss-120b" ); assert.deepEqual( evaluateCandidateConnections(QUOTA_SAFE, entry, () => undefined, BASE_OPTIONS), @@ -162,7 +163,7 @@ test("quota-based candidate with no resolvable state is excluded, not assumed sa // here — same assertion as #7, the important contract is "never falls back to SAFE"). test("UNKNOWN status excludes", () => { const entry = FREE_MODEL_BUDGETS.find( - (m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile" + (m) => m.provider === "groq" && m.modelId === "openai/gpt-oss-120b" ); const state = freshState({ status: "UNKNOWN", remainingFreeAllowance: null }); assert.deepEqual( @@ -174,7 +175,7 @@ test("UNKNOWN status excludes", () => { // 9. usage state stale → EXCLUDE test("stale checkedAt excludes even when status is SAFE", () => { const entry = FREE_MODEL_BUDGETS.find( - (m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile" + (m) => m.provider === "groq" && m.modelId === "openai/gpt-oss-120b" ); const stale = freshState({ checkedAt: "2026-08-19T00:00:00.000Z" }); // >24h before NOW assert.deepEqual( diff --git a/tests/unit/breaker-network-error-guard.test.ts b/tests/unit/breaker-network-error-guard.test.ts index 8d53edc080..35e1773b0a 100644 --- a/tests/unit/breaker-network-error-guard.test.ts +++ b/tests/unit/breaker-network-error-guard.test.ts @@ -78,7 +78,7 @@ test("forceLiveComboTest=true prevents breaker trip (combo will try next target) // `{ success: false, status: 5xx }` as a success. test("classifyProviderBreakerResult: a resolved 503 on the single-model path is a failure", () => { const outcome = classifyProviderBreakerResult( - { success: false, status: 503, errorCode: null, errorType: null, error: "overloaded" }, + { success: false, status: 503, errorCode: null, errorType: null, error: "service unavailable" }, false, false ); diff --git a/tests/unit/calllogs-format-split.test.ts b/tests/unit/calllogs-format-split.test.ts index a88f86c960..4bb267bfab 100644 --- a/tests/unit/calllogs-format-split.test.ts +++ b/tests/unit/calllogs-format-split.test.ts @@ -17,7 +17,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; - import { asRecord, toNumber, @@ -96,6 +95,15 @@ describe("callLogs/format — toStoredErrorSummary", () => { assert.ok(out.includes("kaboom")); assert.ok(out.includes("message")); }); + it("removes credentials, filesystem paths, and stack frames before persistence", () => { + const out = toStoredErrorSummary( + "Provider failed access_token=persisted-secret at /srv/private/provider.json\n" + + " at dispatch (/srv/private/dispatcher.ts:42:7)" + ); + + assert.equal(typeof out, "string"); + assert.doesNotMatch(out, /persisted-secret|srv\/private|dispatcher\.ts|\bat dispatch\b/i); + }); }); describe("callLogs/format — buildRequestSummary", () => { diff --git a/tests/unit/cerebras-free-tier-11773.test.ts b/tests/unit/cerebras-free-tier-11773.test.ts new file mode 100644 index 0000000000..1d1f0881b7 --- /dev/null +++ b/tests/unit/cerebras-free-tier-11773.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { APIKEY_PROVIDERS } from "../../src/shared/constants/providers/apikey/index.ts"; +import { getProviderById } from "../../src/shared/constants/providers.ts"; +import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.ts"; +import { FREE_TIER_BUDGETS } from "../../open-sse/config/freeTierCatalog.ts"; +import { LEGACY_FREE_PROVIDERS } from "../../open-sse/services/tierConfig.ts"; +import { classifyTier, clearTierCache } from "../../open-sse/services/tierResolver.ts"; +import { PROVIDER_TIER } from "../../open-sse/services/tierTypes.ts"; + +const CEREBRAS_MODELS = ["zai-glm-4.7", "gpt-oss-120b"] as const; + +test("#11773 cerebras stays catalogued, but not as a recurring zero-cost tier", () => { + const entry = APIKEY_PROVIDERS.cerebras; + assert.ok(entry, "APIKEY_PROVIDERS.cerebras must remain registered"); + assert.equal(entry.hasFree, true); + assert.equal(Object.hasOwn(FREE_TIER_BUDGETS, "cerebras"), false); + assert.equal(LEGACY_FREE_PROVIDERS.includes("cerebras"), false); +}); + +test("#11773 cerebras freeNote describes the $5 card-gated signup credit", () => { + const note = getProviderById("cerebras")?.freeNote ?? ""; + assert.match(note, /\$5/); + assert.match(note, /30.?day|30 days/i); + assert.match(note, /payment method|credit card/i); + assert.equal(/1M tokens\/day|30K TPM/.test(note), false); +}); + +test("#11773 cerebras catalog rows are one-time signup credits, not a hard-stop free trial", () => { + const rows = FREE_MODEL_BUDGETS.filter((row) => row.provider === "cerebras"); + assert.ok(rows.length >= CEREBRAS_MODELS.length, "catalog must keep the live Cerebras models"); + for (const modelId of CEREBRAS_MODELS) { + const row = rows.find((entry) => entry.modelId === modelId); + assert.ok(row, `missing catalog row for ${modelId}`); + assert.equal(row.freeType, "one-time-initial"); + assert.equal(row.monthlyTokens, 0); + assert.notEqual(row.hardStopGuaranteed, true); + } +}); + +test("#11773 cerebras is not classified as the free routing tier", () => { + clearTierCache(); + const result = classifyTier("cerebras", "zai-glm-4.7"); + assert.notEqual(result.tier, PROVIDER_TIER.FREE); +}); diff --git a/tests/unit/chat-admission-byte-healthy-headroom.test.ts b/tests/unit/chat-admission-byte-healthy-headroom.test.ts new file mode 100644 index 0000000000..9a4cdbb840 --- /dev/null +++ b/tests/unit/chat-admission-byte-healthy-headroom.test.ts @@ -0,0 +1,107 @@ +// Byte-path call-site slice of #10437: admitChatRequest (POST /v1/responses large +// bodies) must use the existing tryAcquireHealthyHeadroom budget when the primary +// lease is busy and the heap is not pressured. The STRUCTURE path already does this; +// the BYTE path still called acquireHeavyWithin()/tryAcquireHeavy() only. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ChatAdmissionController, + CHAT_LARGE_BODY_BYTES, + admitChatRequest, +} from "../../src/shared/middleware/chatBodyAdmission.ts"; + +function byteHeavyBody(minBytes = 40): string { + return JSON.stringify({ input: [{ role: "user", content: "x".repeat(minBytes) }] }); +} + +function responsesRequest(body: string): Request { + return new Request("http://x/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "content-length": String(body.length) }, + body, + }); +} + +test("byte-heavy admitChatRequest: two concurrent bodies, 1+1 budget, second admits on a healthy heap", async () => { + const controller = new ChatAdmissionController(1, undefined, 1); + const body = byteHeavyBody(); + const options = { + controller, + largeBodyBytes: 32, + hardMaxBytes: 1024, + queueMs: 0, + heapPressureCheck: () => false, + }; + + const [first, second] = await Promise.all([ + admitChatRequest(responsesRequest(body), options), + admitChatRequest(responsesRequest(body), options), + ]); + + assert.equal(first.admit, true, "first byte-heavy request must take the primary lease"); + assert.equal(second.admit, true, "second byte-heavy request must use healthy-headroom"); + assert.equal(controller.activeHeavy, 1); + assert.equal(controller.activeHealthyHeadroom, 1); + if (first.admit) first.lease?.release(); + if (second.admit) second.lease?.release(); + assert.equal(controller.activeHeavy, 0); + assert.equal(controller.activeHealthyHeadroom, 0); +}); + +test("byte-heavy admitChatRequest: a pressured heap still 503s the second concurrent body", async () => { + const controller = new ChatAdmissionController(1, undefined, 1); + const body = byteHeavyBody(); + const options = { + controller, + largeBodyBytes: 32, + hardMaxBytes: 1024, + queueMs: 0, + heapPressureCheck: () => true, + }; + + const [first, second] = await Promise.all([ + admitChatRequest(responsesRequest(body), options), + admitChatRequest(responsesRequest(body), options), + ]); + + const results = [first, second]; + const admitted = results.filter((result) => result.admit); + const rejected = results.filter((result) => !result.admit); + assert.equal(admitted.length, 1, "primary budget still admits exactly one pressured request"); + assert.equal(rejected.length, 1, "pressured heap must not spend healthy-headroom"); + assert.equal(controller.activeHeavy, 1); + assert.equal(controller.activeHealthyHeadroom, 0); + const shed = rejected[0]; + if (!shed.admit) { + assert.equal(shed.response.status, 503); + assert.equal((await shed.response.json()).error.code, "chat_admission_busy"); + } + for (const result of admitted) if (result.admit) result.lease?.release(); +}); + +test("OMNIROUTE_CHAT_LARGE_BODY_BYTES default threshold takes the heavyweight lease and healthy-headroom", async () => { + const controller = new ChatAdmissionController(1, undefined, 1); + const body = byteHeavyBody(CHAT_LARGE_BODY_BYTES); + assert.ok( + body.length >= CHAT_LARGE_BODY_BYTES, + "fixture must sit at or above the default LARGE_BODY_BYTES threshold" + ); + const options = { + controller, + hardMaxBytes: CHAT_LARGE_BODY_BYTES * 2, + queueMs: 0, + heapPressureCheck: () => false, + }; + + const [first, second] = await Promise.all([ + admitChatRequest(responsesRequest(body), options), + admitChatRequest(responsesRequest(body), options), + ]); + + assert.equal(first.admit, true, "body at LARGE_BODY_BYTES must take the primary lease"); + assert.equal(second.admit, true, "second LARGE_BODY_BYTES body must use healthy-headroom"); + assert.equal(controller.activeHeavy, 1); + assert.equal(controller.activeHealthyHeadroom, 1); + if (first.admit) first.lease?.release(); + if (second.admit) second.lease?.release(); +}); diff --git a/tests/unit/chat-body-admission-queue.test.ts b/tests/unit/chat-body-admission-queue.test.ts index 345954c142..b80a11436d 100644 --- a/tests/unit/chat-body-admission-queue.test.ts +++ b/tests/unit/chat-body-admission-queue.test.ts @@ -109,7 +109,13 @@ test("waiting for admission times out into a retryable 503", async () => { test("byte-heavy admission waits for capacity when queueMs is set", async () => { const controller = new ChatAdmissionController(1); const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] }); - const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 500 }; + const options = { + controller, + largeBodyBytes: 32, + hardMaxBytes: 1024, + queueMs: 500, + heapPressureCheck: () => true, + }; const first = await admitChatRequest(chatRequest(body), options); assert.equal(first.admit, true); @@ -230,20 +236,24 @@ test("aborting the admission wait settles early, grants no lease, and removes th settledAfterAbort = true; }); await new Promise((resolve) => setTimeout(resolve, 50)); - assert.equal(settledAfterAbort, true, "abort must settle the wait promptly, not park for queueMs"); + assert.equal( + settledAfterAbort, + true, + "abort must settle the wait promptly, not park for queueMs" + ); const lease = await pending; assert.equal(lease, null, "abort must not grant a lease"); - assert.equal(controller.activeHeavy, 1, "the holder keeps its lease; the aborted wait consumed nothing"); + assert.equal( + controller.activeHeavy, + 1, + "the holder keeps its lease; the aborted wait consumed nothing" + ); // Releasing must NOT wake the removed waiter: capacity stays free. held.release(); await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal( - controller.activeHeavy, - 0, - "releasing after abort must not wake the removed waiter" - ); + assert.equal(controller.activeHeavy, 0, "releasing after abort must not wake the removed waiter"); }); test("aborting the head waiter preserves FIFO order for remaining waiters", async () => { @@ -341,7 +351,13 @@ test("byte-heavy admission enforces the queued-bytes cap end-to-end", async () = assert.ok(held); const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] }); - const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 2_000 }; + const options = { + controller, + largeBodyBytes: 32, + hardMaxBytes: 1024, + queueMs: 2_000, + heapPressureCheck: () => true, + }; // First request parks: declared length (~70B) fits the budget. const first = admitChatRequest(chatRequest(body), options); @@ -452,6 +468,7 @@ test("aborting the request signal cancels a queued byte-heavy wait", async () => largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 2_000, + heapPressureCheck: () => true, }); let settled = false; diff --git a/tests/unit/chat-body-admission.test.ts b/tests/unit/chat-body-admission.test.ts index 05afae869c..b7867bc069 100644 --- a/tests/unit/chat-body-admission.test.ts +++ b/tests/unit/chat-body-admission.test.ts @@ -344,7 +344,13 @@ test("an existing byte-heavy lease is reused for structure-heavy admission", asy test("heavyweight admission is atomic and returns retryable 503 at capacity", async () => { const controller = new ChatAdmissionController(1); const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] }); - const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024 }; + const options = { + controller, + largeBodyBytes: 32, + hardMaxBytes: 1024, + // #10437 byte-path: shedding still requires real heap pressure. + heapPressureCheck: () => true, + }; const first = await admitChatRequest(chatRequest(body), options); assert.equal(first.admit, true); @@ -407,6 +413,7 @@ test("unknown or lying-small lengths cannot bypass occupied heavyweight capacity controller, largeBodyBytes: 32, hardMaxBytes: 1024, + heapPressureCheck: () => true, }); assert.equal(result.admit, false); if (!result.admit) assert.equal(result.response.status, 503); @@ -771,6 +778,7 @@ test("external clients cannot use the bypass header without a trusted self-loop controller, largeBodyBytes: 32, hardMaxBytes: 10 * 1024 * 1024, + heapPressureCheck: () => true, }); // Unknown key + bypass header must NOT bypass — capacity is exhausted → 503. @@ -877,6 +885,7 @@ test("sk_omniroute sentinel is rejected once an env key is configured (REQUIRE_A controller, largeBodyBytes: 32, hardMaxBytes: 10 * 1024 * 1024, + heapPressureCheck: () => true, }); assert.equal(result.admit, false, "sentinel must not bypass when an env key is configured"); diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index a971101891..710bb6f21c 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -25,6 +25,16 @@ const { getCircuitBreaker, resetAllCircuitBreakers, STATE } = // DATA_DIR must be fixed before these modules load; keep this test seam dynamic. const { setTlsClientForTest } = await import("../../open-sse/utils/proxyFetch.ts"); +type ApiErrorJson = { + error?: { + message?: string; + code?: string; + type?: string; + model?: string; + reset_seconds?: number; + }; +}; + async function resetStorage() { resetAllCircuitBreakers(); core.resetDbInstance(); @@ -85,7 +95,7 @@ test("resolveModelOrError rejects unknown built-in auto catalog ids", async () = assert.ok(result.error); assert.equal(result.error.status, 400); - const json = (await result.error.json()) as any; + const json = (await result.error.json()) as ApiErrorJson; assert.match(json.error.message, /Unknown built-in auto combo/i); }); @@ -120,7 +130,7 @@ test("resolveModelOrError rejects ambiguous aliases without a provider prefix", assert.ok(result.error); assert.equal(result.error.status, 400); - const json = (await result.error.json()) as any; + const json = (await result.error.json()) as ApiErrorJson; assert.match(json.error.message, /Ambiguous model/i); }); @@ -133,7 +143,7 @@ test("resolveModelOrError rejects ambiguous slashful canonical ids instead of mi assert.ok(result.error); assert.equal(result.error.status, 400); - const json = (await result.error.json()) as any; + const json = (await result.error.json()) as ApiErrorJson; assert.match(json.error.message, /Ambiguous model/i); assert.match(json.error.message, /openai\/gpt-oss-120b/i); }); @@ -147,7 +157,7 @@ test("resolveModelOrError rejects malformed model strings", async () => { assert.ok(result.error); assert.equal(result.error.status, 400); - const json = (await result.error.json()) as any; + const json = (await result.error.json()) as ApiErrorJson; assert.match(json.error.message, /Invalid model format/i); }); @@ -261,7 +271,7 @@ test("checkPipelineGates blocks providers with an open circuit breaker", async ( resetTimeoutMs: 5_000, }, }); - const json = (await response.json()) as any; + const json = (await response.json()) as ApiErrorJson; const retryAfter = Number(response.headers.get("Retry-After")); assert.equal(response.status, 503); @@ -329,8 +339,8 @@ test("handleNoCredentials reports missing provider credentials and exhausted acc 500 ); - const missingJson = (await missing.json()) as any; - const exhaustedJson = (await exhausted.json()) as any; + const missingJson = (await missing.json()) as ApiErrorJson; + const exhaustedJson = (await exhausted.json()) as ApiErrorJson; assert.equal(missing.status, 404); assert.match(missingJson.error.message, /No active credentials for provider: openai/); @@ -413,7 +423,7 @@ test("handleNoCredentials returns Retry-After when every account is rate limited null, null ); - const json = (await response.json()) as any; + const json = (await response.json()) as ApiErrorJson; assert.equal(response.status, 429); assert.ok(Number(response.headers.get("Retry-After")) >= 1); @@ -438,7 +448,7 @@ test("handleNoCredentials returns structured model_cooldown when every credentia null, null ); - const json = (await response.json()) as any; + const json = (await response.json()) as ApiErrorJson; assert.equal(response.status, 429); assert.equal(Number(response.headers.get("Retry-After")) >= 1, true); @@ -461,7 +471,7 @@ test("handleNoCredentials returns 401 with re-auth hint when every connection is null, null ); - const json = (await response.json()) as any; + const json = (await response.json()) as ApiErrorJson; assert.equal(response.status, 401); assert.match(json.error.message, /\[kiro\]/); @@ -478,12 +488,27 @@ test("handleNoCredentials maps allExpired status='expired' to the 'authenticatio null, null ); - const json = (await response.json()) as any; + const json = (await response.json()) as ApiErrorJson; assert.equal(response.status, 401); assert.match(json.error.message, /3 connection\(s\) authentication expired/); }); +test("handleNoCredentials maps credits_exhausted to HTTP 402 not 401 (#12441)", async () => { + const response = handleNoCredentials( + { allExpired: true, expiredCount: 3, expiredStatus: "credits_exhausted" }, + null, + "chutes", + "moonshotai/Kimi-K3-TEE", + null, + null + ); + const json = (await response.json()) as ApiErrorJson; + + assert.equal(response.status, 402); + assert.match(json.error.message, /3 connection\(s\) credits exhausted/); +}); + test("handleNoCredentials preserves lastError over allExpired after a failed attempt", async () => { const response = handleNoCredentials( { allExpired: true, expiredCount: 1, expiredStatus: "credits_exhausted" }, @@ -501,7 +526,7 @@ test("handleNoCredentials preserves lastError over allExpired after a failed att test("safeResolveProxy returns the direct route when no proxy config is present", async () => { const connection = await seedConnection("openai", { apiKey: "sk-openai-direct" }); - const resolved = await safeResolveProxy((connection as any).id); + const resolved = await safeResolveProxy((connection as { id: string }).id); assert.deepEqual(resolved, { proxy: null, @@ -693,7 +718,7 @@ test("resolveModelOrError returns model_not_found error for unrecognised bare mo assert.ok(result.error); assert.equal(result.error.status, 400); - const json = (await result.error.json()) as any; + const json = (await result.error.json()) as ApiErrorJson; assert.match(json.error.message, /Unable to determine provider/i); assert.match(json.error.message, /completely-unknown-model-xyz/i); }); diff --git a/tests/unit/chatcore-stream-error-result.test.ts b/tests/unit/chatcore-stream-error-result.test.ts index 352877046a..3ebe5821e7 100644 --- a/tests/unit/chatcore-stream-error-result.test.ts +++ b/tests/unit/chatcore-stream-error-result.test.ts @@ -43,6 +43,23 @@ test("createStreamingErrorResult attaches optional code and type", async () => { assert.equal(json.error.type, "rate_limit_error"); }); +test("createStreamingErrorResult sanitizes code and type at the SSE boundary", async () => { + const result = createStreamingErrorResult( + 502, + "upstream failed", + "sk-live-secret-value", + "server_error\nX-Leak: yes" + ); + const body = await result.response.text(); + const json = JSON.parse(body.slice("data: ".length, body.indexOf("\n\n"))) as { + error: { code: string; type: string }; + }; + + assert.equal(json.error.code, "bad_gateway"); + assert.equal(json.error.type, "server_error"); + assert.doesNotMatch(body, /sk-live-secret-value|X-Leak/); +}); + test("getUpstreamErrorIdentifier returns a non-empty string code or undefined", () => { assert.equal(getUpstreamErrorIdentifier({ code: "ECONNRESET" }), "ECONNRESET"); assert.equal(getUpstreamErrorIdentifier({ code: "" }), undefined); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 861f716251..c0125fff83 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -4,8 +4,15 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatcore-translation-")); +const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chatcore-translation-")); +const TEST_DATA_DIR = path.join(TEST_ROOT, "data"); +const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins"); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR; +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true }); process.env.DATA_DIR = TEST_DATA_DIR; +process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); @@ -448,7 +455,11 @@ test.after(async () => { resetAccountSemaphores(); await flushAsyncSideEffects(); await resetStorage(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR; + fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); test("chatCore times out upstream execution before provider response headers", async () => { // This test asserts pendingDetail.providerRequest — only attached when the @@ -1938,35 +1949,12 @@ test("chatCore surfaces translation errors with explicit status codes", async () FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, () => { - const error = new Error("responses translator rejected the payload"); - error.statusCode = 409; - throw error; - }, - null - ); - - const { result } = await invokeChatCore({ - provider: "openai", - model: "gpt-4o-mini", - endpoint: "/v1/responses", - body: { - model: "gpt-4o-mini", - input: "hello", - }, - }); - - assert.equal(result.success, false); - assert.equal(result.status, 409); - assert.equal(result.error, "responses translator rejected the payload"); -}); -test("chatCore surfaces typed translation errors with the declared error type", async () => { - register( - FORMATS.OPENAI_RESPONSES, - FORMATS.OPENAI, - () => { - const error = new Error("typed translator failure"); + const error = new Error( + "translator rejected access_token=translation-secret at /srv/private/translator.ts\n" + + " at translate (/srv/private/translator.ts:41:8)" + ); error.statusCode = 422; - error.errorType = "unsupported_feature"; + error.errorType = "unsupported_feature access_token=type-secret /srv/private/type.ts"; throw error; }, null @@ -1984,10 +1972,16 @@ test("chatCore surfaces typed translation errors with the declared error type", assert.equal(result.success, false); assert.equal(result.status, 422); - - const payload = (await result.response.json()) as any; - assert.equal(payload.error.type, "unsupported_feature"); - assert.equal(payload.error.code, "unsupported_feature"); + const payload = (await result.response.json()) as { + error: { message: string; type: string; code: string }; + }; + assert.equal(payload.error.type, "invalid_request_error"); + assert.equal(payload.error.code, ""); + assert.match(payload.error.message, /translator rejected/); + assert.doesNotMatch( + JSON.stringify({ payload, internalError: result.error }), + /translation-secret|type-secret|srv\/private|translator\.ts|type\.ts|\bat translate\b/i + ); }); test("chatCore returns 500 when translation throws a generic error", async () => { register( diff --git a/tests/unit/check-docs-counts-sync.test.ts b/tests/unit/check-docs-counts-sync.test.ts index 5f786f327a..7bafffec8c 100644 --- a/tests/unit/check-docs-counts-sync.test.ts +++ b/tests/unit/check-docs-counts-sync.test.ts @@ -106,16 +106,20 @@ test("the gate exits 0 against the current (synced) repo state", () => { // down to 1.37B, because no gate watched that number. import { checkFreeTierHeadline, + extractGatedClaims, extractHeadlineClaims, } from "../../scripts/check/check-docs-counts-sync.mjs"; const checkHeadline = checkFreeTierHeadline as ( content: string, - totals: { s: number; m: number; p: number } + totals: { s: number; m: number; p: number; g?: number } ) => { ok: boolean; detail: string }; const extractClaims = extractHeadlineClaims as ( content: string ) => { value: number; text: string }[]; +const extractGated = extractGatedClaims as ( + content: string +) => { tokens: number; unit: "B" | "M"; text: string }[]; const TOTALS = { s: 1_371_725_000, m: 1_998_225_000, p: 39 }; @@ -147,6 +151,45 @@ test("free-tier gate passes when a file carries no headline at all", () => { assert.equal(checkHeadline("no figures here", TOTALS).ok, true); }); +// --- Eligibility-gated bucket ("+~6M behind regional identity verification") -- +// The gated figure sits next to the headline and is validated with its own anchor, +// so it can neither drift nor be silently dropped once the catalog reports one. +const TOTALS_G = { s: 1_503_225_000, m: 2_129_725_000, p: 35, g: 6_000_000 }; + +test("free-tier gate validates the gated figure that sits next to the headline", () => { + const ok = + "~1.5B free tokens per month … +~6M behind regional identity verification (ModelScope)"; + assert.equal(checkHeadline(ok, TOTALS_G).ok, true); + const stale = "~1.5B free tokens per month … +~60M behind regional identity verification"; + assert.equal(checkHeadline(stale, TOTALS_G).ok, false); + assert.match(checkHeadline(stale, TOTALS_G).detail, /gated/); +}); + +test("free-tier gate rejects a file that carries the headline but omits the gated line", () => { + assert.equal(checkHeadline("~1.5B free tokens per month", TOTALS_G).ok, false); + // a file with no headline at all is still fine (per-provider tables, changelogs) + assert.equal(checkHeadline("no figures here", TOTALS_G).ok, true); + // and nothing changes for callers that pass no gated total + assert.equal( + checkHeadline("~1.5B free tokens per month", { s: TOTALS_G.s, m: TOTALS_G.m, p: 35 }).ok, + true + ); +}); + +test("gated claims are read in M or B and need the anchor phrase", () => { + assert.deepEqual(extractGated("~6M of unrelated text"), []); + assert.deepEqual(extractGated("+~6M behind regional identity verification"), [ + { tokens: 6_000_000, unit: "M", text: "+~6M" }, + ]); + assert.equal( + checkHeadline("~1.5B free tokens per month · ~1.2B behind regional identity verification", { + ...TOTALS_G, + g: 1_230_000_000, + }).ok, + true + ); +}); + // --- Generic numeric-claim gate (engines / MCP tools / scopes / CLI) -------- // Extends the same drift guard to the counts that silently drifted in v3.8.49: // 11→12 engines, 94→109 MCP tools, 30→33 scopes, 26→33 CLI tools. @@ -485,8 +528,8 @@ const TRAINING_CLAIM = { }; test("the hard-stop claim passes on the real sentence and fails on a stale count", () => { - const v = makeValidator(7, HARD_STOP_CLAIM); - assert.equal(v("7 entries carry an independently documented hard stop, and").ok, true); + const v = makeValidator(5, HARD_STOP_CLAIM); + assert.equal(v("5 entries carry an independently documented hard stop, and").ok, true); assert.equal(v("99 entries carry an independently documented hard stop, and").ok, false); }); @@ -500,10 +543,10 @@ test("the training claim passes on the real sentence and fails on a stale count" test("a reworded or deleted sentence fails, instead of passing as absent", () => { // The gate's real failure mode is not a stale number, it is silence: reword the // sentence past the pattern and "no claim in this file" used to read green. - const required = makeValidator(7, { ...HARD_STOP_CLAIM, requireClaim: true }); - assert.equal(required("7 entries have a provider-documented hard-stop guarantee.").ok, false); + const required = makeValidator(5, { ...HARD_STOP_CLAIM, requireClaim: true }); + assert.equal(required("5 entries have a provider-documented hard-stop guarantee.").ok, false); assert.equal(required("the page no longer mentions it at all").ok, false); - assert.equal(required("7 entries carry an independently documented hard stop.").ok, true); + assert.equal(required("5 entries carry an independently documented hard stop.").ok, true); const trainingRequired = makeValidator(13, { ...TRAINING_CLAIM, requireClaim: true }); assert.equal(trainingRequired("13 entries disclose training use.").ok, false); @@ -514,7 +557,7 @@ test("the live page actually satisfies both required gates", () => { // A unit test on synthetic strings proves the validator; this one proves the // document. Without it, the two could drift apart and both stay green. const page = readFileSync(path.resolve(here, "../../docs/reference/FREE_TIERS.md"), "utf8"); - assert.equal(makeValidator(7, { ...HARD_STOP_CLAIM, requireClaim: true })(page).ok, true); + assert.equal(makeValidator(5, { ...HARD_STOP_CLAIM, requireClaim: true })(page).ok, true); assert.equal(makeValidator(13, { ...TRAINING_CLAIM, requireClaim: true })(page).ok, true); }); diff --git a/tests/unit/cli-client-version-env-12417.test.ts b/tests/unit/cli-client-version-env-12417.test.ts new file mode 100644 index 0000000000..bc28100be9 --- /dev/null +++ b/tests/unit/cli-client-version-env-12417.test.ts @@ -0,0 +1,130 @@ +/** + * Regression for #12417 — Anthropic gates models (Fable 5.1) on the advertised + * Claude Code client version. Codex already has CODEX_CLIENT_VERSION; Claude + * and Copilot did not. A CLAUDE_USER_AGENT override is not enough: billing + * (`cc_version=`), stainless headers, and the four identity aliases all read + * the captured pin. + * + * Env override is a safe token (same shape as Codex). Garbage is ignored. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const canonical = await import("../../src/shared/constants/claudeCodeClient.ts"); +const copilot = await import("../../open-sse/config/providerHeaderProfiles.ts"); +const claudeHeaders = await import("../../open-sse/config/providers/shared.ts"); + +async function withEnv( + entries: Record, + fn: () => T | Promise +): Promise { + const previous = new Map(); + for (const [key, value] of Object.entries(entries)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + return await fn(); + } finally { + for (const [key, value] of previous.entries()) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +test("#12417 Claude pin stays the captured 2.1.258 binary", () => { + assert.equal(canonical.CLAUDE_CODE_CLIENT_VERSION, "2.1.258"); +}); + +test("#12417 getClaudeCodeClientVersion falls back to the captured pin", async () => { + await withEnv({ CLAUDE_CODE_CLIENT_VERSION: undefined }, () => { + assert.equal(canonical.getClaudeCodeClientVersion(), canonical.CLAUDE_CODE_CLIENT_VERSION); + }); +}); + +test("#12417 getClaudeCodeClientVersion honors a safe env override", async () => { + await withEnv({ CLAUDE_CODE_CLIENT_VERSION: "2.1.259" }, () => { + assert.equal(canonical.getClaudeCodeClientVersion(), "2.1.259"); + assert.equal(canonical.getClaudeCodeUserAgent("cli"), "claude-cli/2.1.259 (external, cli)"); + assert.equal( + canonical.getClaudeCodeUserAgent("sdk-cli"), + "claude-cli/2.1.259 (external, sdk-cli)" + ); + assert.equal( + canonical.getClaudeCodeClientBillingVersion(), + `2.1.259.${canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION}` + ); + }); +}); + +test("#12417 getClaudeCodeClientVersion ignores an unsafe env override", async () => { + await withEnv({ CLAUDE_CODE_CLIENT_VERSION: "bad version value" }, () => { + assert.equal(canonical.getClaudeCodeClientVersion(), canonical.CLAUDE_CODE_CLIENT_VERSION); + }); +}); + +test("#12417 Copilot pin stays the captured 1.0.81-6 CLI", () => { + assert.equal(copilot.GITHUB_COPILOT_CLI_VERSION, "1.0.81-6"); +}); + +test("#12417 getGitHubCopilotCliVersion falls back to the captured pin", async () => { + await withEnv({ GITHUB_COPILOT_CLI_VERSION: undefined }, () => { + assert.equal(copilot.getGitHubCopilotCliVersion(), copilot.GITHUB_COPILOT_CLI_VERSION); + }); +}); + +test("#12417 getGitHubCopilotChatHeaders honors a safe env override", async () => { + await withEnv({ GITHUB_COPILOT_CLI_VERSION: "1.0.82" }, () => { + assert.equal(copilot.getGitHubCopilotCliVersion(), "1.0.82"); + const headers = copilot.getGitHubCopilotChatHeaders(); + assert.equal(headers["user-agent"], "copilot/1.0.82"); + assert.equal(headers["editor-version"], "copilot/1.0.82"); + }); +}); + +test("#12417 getGitHubCopilotCliVersion ignores an unsafe env override", async () => { + await withEnv({ GITHUB_COPILOT_CLI_VERSION: "not a version" }, () => { + assert.equal(copilot.getGitHubCopilotCliVersion(), copilot.GITHUB_COPILOT_CLI_VERSION); + }); +}); + +test("#12417 getClaudeCliHeaders reads the env at call time", async () => { + await withEnv({ CLAUDE_CODE_CLIENT_VERSION: "2.1.259" }, () => { + assert.equal( + claudeHeaders.getClaudeCliHeaders()["User-Agent"], + "claude-cli/2.1.259 (external, cli)" + ); + }); +}); + +test("#12417 applyFingerprint Copilot UA follows the env, pin const does not", async () => { + const fingerprints = await import("../../open-sse/config/cliFingerprints.ts"); + await withEnv({ GITHUB_COPILOT_CLI_VERSION: "1.0.82" }, () => { + const result = fingerprints.applyFingerprint( + "copilot", + { Authorization: "Bearer token", Accept: "application/json" }, + { model: "gpt-4o", messages: [] } + ); + assert.equal(result.headers["User-Agent"], "GitHubCopilotChat/1.0.82"); + assert.equal(copilot.GITHUB_COPILOT_CHAT_USER_AGENT, "GitHubCopilotChat/1.0.81-6"); + }); +}); + +test("#12417 Claude billing pin stays captured while getter follows env", async () => { + const hdr = await import("../../open-sse/config/anthropicHeaders.ts"); + await withEnv({ CLAUDE_CODE_CLIENT_VERSION: "2.1.259" }, () => { + assert.equal(hdr.CLAUDE_CLI_BILLING_VERSION, canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION); + assert.equal( + hdr.getClaudeCliBillingVersion(), + `2.1.259.${canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION}` + ); + }); +}); diff --git a/tests/unit/cli-tunnel-create-command.test.ts b/tests/unit/cli-tunnel-create-command.test.ts new file mode 100644 index 0000000000..2e733ae063 --- /dev/null +++ b/tests/unit/cli-tunnel-create-command.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #12295: `omniroute tunnel create ` crashed with +// "Cannot read properties of undefined (reading optsWithGlobals)" because +// `.command("create [type]")` + `.addArgument(new Argument("[type]", ...))` +// registered TWO positional arguments. Commander then passed +// (type, type2, opts, command) to the action callback, but the handler +// destructured only (type, opts, cmd) — so `cmd` was bound to the real opts +// object and `cmd.parent` was undefined. + +test("tunnel create subcommand declares exactly one positional argument (#12295)", async () => { + const { Command } = await import("commander"); + const { registerTunnel } = await import("../../bin/cli/commands/tunnel.mjs"); + + const program = new Command(); + registerTunnel(program); + + const tunnelCmd = program.commands.find((c) => c.name() === "tunnel"); + assert.ok(tunnelCmd, "tunnel subcommand must exist"); + + const createCmd = tunnelCmd.commands.find((c) => c.name() === "create"); + assert.ok(createCmd, "create subcommand must exist"); + + // Before the fix, Commander registered two arguments named "type" because + // both .command("create [type]") and .addArgument(...) contributed one. + // After the fix, only .addArgument(...) defines the positional. + // Commander stores positional arguments in _args; the public args getter + // returns only required args, so optional args (like [type]) only appear in _args. + assert.equal( + createCmd._args.length, + 1, + `create subcommand must have exactly 1 positional argument, got ${createCmd._args.length}` + ); +}); + +test("tunnel create subcommand action handler accesses parent via Command instance (#12295)", async () => { + const { Command } = await import("commander"); + const { registerTunnel } = await import("../../bin/cli/commands/tunnel.mjs"); + + const program = new Command(); + registerTunnel(program); + + const tunnelCmd = program.commands.find((c) => c.name() === "tunnel"); + const createCmd = tunnelCmd.commands.find((c) => c.name() === "create"); + + assert.ok(createCmd._actionHandler, "create subcommand must have an action handler"); + + // Before the fix, the action callback received (type, type2, opts, command) + // because of the double positional. Destructuring (type, opts, cmd) then + // bound cmd to the opts object, making cmd.parent undefined and + // cmd.parent.optsWithGlobals() throw. + // After the fix, there's only one positional, so (type, opts, cmd) correctly + // binds cmd to the Command instance where cmd.parent === tunnelCmd. + // We can verify this by checking the parent chain on the createCmd itself: + assert.equal( + createCmd.parent, + tunnelCmd, + "create subcommand's parent must be the tunnel command" + ); + assert.equal( + createCmd.parent.parent, + program, + "tunnel command's parent must be the root program" + ); +}); + +test("tunnel create subcommand accepts valid tunnel type choices", async () => { + const { Command } = await import("commander"); + const { registerTunnel } = await import("../../bin/cli/commands/tunnel.mjs"); + + const program = new Command(); + registerTunnel(program); + + const tunnelCmd = program.commands.find((c) => c.name() === "tunnel"); + const createCmd = tunnelCmd.commands.find((c) => c.name() === "create"); + + // The addArgument with choices should still be registered. + assert.equal(createCmd._args.length, 1, "must have exactly one positional after addArgument"); + + // Verify choices are present on the argument. + const arg = createCmd._args[0]; + assert.ok(arg, "first argument must exist"); + assert.deepEqual( + arg.argChoices, + ["cloudflare", "tailscale", "ngrok"], + "argument choices must match VALID_TUNNEL_TYPES" + ); + assert.equal(arg.defaultValue, "cloudflare", "default type must be cloudflare"); +}); diff --git a/tests/unit/cli/claude-config-set-12407.test.ts b/tests/unit/cli/claude-config-set-12407.test.ts new file mode 100644 index 0000000000..12b0653fe4 --- /dev/null +++ b/tests/unit/cli/claude-config-set-12407.test.ts @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const cliPath = path.join(repoRoot, "bin", "omniroute.mjs"); + +test("#12407: config set claude preserves existing settings and writes Claude Code env keys", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-claude-config-")); + try { + const settingsPath = path.join(home, ".claude", "settings.json"); + await fs.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.writeFile( + settingsPath, + JSON.stringify( + { + model: "existing-model", + effortLevel: "high", + hooks: { PreToolUse: [{ command: "echo keep" }] }, + statusLine: { type: "command", command: "omniroute status" }, + env: { KEEP_ME: "1", ANTHROPIC_BASE_URL: "http://old" }, + }, + null, + 2 + ) + ); + + const { stdout, stderr } = await execFileAsync( + process.execPath, + [ + cliPath, + "config", + "set", + "claude", + "--model", + "claude-fallback", + "--yes", + "--non-interactive", + "--allow-container-write", + ], + { + cwd: repoRoot, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + OMNIROUTE_API_KEY: "sk_test_12407", + OMNIROUTE_BASE_URL: "http://localhost:20128/v1", + }, + timeout: 30_000, + } + ); + + assert.match(stdout + stderr, /Config written/); + const written = JSON.parse(await fs.readFile(settingsPath, "utf8")); + + assert.equal(written.model, "claude-fallback"); + assert.equal(written.effortLevel, "high"); + assert.deepEqual(written.hooks, { PreToolUse: [{ command: "echo keep" }] }); + assert.deepEqual(written.statusLine, { type: "command", command: "omniroute status" }); + assert.equal(written.env.KEEP_ME, "1"); + assert.equal(written.env.ANTHROPIC_BASE_URL, "http://localhost:20128"); + assert.equal(written.env.ANTHROPIC_AUTH_TOKEN, "sk_test_12407"); + assert.equal(written.env.ANTHROPIC_MODEL, "claude-fallback"); + assert.equal(written.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1"); + assert.equal("baseUrl" in written, false); + assert.equal("authToken" in written, false); + assert.equal("models" in written, false); + } finally { + await fs.rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); diff --git a/tests/unit/combo-cooldown-retry.test.ts b/tests/unit/combo-cooldown-retry.test.ts index 91d0339b1b..cc2c76699a 100644 --- a/tests/unit/combo-cooldown-retry.test.ts +++ b/tests/unit/combo-cooldown-retry.test.ts @@ -22,8 +22,12 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { shouldWaitForComboCooldown, resolveComboCooldownWaitDecision, COMBO_COOLDOWN_WAIT_MARGIN_MS } = - await import("../../open-sse/services/combo/comboCooldownRetry.ts"); +const { + shouldWaitForComboCooldown, + resolveComboCooldownWaitDecision, + resolveCircuitOpenWaitDecision, + COMBO_COOLDOWN_WAIT_MARGIN_MS, +} = await import("../../open-sse/services/combo/comboCooldownRetry.ts"); function baseSettings(overrides: Partial> = {}) { return { @@ -77,6 +81,11 @@ test("missing/unknown reason (null) → no wait (only an explicit transient reas assert.equal(r.wait, false); }); +test("reason circuit_open is eligible (whole-provider breaker OPEN is a short reset)", () => { + const r = shouldWaitForComboCooldown(baseInput({ reason: "circuit_open" }) as never); + assert.equal(r.wait, true); +}); + test("waitMs above the configured ceiling → no wait", () => { const r = shouldWaitForComboCooldown( baseInput({ waitMs: 5001, settings: baseSettings({ maxWaitMs: 5000 }) }) as never @@ -146,6 +155,7 @@ test("returned waitMs is clamped to a finite number (0 when input invalid)", () // ── resolveComboCooldownWaitDecision (target resolution + hint/fallback) ────── const M = COMBO_COOLDOWN_WAIT_MARGIN_MS; +assert.equal(M, 50, "wait margin is a pinned production constant, not a free parameter"); function decisionInput(overrides: Record = {}) { return { @@ -269,3 +279,75 @@ test("resolve: lock remaining above the ceiling → no wait (not a SHORT cooldow ); assert.equal(r.wait, false); }); + +// Live incident 2026-09-03: a single-target combo pre-skipped every target because +// the whole-provider breaker was OPEN (claude Overloaded STREAM_EARLY_EOF). That +// path crystallized ALL_TARGETS_SKIPPED in ~43ms and never entered the cooldown +// wait, even though the breaker resetTimeout is 60s and comboCooldownWait was on. + +function circuitOpenInput(overrides: Record = {}) { + return { + skippedForCircuitOpen: true, + retryAfterMs: 30_000, + attempt: 0, + budgetLeftMs: 90_000, + settings: baseSettings({ maxWaitMs: 90_000, budgetMs: 300_000, maxAttempts: 5 }), + ...overrides, + }; +} + +test("circuit-open skip with a short breaker reset → wait", () => { + const r = resolveCircuitOpenWaitDecision(circuitOpenInput() as never); + assert.equal(r.wait, true); + assert.equal(r.waitMs, 30_000 + M); + assert.equal(r.reason, "circuit_open"); +}); + +test("circuit-open skip is ignored when no target was skipped for circuit_open", () => { + const r = resolveCircuitOpenWaitDecision( + circuitOpenInput({ skippedForCircuitOpen: false }) as never + ); + assert.equal(r.wait, false); + assert.equal(r.reason, null); +}); + +test("circuit-open skip with zero retryAfter → no wait", () => { + const r = resolveCircuitOpenWaitDecision(circuitOpenInput({ retryAfterMs: 0 }) as never); + assert.equal(r.wait, false); +}); + +test("circuit-open skip above the wait ceiling → no wait", () => { + const r = resolveCircuitOpenWaitDecision( + circuitOpenInput({ + retryAfterMs: 120_000, + settings: baseSettings({ maxWaitMs: 90_000, budgetMs: 300_000, maxAttempts: 5 }), + }) as never + ); + assert.equal(r.wait, false); +}); + +test("circuit-open skip honors attempt/budget the same as model-lockout waits", () => { + assert.equal( + resolveCircuitOpenWaitDecision(circuitOpenInput({ attempt: 5 }) as never).wait, + false + ); + assert.equal( + resolveCircuitOpenWaitDecision(circuitOpenInput({ budgetLeftMs: 1_000 }) as never).wait, + false + ); +}); + +test("circuit-open skip is off when comboCooldownWait.enabled is false", () => { + const r = resolveCircuitOpenWaitDecision( + circuitOpenInput({ + settings: baseSettings({ + enabled: false, + maxWaitMs: 90_000, + budgetMs: 300_000, + maxAttempts: 5, + }), + }) as never + ); + assert.equal(r.wait, false); + assert.equal(r.reason, null); +}); diff --git a/tests/unit/combo-delete-lkgp-cleanup-12326.test.ts b/tests/unit/combo-delete-lkgp-cleanup-12326.test.ts new file mode 100644 index 0000000000..a35920535d --- /dev/null +++ b/tests/unit/combo-delete-lkgp-cleanup-12326.test.ts @@ -0,0 +1,152 @@ +/** + * Issue #12326 — deleting a combo must remove the LKGP pins keyed by its name + * without disturbing surviving combos' pins. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-lkgp-12326-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const comboRepo = await import("../../src/lib/db/repositories/sqliteComboRepository.ts"); +const lkgpDb = await import("../../src/lib/db/settings/lkgp.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); + +async function resetStorage() { + core.resetDbInstance(); + + for (let attempt = 0; attempt < 10; attempt++) { + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + break; + } catch (error: unknown) { + const code = + error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + continue; + } + + throw error; + } + } + + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function createCombo(name: string): Promise { + const combo = await comboRepo.createCombo({ + name, + models: [{ provider: "berry", model: "model-x" }], + } as Parameters[0]); + + assert.equal(typeof combo.id, "string", "combo fixture must return an id"); + return combo.id as string; +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("#12326: deleting a combo removes its LKGP pins", async () => { + const doomedId = await createCombo("doomed-combo"); + await createCombo("survivor-combo"); + + await lkgpDb.setLKGP("doomed-combo", "model-x", "berry", "conn-1"); + await lkgpDb.setLKGP("doomed-combo", "model-y", "berry", "conn-2"); + await lkgpDb.setLKGP("survivor-combo", "model-x", "berry", "conn-3"); + + assert.equal(await comboRepo.deleteCombo(doomedId), true); + + assert.equal(await lkgpDb.getLKGP("doomed-combo", "model-x"), null); + assert.equal(await lkgpDb.getLKGP("doomed-combo", "model-y"), null); + assert.deepEqual(await lkgpDb.getLKGP("survivor-combo", "model-x"), { + provider: "berry", + connectionId: "conn-3", + }); +}); + +test("#12326: deleting a combo invalidates warmed LKGP read-cache entries", async () => { + const doomedId = await createCombo("cached-combo"); + + await lkgpDb.setLKGP("cached-combo", "model-x", "berry", "conn-1"); + + assert.deepEqual(await readCache.getCachedLKGP("cached-combo", "model-x"), { + provider: "berry", + connectionId: "conn-1", + }); + + assert.equal(await comboRepo.deleteCombo(doomedId), true); + + assert.equal( + await readCache.getCachedLKGP("cached-combo", "model-x"), + null, + "deleted combos' LKGP pins must not survive in the read cache" + ); +}); + +test("#12326: a combo whose name prefixes another keeps the sibling's pins", async () => { + const doomedId = await createCombo("prod"); + await createCombo("prod-canary"); + + await lkgpDb.setLKGP("prod", "model-x", "berry", "conn-1"); + await lkgpDb.setLKGP("prod-canary", "model-x", "berry", "conn-2"); + + assert.equal(await comboRepo.deleteCombo(doomedId), true); + + assert.equal(await lkgpDb.getLKGP("prod", "model-x"), null); + assert.deepEqual( + await lkgpDb.getLKGP("prod-canary", "model-x"), + { provider: "berry", connectionId: "conn-2" }, + "the ':' delimiter must keep a prefix-sharing sibling's pins intact" + ); +}); + +test("#12326: LIKE wildcards in a combo name do not widen the cleanup", async () => { + const doomedId = await createCombo("temp_a"); + await createCombo("tempXa"); + + await lkgpDb.setLKGP("temp_a", "model-x", "berry", "conn-1"); + await lkgpDb.setLKGP("tempXa", "model-x", "berry", "conn-2"); + + assert.equal(await comboRepo.deleteCombo(doomedId), true); + + assert.equal(await lkgpDb.getLKGP("temp_a", "model-x"), null); + assert.deepEqual( + await lkgpDb.getLKGP("tempXa", "model-x"), + { provider: "berry", connectionId: "conn-2" }, + "'_' must be escaped so it cannot match an arbitrary character" + ); +}); + +test("#12326: deleting an unknown combo id leaves LKGP state untouched", async () => { + await createCombo("untouched-combo"); + await lkgpDb.setLKGP("untouched-combo", "model-x", "berry", "conn-1"); + + assert.equal(await comboRepo.deleteCombo("00000000-0000-0000-0000-000000000000"), false); + + assert.deepEqual(await lkgpDb.getLKGP("untouched-combo", "model-x"), { + provider: "berry", + connectionId: "conn-1", + }); +}); + +test("#12326: deleting a combo without pins succeeds", async () => { + const doomedId = await createCombo("no-pins-combo"); + + assert.equal(await comboRepo.deleteCombo(doomedId), true); + assert.equal(await lkgpDb.getLKGP("no-pins-combo", "model-x"), null); +}); diff --git a/tests/unit/combo-diagnostics-trace.test.ts b/tests/unit/combo-diagnostics-trace.test.ts index fe8bb546a4..dc1354f7ca 100644 --- a/tests/unit/combo-diagnostics-trace.test.ts +++ b/tests/unit/combo-diagnostics-trace.test.ts @@ -9,9 +9,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { errorResponseWithComboDiagnostics, sanitizeComboDiagnostics } = await import( - "../../open-sse/utils/error.ts" -); +const { errorResponseWithComboDiagnostics, sanitizeComboDiagnostics } = + await import("../../open-sse/utils/error.ts"); +const { buildRecoveryHint } = await import("../../open-sse/services/combo/pinRecovery.ts"); test("combo diagnostics: headers + body carry the sanitized trace (code override preserved)", async () => { const res = errorResponseWithComboDiagnostics( @@ -89,7 +89,9 @@ test("combo diagnostics: terminalReason with a non-Latin1 char (em dash) must no { poolSize: 4, attempted: 1, - excluded: [{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }], + excluded: [ + { provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }, + ], attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }], terminalReason, } @@ -112,8 +114,43 @@ test("combo diagnostics: JSON body keeps the original non-Latin1 text even thoug } ); // Header value must be a valid Latin1 ByteString — em dash (U+2014) replaced. - assert.equal(res.headers.get("x-omniroute-combo-terminal-reason"), terminalReason.replace("—", "?")); + assert.equal( + res.headers.get("x-omniroute-combo-terminal-reason"), + terminalReason.replace("—", "?") + ); const body = await res.json(); // JSON body keeps the original, readable (unsanitized) em dash. assert.equal(body.diagnostics.terminalReason, terminalReason); }); + +test("combo diagnostics preserve every canonical recovery hint up to the existing cap", async () => { + const reasons = [ + "reasoning_budget_exhausted", + "max_attempts_exceeded", + "all_accounts_inactive", + "quota_exhausted", + "all_models_failed", + "no_executable_targets", + "context_requirements_exhausted", + "all_targets_skipped", + "unknown_reason", + ]; + + for (const reason of reasons) { + const recovery = buildRecoveryHint(reason, 30); + const response = errorResponseWithComboDiagnostics(503, "combo failed", { + poolSize: 1, + attempted: 1, + excluded: [], + attemptOrder: [], + terminalReason: reason, + recovery, + }); + const body = (await response.json()) as { + recovery_hint?: { action: string; next_step: string }; + }; + + assert.equal(body.recovery_hint?.action, recovery.action, reason); + assert.equal(body.recovery_hint?.next_step, recovery.next_step.slice(0, 200), reason); + } +}); diff --git a/tests/unit/combo-strategies.test.ts b/tests/unit/combo-strategies.test.ts index dd0c7be367..990a5b9fe4 100644 --- a/tests/unit/combo-strategies.test.ts +++ b/tests/unit/combo-strategies.test.ts @@ -17,6 +17,8 @@ const { clearAllStickyBindings } = const { invalidateCodexQuotaCache, registerCodexConnection, registerCodexQuotaFetcher } = await import("../../open-sse/services/codexQuotaFetcher.ts"); const { registerQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts"); +const { getQuotaScopedModelForProvider } = + await import("../../open-sse/services/antigravityQuotaFamily.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const { recordComboRequest } = await import("../../open-sse/services/comboMetrics.ts"); @@ -434,6 +436,14 @@ test("reset-aware strategy avoids accounts near 5h exhaustion", async (t) => { assert.equal(await selectedConnectionFor(combo), healthy5h.id); }); +test("Antigravity aliases share one family-scoped cache key", () => { + assert.equal(getQuotaScopedModelForProvider("agy", "gemini-3.7-flash-high"), "family:gemini"); + assert.equal( + getQuotaScopedModelForProvider("antigravity", "gemini-3.7-flash-high"), + "family:gemini" + ); +}); + test("reset-aware strategy rotates similar scores with round-robin tie breaking", async () => { const provider = `tie-provider-${randomUUID()}`; const first = `first-${randomUUID()}`; diff --git a/tests/unit/diagnostics.test.ts b/tests/unit/diagnostics.test.ts index dbcb643480..62da56fc0c 100644 --- a/tests/unit/diagnostics.test.ts +++ b/tests/unit/diagnostics.test.ts @@ -105,6 +105,22 @@ test("failed Responses API body gets a request-scoped machine-readable classific }); }); +test("failed Responses API body surfaces the upstream error message when present", () => { + const failed = { + object: "response", + status: "failed", + output: [], + error: { code: "server_error", message: " Gemini 503: overloaded " }, + }; + const reason = detectMalformedNonStream(failed); + assert.equal(reason, "empty_choices"); + assert.deepEqual(describeMalformedNonStream(failed, reason), { + message: "upstream reported a failed response: Gemini 503: overloaded", + code: "upstream_response_failed", + type: "upstream_response_error", + }); +}); + test("detectMalformedNonStream returns 'empty_choices' when choice message has no content", () => { const body = { choices: [{ message: { content: "", tool_calls: null }, finish_reason: "stop" }], diff --git a/tests/unit/error-message-sanitization.test.ts b/tests/unit/error-message-sanitization.test.ts index f33a74a591..8813e7ac71 100644 --- a/tests/unit/error-message-sanitization.test.ts +++ b/tests/unit/error-message-sanitization.test.ts @@ -8,8 +8,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-err-sanitize-")); +const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-err-sanitize-")); +const TEST_DATA_DIR = path.join(TEST_ROOT, "data"); +const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins"); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true }); process.env.DATA_DIR = TEST_DATA_DIR; +process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR; process.env.API_KEY_SECRET = "test-api-key-secret-32chars-long!!"; const core = await import("../../src/lib/db/core.ts"); @@ -42,7 +50,13 @@ test.beforeEach(async () => { test.after(() => { core.resetDbInstance(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR; + if (ORIGINAL_API_KEY_SECRET === undefined) delete process.env.API_KEY_SECRET; + else process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); async function createCombo(name: string, model: string) { @@ -338,7 +352,8 @@ test("buildErrorBody — upstream details with stack key are stripped", async () !("stack" in (body.upstream_details as any)), "stack must be stripped from upstream_details" ); - assert.equal((body.upstream_details as any).code, "internal"); + assert.equal((body.upstream_details as any).code, ""); + assert.doesNotMatch(JSON.stringify(body.upstream_details), /internal/); }); // ── createErrorResult with upstreamDetails ─────────────────────────────────── diff --git a/tests/unit/error-public-boundaries-hardening.test.ts b/tests/unit/error-public-boundaries-hardening.test.ts new file mode 100644 index 0000000000..8e0e202b5b --- /dev/null +++ b/tests/unit/error-public-boundaries-hardening.test.ts @@ -0,0 +1,11 @@ +import test from "node:test"; + +import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts"; + +test("public error boundaries pass in an isolated child process", () => { + runIsolatedBoundaryFixture({ + fixtureUrl: new URL("./fixtures/error-public-boundaries-hardening.fixture.ts", import.meta.url), + expectedTests: 23, + label: "public error boundaries", + }); +}); diff --git a/tests/unit/error-sensitive-redaction.test.ts b/tests/unit/error-sensitive-redaction.test.ts index 1c7b5129fb..eb2e7e2392 100644 --- a/tests/unit/error-sensitive-redaction.test.ts +++ b/tests/unit/error-sensitive-redaction.test.ts @@ -1,8 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../../open-sse/utils/error.ts"; +import { + sanitizeErrorMessage, + sanitizeUpstreamDetails, +} from "../../open-sse/utils/errorSanitization.ts"; -test("sanitizeErrorMessage redacts bearer credentials and image data URLs", () => { +test("sanitizeErrorMessage removes bearer credentials and image data URLs", () => { const raw = "upstream echoed Authorization: Bearer eyJ.secret.token and data:image/png;charset=utf-8;base64,iVBORw0KGgoAAAANSUhEUgAAAAE="; const safe = sanitizeErrorMessage(raw); @@ -10,7 +13,10 @@ test("sanitizeErrorMessage redacts bearer credentials and image data URLs", () = assert.doesNotMatch(safe, /eyJ\.secret\.token/); assert.doesNotMatch(safe, /iVBORw0KGgo/); assert.match(safe, /\[REDACTED\]/); - assert.match(safe, /\[REDACTED_DATA_URL\]/); + // Authorization labels are fail-closed: once a credential label is seen, + // the sanitizer may discard the remaining untrusted tail instead of + // preserving a marker for each later secret. + assert.equal(safe, "upstream echoed Authorization: [REDACTED]"); }); test("sanitizeErrorMessage redacts common JSON credential fields", () => { @@ -25,6 +31,122 @@ test("sanitizeErrorMessage redacts common JSON credential fields", () => { assert.match(safe, /\[REDACTED\]/); }); +test("sanitizeErrorMessage redacts URL credentials while preserving safe URLs", () => { + const safeUrl = "https://example.com/docs/error?lang=en#recovery"; + const projected = sanitizeErrorMessage( + "proxy failed https://svc-user:p4ss-opaque-9382@internal.example/v1 " + + "then https://storage.example/blob?X-Amz-Credential=AKIAOPAQUE%2Fscope&" + + "X-Amz-Signature=signature-secret&X-Amz-Expires=60 " + + "and https://account.blob.core.windows.net/c?sv=2025-01-05&sig=sas-secret&se=soon " + + "then https://vertex.example/predict?key=vertex-key-secret&mode=express " + + "plus https://gateway.example/v1?api_key=query-api-secret&token=query-token-secret " + + `see ${safeUrl}` + ); + + assert.doesNotMatch( + projected, + /svc-user|p4ss-opaque|AKIAOPAQUE|signature-secret|sas-secret|vertex-key-secret|query-api-secret|query-token-secret/i + ); + assert.match(projected, /\[REDACTED\]/); + assert.match(projected, /X-Amz-Expires=60/); + assert.match(projected, /sv=2025-01-05/); + assert.match(projected, /se=soon/); + assert.match(projected, new RegExp(safeUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); +}); + +test("sanitizeErrorMessage redacts credentials hidden behind serialized whitespace", () => { + const inputs = [ + String.raw`api_key\t=opaque-tab-secret-9382746`, + String.raw`api_key\u0009=opaque-unicode-tab-9382746`, + String.raw`Bearer\topaque-bearer-secret-9382746`, + String.raw`api_key\\t=opaque-double-tab-secret-9382746`, + ]; + + for (const input of inputs) { + const projected = sanitizeErrorMessage(input); + assert.doesNotMatch(projected, /opaque-(?:tab|unicode-tab|bearer|double-tab)-secret/i); + assert.match(projected, /\[REDACTED\]/); + } +}); + +test("sanitizeErrorMessage redacts CLI credential flag values", () => { + const inputs = [ + "spawn failed: helper --api-key opaque-cli-key-9382746 --mode check", + 'spawn failed: helper --token "opaque cli token 9382746" --mode check', + "spawn failed: helper --password 'opaque-cli-password-9382746' --mode check", + ]; + + for (const input of inputs) { + const projected = sanitizeErrorMessage(input); + assert.doesNotMatch(projected, /opaque(?: cli|-cli)/i); + assert.match(projected, /\[REDACTED\]/); + } +}); + +test("sanitizeErrorMessage covers the canonical credential pattern catalog", () => { + const credentials = [ + `AIza${"A".repeat(35)}`, + `hf_${"A".repeat(34)}`, + `r8_${"A".repeat(37)}`, + `gho_${"A".repeat(36)}`, + `ghu_${"A".repeat(36)}`, + `ghs_${"A".repeat(36)}`, + `ghr_${"A".repeat(36)}`, + `lin_api_${"A".repeat(40)}`, + `secret_${"A".repeat(43)}`, + `npm_${"A".repeat(36)}`, + `PMAK-1234abcd-${"a".repeat(32)}`, + `rk_live_${"A".repeat(24)}`, + `sq0atp-${"A".repeat(22)}`, + `SK${"a".repeat(32)}`, + `SG.${"A".repeat(22)}.${"B".repeat(43)}`, + `key-${"a".repeat(32)}`, + `M${"A".repeat(23)}.${"B".repeat(6)}.${"C".repeat(27)}`, + "postgresql://db-user:db-password@db.internal.example/app", + ]; + + for (const credential of credentials) { + const projected = sanitizeErrorMessage(`upstream echoed ${credential}`); + assert.equal(projected.includes(credential), false, credential.slice(0, 16)); + assert.match(projected, /\[REDACTED(?::[^\]]+)?\]/); + } +}); + +test("sanitizeErrorMessage redacts credentials that cross the public length boundary", () => { + const credential = `hf_${"A".repeat(34)}`; + const projected = sanitizeErrorMessage(`${"x".repeat(4088)}${credential}`); + const escapedPrefixProjected = sanitizeErrorMessage( + `${String.raw`\t`}${"x".repeat(4088)}${credential}` + ); + + for (const output of [projected, escapedPrefixProjected]) { + assert.equal(output.includes("hf_"), false); + assert.equal(output.includes(credential), false); + assert.match(output, /\[REDACTED(?::[^\]]+)?\]$/); + assert.ok(output.length <= 4096); + } +}); + +test("sanitizeErrorMessage redacts closed and unterminated PGP private-key armor", () => { + const closed = sanitizeErrorMessage( + "provider returned -----BEGIN PGP PRIVATE KEY BLOCK-----\n" + + "Version: test\n\npgp-private-material\n" + + "-----END PGP PRIVATE KEY BLOCK----- after" + ); + const unterminated = sanitizeErrorMessage( + "provider returned -----BEGIN PGP PRIVATE KEY BLOCK-----\npgp-unterminated-material" + ); + + // Public exception messages fail closed at the first physical line; the + // post-block suffix is intentionally not recovered from a multiline secret. + assert.equal(closed, "provider returned [REDACTED]"); + assert.equal(unterminated, "provider returned [REDACTED]"); + assert.doesNotMatch( + `${closed} ${unterminated}`, + /pgp-private-material|pgp-unterminated-material/ + ); +}); + test("sanitizeUpstreamDetails drops credential headers and redacts data URLs", () => { const safe = sanitizeUpstreamDetails({ authorization: "Bearer sensitive", diff --git a/tests/unit/false-terminal-401-quota.test.ts b/tests/unit/false-terminal-401-quota.test.ts new file mode 100644 index 0000000000..393e2fdd68 --- /dev/null +++ b/tests/unit/false-terminal-401-quota.test.ts @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-false-terminal-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("401 credits-exhausted body is credits_exhausted, not expired", async () => { + await resetStorage(); + const conn = await providersDb.createProviderConnection({ + provider: "chutes", + authType: "apikey", + apiKey: "sk-chutes-live", + isActive: true, + testStatus: "active", + }); + const connId = String(conn.id); + await auth.markAccountUnavailable( + connId, + 401, + "[chutes] All 3 connection(s) credits exhausted — please reconnect in the dashboard", + "chutes", + "moonshotai/Kimi-K3-TEE" + ); + const after = await providersDb.getProviderConnectionById(connId); + assert.equal(after.testStatus, "credits_exhausted"); + assert.notEqual(after.testStatus, "expired"); +}); + +test("billing-cycle quota 403 stays unavailable until the cached reset", async () => { + await resetStorage(); + const quotaCache = await import("../../src/domain/quotaCache.ts"); + const conn = await providersDb.createProviderConnection({ + provider: "kimi-coding", + authType: "oauth", + accessToken: "kimi-access-token", + refreshToken: "kimi-refresh-token", + isActive: true, + testStatus: "active", + }); + const connId = String(conn.id); + const resetAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); + quotaCache.setQuotaCache(connId, "kimi-coding", { + Ratelimit: { remainingPercentage: 0, resetAt }, + Weekly: { + remainingPercentage: 62, + resetAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), + }, + }); + const result = await auth.markAccountUnavailable( + connId, + 403, + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle.", + "kimi-coding", + "kimi-for-coding" + ); + const after = await providersDb.getProviderConnectionById(connId); + assert.equal(result.shouldFallback, true); + assert.ok(Math.abs(result.cooldownMs - 30 * 60 * 1000) < 2_000); + assert.equal(after.testStatus, "unavailable"); + assert.notEqual(after.testStatus, "credits_exhausted"); + assert.equal(after.lastErrorType, "quota_exhausted"); + quotaCache.__clearForTests(); +}); + +test("401 with a still-valid access token does not expire the connection", async () => { + await resetStorage(); + const conn = await providersDb.createProviderConnection({ + provider: "claude", + authType: "oauth", + accessToken: "sk-ant-fresh", + refreshToken: "rt-fresh", + isActive: true, + testStatus: "active", + tokenExpiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), + expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), + }); + const connId = String(conn.id); + await auth.markAccountUnavailable(connId, 401, "unauthorized", "claude", "claude-opus-4-8"); + const after = await providersDb.getProviderConnectionById(connId); + assert.notEqual(after.testStatus, "expired"); + assert.equal(after.isActive, true); +}); diff --git a/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts b/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts new file mode 100644 index 0000000000..2b953ebd9e --- /dev/null +++ b/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts @@ -0,0 +1,608 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-public-errors-")); +const TEST_DATA_DIR = path.join(TEST_ROOT, "data"); +const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins"); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR; +const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); + +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true }); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const { + buildErrorBody, + buildModelCooldownBody, + createErrorResult, + parseUpstreamError, + projectPublicErrorIdentifier, + providerCircuitOpenResponse, + sanitizeErrorMessage, + sanitizeUpstreamDetails, + unavailableResponse, +} = await import("../../../open-sse/utils/error.ts"); +const { buildPassthroughErrorResponse, shouldPassthroughUpstreamError } = + await import("../../../open-sse/utils/upstreamErrorPassthrough.ts"); + +test.after(() => { + core.resetDbInstance(); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR; + fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("sanitizeErrorMessage removes non-source paths, credentials, and serialized stacks", () => { + const raw = String.raw`Provider failed at /srv/private/provider-key.json access_token=provider-secret\n at validate (C:\Users\admin\private\validator.ts:42:7)`; + const safe = sanitizeErrorMessage(raw); + + assert.match(safe, /Provider failed/i); + assert.doesNotMatch(safe, /srv\/private|provider-secret|C:\\Users|validator\.ts/i); + assert.doesNotMatch(safe, /\\n\s*at validate/i); +}); + +test("sanitizeErrorMessage redacts Windows drive-root-relative filesystem paths", () => { + const plain = sanitizeErrorMessage( + String.raw`Provider failed at \Users\admin\private\secret.txt` + ); + const quoted = sanitizeErrorMessage( + String.raw`Provider failed opening "\Windows\Temp\native.dll"` + ); + const singleSegment = sanitizeErrorMessage(String.raw`Provider failed opening \private.db`); + const prose = sanitizeErrorMessage(String.raw`Provider reported \offline without a path`); + const escapedInitialPaths = [ + String.raw`Provider failed at \bin\private.db`, + String.raw`Provider failed at \folder\private.db`, + String.raw`Provider failed at \new\private.db`, + String.raw`Provider failed at \root\private.db`, + String.raw`Provider failed at \temp\private.db`, + String.raw`Provider failed at C:\temp\private.db`, + ].map((message) => sanitizeErrorMessage(message)); + + assert.equal(plain, "Provider failed at "); + assert.equal(quoted, 'Provider failed opening ""'); + assert.equal(singleSegment, "Provider failed opening "); + assert.equal(prose, String.raw`Provider reported \offline without a path`); + for (const projected of escapedInitialPaths) { + assert.equal(projected, "Provider failed at "); + } +}); + +test("sanitizeErrorMessage redacts extensionless POSIX paths without hiding explicit routes", () => { + const compact = sanitizeErrorMessage("Provider failed at /custom/internal/secret"); + const spaced = sanitizeErrorMessage("Provider failed at /custom/internal secret directory"); + const route = sanitizeErrorMessage("Route /dashboard/providers is unavailable"); + const singleSegment = sanitizeErrorMessage("Provider failed opening /vault"); + const singleSegmentRoute = sanitizeErrorMessage("Route /vault is unavailable"); + const compoundPathAndRoute = sanitizeErrorMessage( + "Failed /vault then GET /home/profile returned 404" + ); + const knownRootRoutes = [ + sanitizeErrorMessage("GET /home returned 404"), + sanitizeErrorMessage("Route /run is unavailable"), + sanitizeErrorMessage("POST /data returned 409"), + sanitizeErrorMessage("Route /var is unavailable"), + ]; + const body = buildErrorBody(500, "Provider failed at /custom/internal/secret"); + + assert.doesNotMatch(compact, /custom\/internal\/secret/); + assert.doesNotMatch(spaced, /custom\/internal|secret directory/); + assert.doesNotMatch(body.error.message, /custom\/internal\/secret/); + assert.match(compact, //); + assert.equal(route, "Route /dashboard/providers is unavailable"); + assert.equal(singleSegment, "Provider failed opening "); + assert.equal(singleSegmentRoute, "Route /vault is unavailable"); + assert.equal(compoundPathAndRoute, "Failed then GET /home/profile returned 404"); + assert.deepEqual(knownRootRoutes, [ + "GET /home returned 404", + "Route /run is unavailable", + "POST /data returned 409", + "Route /var is unavailable", + ]); +}); + +test("sanitizeErrorMessage fails closed when string coercion is hostile", () => { + const hostile = { + toString(): never { + throw new Error("access_token=hostile-secret at /srv/private/hostile.ts:1:2"); + }, + }; + + assert.equal(sanitizeErrorMessage(hostile), ""); +}); + +test("buildErrorBody projects untrusted error classifications onto safe identifiers", () => { + const body = buildErrorBody(502, "upstream failed", undefined, { + type: "server_error\nX-Leak: yes", + code: "sk-live-secret-value", + reason: "access_token=reason-secret", + }); + + assert.equal(body.error.type, "server_error"); + assert.equal(body.error.code, "bad_gateway"); + assert.equal(body.error.reason, undefined); +}); + +test("createErrorResult rejects opaque upstream identifiers that could be echoed credentials", async () => { + const opaqueCredential = "AbC9xY7pQ2mN8vR4kL6z"; + const result = createErrorResult( + 502, + "upstream failed", + null, + opaqueCredential, + opaqueCredential + ); + const body = (await result.response.json()) as { + error: { code: string; type: string }; + }; + + assert.equal(body.error.code, "bad_gateway"); + assert.equal(body.error.type, "server_error"); + assert.doesNotMatch(JSON.stringify(body), new RegExp(opaqueCredential)); +}); + +test("parseUpstreamError never stringifies an untrusted error object into the public message", async () => { + const opaqueIdentifier = "AbC9xY7pQ2mN8vR4kL6z"; + const parsed = await parseUpstreamError( + Response.json( + { + error: { + code: opaqueIdentifier, + type: opaqueIdentifier, + reason: opaqueIdentifier, + }, + }, + { status: 502 } + ), + "openai" + ); + const result = createErrorResult( + parsed.statusCode, + parsed.message, + parsed.retryAfterMs, + parsed.errorCode as string, + parsed.errorType as string, + parsed.responseBody + ); + const bodyText = await result.response.text(); + + assert.equal(parsed.message, "Upstream error: 502"); + assert.doesNotMatch(bodyText, new RegExp(opaqueIdentifier)); +}); + +test("buildErrorBody preserves the configured empty code for unmapped client statuses", () => { + const body = buildErrorBody(424, "Dependency failed"); + + assert.equal(body.error.type, "invalid_request_error"); + assert.equal(body.error.code, ""); +}); + +test("public identifier vocabulary preserves current internal machine-readable contracts", () => { + const identifiers = [ + "context_length_exceeded", + "tool_calling_not_supported", + "vision", + "tools", + "structured_output", + "context_window", + "unsupported_endpoint", + "unverified_codex_client", + "invalid_previous_response_binding", + "incompatible_reasoning_effort", + "STREAM_READINESS_TIMEOUT", + "stream_timeout", + "STREAM_EARLY_EOF", + "stream_early_eof", + "LEASE_NO_ELIGIBLE_CONNECTION", + "LEASE_ELIGIBILITY_UNAVAILABLE", + "LEASE_UNSUPPORTED_ROUTE", + "LEASE_UNSUPPORTED_TRANSPORT", + "DIRECT_RESPONSE_START_TIMEOUT", + "PROXY_FAMILY_UNAVAILABLE", + "RELAY_TIMEOUT", + "TLS_FINGERPRINT_FAILED", + "PROXY_REQUEST_FAILED", + "TLS_SESSION_CAPACITY", + "TLS_CIRCUIT_OPEN", + "PROVIDER_RETIRED", + "upstream_empty_response", + "upstream_response_error", + "upstream_response_failed", + "stream_pipeline_error", + "stream_terminated", + "rate_limited", + "usage_limit_reached", + "timeout", + "semaphore_timeout", + "semaphore_queue_full", + "RATE_LIMIT_EXECUTION_TIMEOUT", + "RATE_LIMIT_QUEUE_FULL", + "RATE_LIMIT_QUEUE_WEDGED", + "RATE_LIMIT_QUEUE_TIMEOUT", + "rate_limit_queue_wedged", + "429", + "empty_response", + "stream_idle_timeout", + "empty_content", + "UNAVAILABLE", + "RESOURCE_EXHAUSTED", + "provider_unavailable", + "unsupported_feature", + "missing_project_id", + "oauth_missing_project_id", + "gcp_project_required", + "QUOTA_ONLY", + "QUOTA_NOT_ALLOCATED", + "cloudflare_challenge", + "cf_mitigated_challenge", + "upstream_protocol_error", + "claude_web_protocol_error", + "service_not_running", + "storage_encryption_stale", + "HTTP_429", + "BLACKBOX_SUBSCRIPTION_REQUIRED", + "BLACKBOX_AUTH_REQUIRED", + "BLACKBOX_RATE_LIMIT", + "abort", + "ABORTED", + "CHIPOTLE_ERROR", + "premium_model_requires_key", + "GROK_ERROR", + "TLS_CLIENT_UNAVAILABLE", + "upstream_access_denied", + "proxy_unavailable", + "EXECUTOR_ERROR", + "executor_contract_violation", + "orphan_tool_result", + "bedrock_stream_error", + "invalid_kiro_tool_call", + "devin_cli_error", + "upstream_websocket_error", + "upstream_websocket_connect_failed", + "codex_app_server_turn_failed", + "missing_credits", + "reached_limit", + "rate_limit_reached", + "rate_limit_longer_reached", + "client_cancelled", + "client_closed_request", + "compaction_control_unavailable", + "compaction_handoff_failed", + "connector_not_found", + "connector_error", + "prompt_attachment_integrity", + "chatgpt_session_expired", + "chatgpt_subscription_unavailable", + "upstream_server_error", + "multipart_protocol_violation", + "browser_stream_inconsistent", + "structured_output_validation_failed", + "chatgpt_submission_ambiguous", + "chatgpt_submitted_turn_failed", + "cli_not_found", + "upstream_auth_error", + "wreq_unavailable", + "api_error", + "connection_error", + "unsupported_runtime", + "VIDEO_ARTIFACT_URL_INVALID", + "VIDEO_ARTIFACT_URL_BLOCKED", + "VIDEO_ARTIFACT_DOWNLOAD_FAILED", + "VIDEO_ARTIFACT_TOO_LARGE", + "VIDEO_ARTIFACT_SIGNATURE_INVALID", + "VIDEO_ARTIFACT_NOT_READY", + "VIDEO_ARTIFACT_UNAVAILABLE", + "VIDEO_ARTIFACT_CONTENT_TYPE_INVALID", + "codex_app_server_unconfigured", + "meta_ai_warmup_failed", + "meta_ai_mode_switch_failed", + "meta_ai_ws_error", + "meta_ai_empty_response", + "PPLX_ERROR", + "cloudflare_or_bot", + "request_failed", + "lmarena_error", + "network_error", + ]; + + for (const identifier of identifiers) { + assert.equal(projectPublicErrorIdentifier(identifier, "bad_request"), identifier, identifier); + } +}); + +test("public numeric identifiers are limited to three-digit HTTP status codes", () => { + assert.equal(projectPublicErrorIdentifier("100", "bad_request"), "100"); + assert.equal(projectPublicErrorIdentifier("599", "bad_request"), "599"); + assert.equal(projectPublicErrorIdentifier("099", "bad_request"), "bad_request"); + assert.equal(projectPublicErrorIdentifier("600", "bad_request"), "bad_request"); + assert.equal(projectPublicErrorIdentifier("5000", "bad_request"), "bad_request"); + assert.equal(projectPublicErrorIdentifier("40002", "bad_request"), "bad_request"); + assert.equal(projectPublicErrorIdentifier("HTTP_600", "bad_request"), "bad_request"); + assert.equal(projectPublicErrorIdentifier("HTTP_40002", "bad_request"), "bad_request"); + assert.equal(projectPublicErrorIdentifier("weird_error", "bad_gateway"), "bad_gateway"); +}); + +test("buildErrorBody callers never overwrite a projected public classification", () => { + const productionFiles: string[] = []; + const collectTypeScriptFiles = (directory: string): void => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + collectTypeScriptFiles(entryPath); + } else if (entry.isFile() && /\.tsx?$/.test(entry.name)) { + productionFiles.push(entryPath); + } + } + }; + + collectTypeScriptFiles(path.join(REPO_ROOT, "open-sse")); + collectTypeScriptFiles(path.join(REPO_ROOT, "src")); + + const mutationPattern = /\b[A-Za-z_$][A-Za-z0-9_$]*\.error\.(?:code|type|reason)\s*=(?!=)/g; + const violations: string[] = []; + for (const filePath of productionFiles) { + const source = fs.readFileSync(filePath, "utf8"); + if (!source.includes("buildErrorBody")) continue; + for (const match of source.matchAll(mutationPattern)) { + const line = source.slice(0, match.index).split("\n").length; + violations.push(`${path.relative(REPO_ROOT, filePath)}:${line}`); + } + } + + assert.deepEqual(violations, []); + + const chatCoreSource = fs.readFileSync( + path.join(REPO_ROOT, "open-sse/handlers/chatCore.ts"), + "utf8" + ); + assert.doesNotMatch( + chatCoreSource, + /JSON\.stringify\(\s*\{\s*error\s*:\s*\{/, + "chatCore must not bypass buildErrorBody with a manually assembled error envelope" + ); +}); + +test("operational log persistence catches use the canonical sanitizer", () => { + const callLogsSource = fs.readFileSync(path.join(REPO_ROOT, "src/lib/usage/callLogs.ts"), "utf8"); + const proxyLoggerSource = fs.readFileSync(path.join(REPO_ROOT, "src/lib/proxyLogger.ts"), "utf8"); + + assert.match(callLogsSource, /sanitizeErrorMessage\(error\)/); + assert.doesNotMatch(callLogsSource, /\(error as Error\)\.message/); + assert.match(proxyLoggerSource, /sanitizeErrorMessage\(err\)/); + assert.doesNotMatch(proxyLoggerSource, /err\?\.message\s*\|\|\s*err/); +}); + +test("stream request finalization never warns with a raw error object", () => { + const source = fs.readFileSync( + path.join(REPO_ROOT, "open-sse/utils/streamFailureFinalization.ts"), + "utf8" + ); + + assert.match(source, /sanitizeErrorMessage\(error\)/); + assert.doesNotMatch(source, /"message" in error[\s\S]{0,160}: error/); +}); + +test("chatCore provider-failure writes use the projected persistent message", () => { + const source = fs.readFileSync(path.join(REPO_ROOT, "open-sse/handlers/chatCore.ts"), "utf8"); + const failureStart = source.indexOf("providerFailure: if (!providerResponse.ok)"); + const failureEnd = source.indexOf("// Non-streaming response", failureStart); + assert.ok(failureStart >= 0 && failureEnd > failureStart, "providerFailure block must exist"); + const failureBlock = source.slice(failureStart, failureEnd); + + assert.doesNotMatch(failureBlock, /lastError:\s*message\b/); + assert.ok( + (failureBlock.match(/lastError:\s*persistentMessage\b/g) || []).length >= 11, + "every providerFailure persistence branch must use persistentMessage" + ); +}); + +test("public cooldown and circuit responses sanitize dynamic context", async () => { + const unavailable = unavailableResponse( + 503, + "Provider failed at /srv/private/state.sqlite access_token=unavailable-secret", + 5, + "retry after reading C:\\Users\\admin\\private\\state.json" + ); + const unavailableBody = (await unavailable.json()) as { error: { message: string } }; + assert.doesNotMatch(unavailableBody.error.message, /srv\/private|unavailable-secret|C:\\Users/i); + + const circuit = providerCircuitOpenResponse( + "provider access_token=circuit-secret /home/service/provider.json", + 5 + ); + const circuitBody = (await circuit.json()) as { + error: { message: string; provider: string }; + }; + assert.equal(circuitBody.error.provider, "unknown"); + assert.doesNotMatch(JSON.stringify(circuitBody), /circuit-secret|\/home\/service/i); + + const cooldown = buildModelCooldownBody({ + model: "model access_token=model-secret /opt/models/private.json", + retryAfterSec: Number.NaN, + retryAfterAt: "not-a-timestamp access_token=timestamp-secret", + }); + assert.equal(cooldown.error.model, undefined); + assert.equal(cooldown.error.retry_after, undefined); + assert.equal(cooldown.error.reset_seconds, 1); + assert.doesNotMatch(JSON.stringify(cooldown), /model-secret|timestamp-secret|\/opt\/models/i); +}); + +test("sanitizeUpstreamDetails drops credential aliases and prototype-control keys", () => { + const input = Object.create(null) as Record; + input.error = { + message: "quota metadata at /srv/provider/private.json", + credential: "credential-secret", + sessionId: "session-secret", + session_count: 2, + }; + input.__proto__ = { leaked: true }; + + const safe = sanitizeUpstreamDetails(input) as Record; + const serialized = JSON.stringify(safe); + + assert.doesNotMatch(serialized, /credential-secret|session-secret|srv\/provider|__proto__/i); + assert.match(serialized, /"session_count":2/); +}); + +test("buildErrorBody fails closed for hostile upstream detail accessors", () => { + const hostile = new Proxy( + {}, + { + ownKeys(): never { + throw new Error("access_token=hostile-detail at /srv/private/detail.ts:1:2"); + }, + } + ); + + let body: ReturnType | undefined; + assert.doesNotThrow(() => { + body = buildErrorBody(502, "upstream failed", hostile); + }); + assert.equal(body?.upstream_details, undefined); + assert.doesNotMatch(JSON.stringify(body), /hostile-detail|srv\/private|detail\.ts/i); +}); + +test("upstream passthrough preserves safe wording but recursively sanitizes the JSON body", async () => { + const opaqueIdentifier = "AbC9xY7pQ2mN8vR4kL6z"; + const upstream = { + type: "error", + error: { + type: "invalid_request_error", + code: opaqueIdentifier, + reason: opaqueIdentifier, + message: "quota metadata from /srv/provider/private.json", + credential: "credential-secret", + session_count: 2, + details: [{ type: "integer", reason: "must be positive" }], + }, + }; + + assert.equal(shouldPassthroughUpstreamError(422, upstream), true); + const response = buildPassthroughErrorResponse(422, upstream); + assert.ok(response); + const serialized = JSON.stringify(await response.json()); + + assert.match(serialized, /invalid_request_error/); + assert.match(serialized, /"session_count":2/); + assert.match(serialized, /"type":"integer","reason":"must be positive"/); + assert.doesNotMatch( + serialized, + new RegExp(`credential-secret|srv/provider|${opaqueIdentifier}`, "i") + ); +}); + +test("upstream classification projection preserves HTTP numbers and rejects opaque aliases", () => { + const opaqueIdentifier = "AbC9xY7pQ2mN8vR4kL6z"; + const projected = sanitizeUpstreamDetails({ + code: 400, + status: "UNAVAILABLE", + oversizedCode: 40002, + error: { + code: 40002, + error_code: opaqueIdentifier, + errorCode: opaqueIdentifier, + error_type: opaqueIdentifier, + errorType: opaqueIdentifier, + sub_type: opaqueIdentifier, + subType: opaqueIdentifier, + status: opaqueIdentifier, + status_code: opaqueIdentifier, + statusCode: opaqueIdentifier, + message: "safe provider wording", + }, + }) as { + code?: unknown; + status?: unknown; + oversizedCode?: unknown; + error?: Record; + }; + + assert.equal(projected.code, 400); + assert.equal(projected.status, "UNAVAILABLE"); + assert.equal(projected.oversizedCode, 40002); + assert.equal(projected.error?.code, undefined); + assert.equal(projected.error?.error_code, ""); + assert.equal(projected.error?.errorCode, ""); + assert.equal(projected.error?.error_type, "upstream_error"); + assert.equal(projected.error?.errorType, "upstream_error"); + assert.equal(projected.error?.sub_type, "upstream_error"); + assert.equal(projected.error?.subType, "upstream_error"); + assert.equal(projected.error?.status, undefined); + assert.equal(projected.error?.status_code, undefined); + assert.equal(projected.error?.statusCode, undefined); + assert.equal(projected.error?.message, "safe provider wording"); + assert.doesNotMatch(JSON.stringify(projected), new RegExp(opaqueIdentifier)); +}); + +test("upstream classification projection preserves only real gRPC numeric codes", () => { + const projected = sanitizeUpstreamDetails({ + error: { code: 7 }, + errors: [{ code: 16 }, { code: 17 }, { code: 40002 }], + status: 7, + warning: { code: "model_capacity", type: "unknown" }, + }) as { + error?: { code?: unknown }; + errors?: Array<{ code?: unknown }>; + status?: unknown; + warning?: { code?: unknown; type?: unknown }; + }; + + assert.equal(projected.error?.code, 7); + assert.equal(projected.errors?.[0]?.code, 16); + assert.equal(projected.errors?.[1]?.code, undefined); + assert.equal(projected.errors?.[2]?.code, undefined); + assert.equal(projected.status, undefined); + assert.equal(projected.warning?.code, ""); + assert.equal(projected.warning?.type, "upstream_error"); +}); + +test("sanitizeUpstreamDetails fails closed for hostile prototype access", () => { + const hostile = new Proxy( + {}, + { + getPrototypeOf(): never { + throw new Error("access_token=prototype-secret at /srv/private/prototype.ts"); + }, + } + ); + + let projected: unknown; + assert.doesNotThrow(() => { + projected = sanitizeUpstreamDetails(hostile); + }); + assert.doesNotMatch(JSON.stringify(projected), /prototype-secret|srv\/private|prototype\.ts/i); +}); + +test("upstream passthrough fails closed for non-serializable bodies", () => { + const cyclic: Record = { error: { message: "safe" } }; + cyclic.self = cyclic; + + assert.equal(shouldPassthroughUpstreamError(400, cyclic), false); + assert.equal(buildPassthroughErrorResponse(400, cyclic), null); +}); + +test("upstream passthrough fails closed when getters change after eligibility", () => { + let reads = 0; + const upstream = Object.create(null) as Record; + Object.defineProperty(upstream, "error", { + enumerable: true, + get(): unknown { + reads += 1; + if (reads === 1) return { message: "safe capability error" }; + throw new Error("access_token=second-read-secret at /srv/private/getter.ts:1:2"); + }, + }); + + assert.doesNotThrow(() => buildPassthroughErrorResponse(400, upstream)); + assert.equal(buildPassthroughErrorResponse(400, upstream), null); +}); diff --git a/tests/unit/fixtures/mcp-public-error-boundaries.fixture.ts b/tests/unit/fixtures/mcp-public-error-boundaries.fixture.ts new file mode 100644 index 0000000000..1eaad34050 --- /dev/null +++ b/tests/unit/fixtures/mcp-public-error-boundaries.fixture.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mcp-error-boundaries-")); +const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const originalDataDir = process.env.DATA_DIR; +const originalPluginsDir = process.env.OMNIROUTE_PLUGINS_DIR; +const originalApiKey = process.env.OMNIROUTE_API_KEY; +const originalApiKeyId = process.env.OMNIROUTE_API_KEY_ID; +const originalInternalToken = process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN; +const originalInternalTokenFile = process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE; +const originalBaseUrl = process.env.OMNIROUTE_BASE_URL; +process.env.DATA_DIR = path.join(testRoot, "data"); +process.env.OMNIROUTE_PLUGINS_DIR = path.join(testRoot, "plugins"); +process.env.OMNIROUTE_API_KEY = "mcp-boundary-test-key"; +process.env.OMNIROUTE_API_KEY_ID = "mcp-boundary-test-key-id"; +process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN = "mcp-boundary-internal-test-token"; +process.env.OMNIROUTE_BASE_URL = "http://localhost:20128"; +delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE; +fs.mkdirSync(process.env.DATA_DIR, { recursive: true }); +fs.mkdirSync(process.env.OMNIROUTE_PLUGINS_DIR, { recursive: true }); + +const { createMcpServer } = await import("../../../open-sse/mcp-server/server.ts"); +const { closeAuditDb, queryAuditEntries } = await import("../../../open-sse/mcp-server/audit.ts"); +const { obsidianTools } = await import("../../../open-sse/mcp-server/tools/obsidianTools.ts"); +const { skillTools } = await import("../../../open-sse/mcp-server/tools/skillTools.ts"); +const { skillRegistry } = await import("../../../src/lib/skills/registry.ts"); +const { skillExecutor } = await import("../../../src/lib/skills/executor.ts"); +const core = await import("../../../src/lib/db/core.ts"); + +type McpResult = { + content?: Array<{ type: string; text: string }>; + isError?: boolean; +}; + +type RegisteredTool = { + handler: (args: unknown, extra?: unknown) => Promise; +}; + +function getRegisteredHandler(server: unknown, toolName: string): RegisteredTool["handler"] { + const registry = (server as { _registeredTools?: Record }) + ._registeredTools; + assert.ok(registry, "McpServer should expose _registeredTools"); + const tool = registry[toolName]; + assert.ok(tool, `${toolName} must be registered`); + return tool.handler; +} + +function assertPublicMcpError(result: McpResult): void { + const text = result.content?.[0]?.text ?? ""; + assert.equal(result.isError, true); + assert.match(text, /Error:/); + assert.doesNotMatch(text, /mcp-boundary-secret|srv\/private|mcp-boundary\.ts|\bat execute\b/i); +} + +test.after(() => { + closeAuditDb(); + core.resetDbInstance(); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + if (originalPluginsDir === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = originalPluginsDir; + if (originalApiKey === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = originalApiKey; + if (originalApiKeyId === undefined) delete process.env.OMNIROUTE_API_KEY_ID; + else process.env.OMNIROUTE_API_KEY_ID = originalApiKeyId; + if (originalInternalToken === undefined) delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN; + else process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN = originalInternalToken; + if (originalInternalTokenFile === undefined) { + delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE; + } else { + process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE = originalInternalTokenFile; + } + if (originalBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL; + else process.env.OMNIROUTE_BASE_URL = originalBaseUrl; + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("core MCP handlers sanitize upstream bodies before public and audit boundaries", async () => { + const hostile = "Bearer mcp-fetch-boundary-secret at /srv/private/mcp-fetch-boundary.ts:9:3"; + const originalFetch = globalThis.fetch; + const calledUrls: string[] = []; + globalThis.fetch = async (input) => { + calledUrls.push(String(input)); + return new Response(hostile, { status: 500 }); + }; + + try { + const handler = getRegisteredHandler(createMcpServer(), "omniroute_list_combos"); + const result = await handler({ includeMetrics: false }); + const publicText = result.content?.[0]?.text ?? ""; + assert.equal(result.isError, true); + assert.doesNotMatch( + publicText, + /mcp-fetch-boundary-secret|srv\/private|mcp-fetch-boundary\.ts/i + ); + assert.deepEqual(calledUrls, ["http://localhost:20128/api/combos"]); + + const audit = await queryAuditEntries({ tool: "omniroute_list_combos", success: false }); + assert.ok(audit.entries.length >= 1); + assert.doesNotMatch( + JSON.stringify(audit.entries), + /mcp-fetch-boundary-secret|srv\/private|mcp-fetch-boundary\.ts/i + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("every MCP public catch uses the canonical fail-closed projector", () => { + const source = fs.readFileSync(path.join(repoRoot, "open-sse/mcp-server/server.ts"), "utf8"); + assert.doesNotMatch(source, /err instanceof Error \? err\.message : String\(err\)/); +}); + +test("Obsidian and dynamic-skill MCP wrappers sanitize thrown errors", async () => { + const hostile = new Error( + "MCP failed access_token=mcp-boundary-secret at /srv/private/mcp-boundary.ts\n" + + " at execute (/srv/private/mcp-boundary.ts:9:3)" + ); + const mutableObsidianTool = obsidianTools[0] as unknown as { + name: string; + handler: (args: unknown, extra?: unknown) => Promise; + }; + const originalObsidianHandler = mutableObsidianTool.handler; + try { + mutableObsidianTool.handler = async () => { + throw hostile; + }; + const obsidianHandler = getRegisteredHandler(createMcpServer(), mutableObsidianTool.name); + assertPublicMcpError(await obsidianHandler({}, { authInfo: { scopes: ["read:obsidian"] } })); + } finally { + mutableObsidianTool.handler = originalObsidianHandler; + } + + const mutableRegistry = skillRegistry as unknown as { + list: () => Array<{ name: string; description: string; enabled: boolean }>; + }; + const mutableExecutor = skillExecutor as unknown as { + execute: (...args: unknown[]) => Promise; + }; + const originalList = mutableRegistry.list; + const originalExecute = mutableExecutor.execute; + try { + mutableRegistry.list = () => [ + { name: "mcp_boundary_skill", description: "boundary test", enabled: true }, + ]; + const dynamicHandler = getRegisteredHandler(createMcpServer(), "skill_mcp_boundary_skill"); + mutableExecutor.execute = async () => { + throw hostile; + }; + assertPublicMcpError( + await dynamicHandler({}, { authInfo: { clientId: "test", scopes: ["execute:skills"] } }) + ); + } finally { + mutableRegistry.list = originalList; + mutableExecutor.execute = originalExecute; + } +}); + +test("skill-tool MCP wrapper uses its own fail-closed fallback for hostile thrown values", async () => { + const mutableSkillTool = Object.values(skillTools)[0] as unknown as { + name: string; + handler: (args: unknown, extra?: unknown) => Promise; + }; + const originalHandler = mutableSkillTool.handler; + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + + try { + mutableSkillTool.handler = async () => { + throw revocable.proxy; + }; + const handler = getRegisteredHandler(createMcpServer(), mutableSkillTool.name); + const result = await handler({}, { authInfo: { scopes: ["read:skills"] } }); + assert.equal(result.isError, true); + assert.equal(result.content?.[0]?.text, "Error: Skill tool execution failed"); + } finally { + mutableSkillTool.handler = originalHandler; + } +}); diff --git a/tests/unit/fixtures/provider-connection-test-error-boundaries.fixture.ts b/tests/unit/fixtures/provider-connection-test-error-boundaries.fixture.ts new file mode 100644 index 0000000000..3ab084243b --- /dev/null +++ b/tests/unit/fixtures/provider-connection-test-error-boundaries.fixture.ts @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provider-errors-")); +const originalDataDir = process.env.DATA_DIR; +const originalPluginsDir = process.env.OMNIROUTE_PLUGINS_DIR; +const originalApiKeySecret = process.env.API_KEY_SECRET; +const originalDisableBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP; +const originalDisableHealthCheck = process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK; +const pluginsDir = path.join(testRoot, "plugins"); +const testDataDir = path.join(testRoot, "data"); +fs.mkdirSync(pluginsDir, { recursive: true }); +fs.mkdirSync(testDataDir, { recursive: true }); +process.env.OMNIROUTE_PLUGINS_DIR = pluginsDir; +process.env.DATA_DIR = testDataDir; +assert.notEqual(fs.realpathSync(testDataDir), "/home/diegosouzapw/.omniroute"); +assert.notEqual(fs.realpathSync(pluginsDir), "/home/diegosouzapw/.omniroute/plugins"); + +process.env.API_KEY_SECRET = "provider-error-boundary-test-secret"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK = "true"; + +// Connection tests suppress their call-log entry under node --test. This file +// exercises the real persistent boundary, so present a normal runtime identity +// before importing the route and its logging modules. +const originalArgv = process.argv; +const originalExecArgv = process.execArgv; +const originalNodeEnv = process.env.NODE_ENV; +const originalVitest = process.env.VITEST; +process.argv = [ + process.execPath, + path.join(process.cwd(), "scripts/ad-hoc/omniroute-boundary-harness.mjs"), +]; +process.execArgv = []; +process.env.NODE_ENV = "development"; +delete process.env.VITEST; + +const hostileValidationMessage = + "Jules failed access_token=jules-boundary-secret at /srv/private/validator.ts\n" + + " at probe (/srv/private/validator.ts:42:7)"; +const julesValidationUrl = "https://jules.googleapis.com/v1alpha/sources"; +const originalFetch = globalThis.fetch; +let validationFetchCalls = 0; +const boundaryFetch = (async (input: string | URL | Request) => { + const url = + typeof input === "string" ? input : input instanceof Request ? input.url : input.toString(); + assert.equal(url, julesValidationUrl, `unexpected outbound request: ${url}`); + validationFetchCalls += 1; + return new Response(hostileValidationMessage, { status: 500 }); +}) as typeof fetch; +globalThis.fetch = boundaryFetch; + +const core = await import("../../../src/lib/db/core.ts"); +const providersDb = await import("../../../src/lib/db/providers.ts"); +const { saveCallLog, waitForCallLogSaves, closeCallLogSaves } = + await import("../../../src/lib/usage/callLogs.ts"); +const { flushProxyLogsSync } = await import("../../../src/lib/proxyLogger.ts"); +const { projectProviderRuntimeForPublicResponse, testSingleConnection } = + await import("../../../src/app/api/providers/[id]/test/route.ts"); +// proxyFetch installs its global dispatcher while the imports above load. Put +// the deterministic stub back at the final fetch seam so this test can never +// reach Jules over the network. +globalThis.fetch = boundaryFetch; + +type ArtifactRow = { artifact_relpath: string | null; error_summary: string | null }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function readArtifact(relativePath: string | null): Record { + assert.ok(relativePath, "call log must have a persisted detail artifact"); + const absolutePath = path.join(testDataDir, "call_logs", relativePath); + return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as Record; +} + +test.after(async () => { + await closeCallLogSaves(2_000); + flushProxyLogsSync(); + globalThis.fetch = originalFetch; + process.argv = originalArgv; + process.execArgv = originalExecArgv; + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + if (originalVitest === undefined) delete process.env.VITEST; + else process.env.VITEST = originalVitest; + if (originalPluginsDir === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = originalPluginsDir; + core.resetDbInstance(); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + if (originalApiKeySecret === undefined) delete process.env.API_KEY_SECRET; + else process.env.API_KEY_SECRET = originalApiKeySecret; + if (originalDisableBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP; + else process.env.DISABLE_SQLITE_AUTO_BACKUP = originalDisableBackup; + if (originalDisableHealthCheck === undefined) { + delete process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK; + } else { + process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK = originalDisableHealthCheck; + } + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("public runtime projection omits host paths and internal error envelopes", () => { + const projected = projectProviderRuntimeForPublicResponse({ + installed: true, + runnable: false, + requiresBinary: true, + reason: "not_executable", + runtimeMode: "local", + version: "v1 from /srv/private/bin/tool", + command: "/srv/private/bin/tool", + commandPath: "/srv/private/bin/tool", + settingsPath: "C:\\Users\\admin\\.config\\tool.json", + error: "access_token=runtime-secret at /srv/private/runtime.json", + diagnosis: { message: "runtime-secret at /srv/private/runtime.ts" }, + }); + const serialized = JSON.stringify(projected); + + assert.equal(projected?.installed, true); + assert.equal(projected?.runnable, false); + assert.equal("commandPath" in (projected || {}), false); + assert.equal("settingsPath" in (projected || {}), false); + assert.equal("error" in (projected || {}), false); + assert.equal("diagnosis" in (projected || {}), false); + assert.doesNotMatch(serialized, /runtime-secret|srv\/private|C:\\\\Users/i); +}); + +test("connection validation projects hostile errors before public and persistent boundaries", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "jules", + authType: "apikey", + name: "Jules Error Boundary", + apiKey: "jules-test-key", + isActive: true, + testStatus: "active", + }); + assert.ok(connection?.id); + + const result = await testSingleConnection(connection.id); + assert.equal(result.valid, false); + assert.ok(validationFetchCalls > 0, "the deterministic Jules stub must handle the probe"); + assert.match(String(result.error), /Jules failed/i); + assert.equal(await waitForCallLogSaves(10_000), true, "call-log write must drain"); + flushProxyLogsSync(); + + const db = core.getDbInstance(); + const providerRow = db + .prepare("SELECT last_error FROM provider_connections WHERE id = ?") + .get(connection.id) as { last_error: string | null }; + const callLogRow = db + .prepare( + `SELECT error_summary, artifact_relpath + FROM call_logs + WHERE connection_id = ? AND model = 'connection-test' + ORDER BY rowid DESC LIMIT 1` + ) + .get(connection.id) as ArtifactRow; + const proxyLogRow = db + .prepare( + `SELECT error + FROM proxy_logs + WHERE connection_id = ? AND provider = 'jules' + AND target_url = 'jules/connection-test' + ORDER BY rowid DESC LIMIT 1` + ) + .get(connection.id) as { error: string | null }; + assert.ok(callLogRow, "connection test must write call_logs"); + assert.ok(proxyLogRow, "connection test must write proxy_logs"); + const artifact = readArtifact(callLogRow.artifact_relpath); + + const boundaries = { + publicResult: result, + providerLastError: providerRow.last_error, + callLogSummary: callLogRow.error_summary, + callLogArtifactError: artifact.error, + proxyLogError: proxyLogRow.error, + }; + const leakPattern = /jules-boundary-secret|srv\/private|validator\.ts|\bat probe\b/i; + const leakingBoundaries = Object.entries(boundaries) + .filter(([, value]) => leakPattern.test(JSON.stringify(value))) + .map(([name]) => name); + assert.deepEqual(leakingBoundaries, []); +}); + +test("failed call logs sanitize response-body copies while successful bodies stay unchanged", async () => { + const hostileBody = { + message: "access_token=call-body-secret at /srv/private/upstream.json", + detail: "Error: api_key=call-detail-secret\n at dispatch (/srv/private/rerank.ts:7:2)", + }; + const successBody = { + message: "Successful output mentions /tmp/public-example.ts and remains unchanged", + usage: { total_tokens: 4 }, + }; + + await saveCallLog({ + id: "error-body-json", + status: 502, + provider: "rerank-test", + model: "rerank-test", + responseBody: hostileBody, + pipelinePayloads: { + providerResponse: { body: hostileBody }, + clientResponse: { body: hostileBody }, + }, + }); + await saveCallLog({ + id: "error-body-text", + status: 503, + provider: "rerank-test", + model: "rerank-test", + responseBody: "Bearer plaintext-body-secret at C:\\Users\\admin\\upstream.txt", + }); + await saveCallLog({ + id: "success-body-control", + status: 200, + provider: "rerank-test", + model: "rerank-test", + responseBody: successBody, + pipelinePayloads: { + providerResponse: { body: successBody }, + clientResponse: { body: successBody }, + }, + }); + await saveCallLog({ + id: "error-body-binary", + status: 500, + provider: "rerank-test", + model: "rerank-test", + responseBody: Buffer.from([1, 2, 3, 4]), + }); + assert.equal(await waitForCallLogSaves(2_000), true, "call-log writes must drain"); + + const db = core.getDbInstance(); + const rows = db + .prepare( + `SELECT id, artifact_relpath FROM call_logs + WHERE id IN ( + 'error-body-json', 'error-body-text', 'success-body-control', 'error-body-binary' + )` + ) + .all() as Array<{ id: string; artifact_relpath: string | null }>; + const artifacts = Object.fromEntries( + rows.map((row) => [row.id, readArtifact(row.artifact_relpath)]) + ) as Record>; + + assert.doesNotMatch( + JSON.stringify({ json: artifacts["error-body-json"], text: artifacts["error-body-text"] }), + /call-body-secret|call-detail-secret|plaintext-body-secret|srv\/private|C:\\\\Users|\bat dispatch\b/i + ); + assert.deepEqual(artifacts["success-body-control"].responseBody, successBody); + assert.equal(artifacts["error-body-binary"].responseBody, "[binary 4 bytes]"); + const pipeline = artifacts["success-body-control"].pipeline; + assert.ok(isRecord(pipeline)); + assert.ok(isRecord(pipeline.providerResponse)); + assert.ok(isRecord(pipeline.clientResponse)); + assert.deepEqual(pipeline.providerResponse.body, successBody); + assert.deepEqual(pipeline.clientResponse.body, successBody); +}); diff --git a/tests/unit/fixtures/provider-last-error-sanitization.fixture.ts b/tests/unit/fixtures/provider-last-error-sanitization.fixture.ts new file mode 100644 index 0000000000..b131bdc67d --- /dev/null +++ b/tests/unit/fixtures/provider-last-error-sanitization.fixture.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provider-last-error-")); +const testDataDir = path.join(testRoot, "data"); +const testPluginsDir = path.join(testRoot, "plugins"); +const originalEnv = { + DATA_DIR: process.env.DATA_DIR, + OMNIROUTE_PLUGINS_DIR: process.env.OMNIROUTE_PLUGINS_DIR, + API_KEY_SECRET: process.env.API_KEY_SECRET, + DISABLE_SQLITE_AUTO_BACKUP: process.env.DISABLE_SQLITE_AUTO_BACKUP, +}; +fs.mkdirSync(testDataDir, { recursive: true }); +fs.mkdirSync(testPluginsDir, { recursive: true }); +process.env.DATA_DIR = testDataDir; +process.env.OMNIROUTE_PLUGINS_DIR = testPluginsDir; +process.env.API_KEY_SECRET = "provider-last-error-test-secret"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../../src/lib/db/core.ts"); +const providersDb = await import("../../../src/lib/db/providers.ts"); +const loggerResource = await import("../../../src/shared/utils/loggerResource.ts"); +const { runAsProbe } = await import("../../../src/shared/utils/probeOrigin.ts"); +const { writeTerminalStatus } = await import("../../../src/shared/utils/terminalStatus.ts"); +const { markAccountUnavailable } = await import("../../../src/sse/services/auth.ts"); + +function restoreEnv(name: keyof typeof originalEnv): void { + const original = originalEnv[name]; + if (original === undefined) delete process.env[name]; + else process.env[name] = original; +} + +function readLastError(connectionId: string): string | null { + const row = core + .getDbInstance() + .prepare("SELECT last_error FROM provider_connections WHERE id = ?") + .get(connectionId) as { last_error: string | null } | undefined; + return row?.last_error ?? null; +} + +test.after(async () => { + core.resetDbInstance(); + await loggerResource.closeSharedLoggerResource(); + restoreEnv("DATA_DIR"); + restoreEnv("OMNIROUTE_PLUGINS_DIR"); + restoreEnv("API_KEY_SECRET"); + restoreEnv("DISABLE_SQLITE_AUTO_BACKUP"); + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("normal and probe failures sanitize provider_connections.lastError at the write seam", async () => { + const hostile = + "provider failed access_token=provider-last-error-secret at /srv/private/provider.ts\n" + + " at dispatch (/srv/private/provider.ts:12:4)"; + const normal = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "normal last-error boundary", + apiKey: "normal-last-error-test-key", // pragma: allowlist secret + isActive: true, + testStatus: "active", + }); + const probe = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "probe last-error boundary", + apiKey: "probe-last-error-test-key", // pragma: allowlist secret + isActive: true, + testStatus: "active", + }); + const terminal = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "terminal last-error boundary", + apiKey: "terminal-last-error-test-key", // pragma: allowlist secret + isActive: true, + testStatus: "active", + }); + + await markAccountUnavailable(normal.id, 500, hostile, "openai"); + await runAsProbe(() => markAccountUnavailable(probe.id, 500, hostile, "openai")); + await writeTerminalStatus( + terminal.id, + { + testStatus: "banned", + isActive: false, + lastError: hostile, + lastErrorType: "forbidden", + errorCode: "403", + }, + "production" + ); + + const persisted = { + normal: readLastError(normal.id), + probe: readLastError(probe.id), + terminal: readLastError(terminal.id), + }; + assert.match(String(persisted.normal), /provider failed/i); + assert.match(String(persisted.probe), /provider failed/i); + assert.match(String(persisted.terminal), /provider failed/i); + assert.doesNotMatch( + JSON.stringify(persisted), + /provider-last-error-secret|srv\/private|provider\.ts|\bat dispatch\b/i + ); +}); diff --git a/tests/unit/fixtures/request-log-management-boundary.fixture.ts b/tests/unit/fixtures/request-log-management-boundary.fixture.ts new file mode 100644 index 0000000000..e3b75b22a9 --- /dev/null +++ b/tests/unit/fixtures/request-log-management-boundary.fixture.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-log-management-boundary-")); +const TEST_DATA_DIR = path.join(TEST_ROOT, "data"); +const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins"); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR; +const ORIGINAL_DISABLE_BACKUP = process.env.DISABLE_SQLITE_AUTO_BACKUP; + +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true }); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "1"; + +const core = await import("../../../src/lib/db/core.ts"); +const usageHistory = await import("../../../src/lib/usage/usageHistory.ts"); +const logsRoute = await import("../../../src/app/api/logs/[id]/route.ts"); +const usageHistoryRoute = await import("../../../src/app/api/usage/history/route.ts"); + +test.afterEach(() => { + usageHistory.clearPendingRequests(); +}); + +test.after(() => { + usageHistory.clearPendingRequests(); + core.resetDbInstance(); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR; + if (ORIGINAL_DISABLE_BACKUP === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP; + else process.env.DISABLE_SQLITE_AUTO_BACKUP = ORIGINAL_DISABLE_BACKUP; + fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const HOSTILE = + "Bearer management-cache-secret at /srv/private/completed-request.ts:12:3\n" + + " at finalize (/srv/private/finalize.ts:4:2)"; + +async function readManagementDetail(id: string): Promise> { + const response = await logsRoute.GET(undefined as unknown as Request, { params: { id } }); + assert.equal(response.status, 200); + return (await response.json()) as Record; +} + +function serializedDetail(detail: Record): string { + return JSON.stringify(detail); +} + +test("management detail sanitizes in-flight failure chunks at the endpoint boundary", async () => { + const requestId = usageHistory.trackPendingRequest("model", "provider", "conn-inflight", true); + assert.ok(requestId); + usageHistory.updatePendingRequestStreamChunks("model", "provider", "conn-inflight", { + provider: [`event: error\ndata: ${HOSTILE}\n\n`], + openai: [], + client: [], + }); + + const detail = await readManagementDetail(requestId); + assert.doesNotMatch( + serializedDetail(detail), + /management-cache-secret|srv\/private|completed-request\.ts|\bat finalize\b/i + ); +}); + +test("management detail sanitizes completed error metadata and cached chunks", async () => { + const requestId = usageHistory.trackPendingRequest("model", "provider", "conn-completed", true); + assert.ok(requestId); + usageHistory.updatePendingRequestStreamChunks("model", "provider", "conn-completed", { + provider: [`data: ${JSON.stringify({ type: "error", message: HOSTILE })}\n\n`], + openai: [], + client: [], + }); + assert.equal( + usageHistory.finalizePendingRequestById(requestId, { status: 502, error: HOSTILE }), + true + ); + + const detail = await readManagementDetail(requestId); + assert.doesNotMatch( + serializedDetail(detail), + /management-cache-secret|srv\/private|completed-request\.ts|\bat finalize\b/i + ); +}); + +test("usage history endpoint exposes pending counters without raw request details", async () => { + const requestId = usageHistory.trackPendingRequest("model", "provider", "conn-usage", true); + assert.ok(requestId); + usageHistory.updatePendingRequestStreamChunks("model", "provider", "conn-usage", { + provider: [`event: error\ndata: ${HOSTILE}\n\n`], + openai: [], + client: [], + }); + + const response = await usageHistoryRoute.GET(undefined as unknown as Request); + assert.equal(response.status, 200); + const body = (await response.json()) as { + pending?: { byModel?: Record; details?: unknown }; + }; + assert.equal(body.pending?.byModel?.["model (provider)"], 1); + assert.equal("details" in (body.pending ?? {}), false); + assert.doesNotMatch(JSON.stringify(body), /management-cache-secret|srv\/private/i); +}); diff --git a/tests/unit/fixtures/stream-failure-persistent-classification.fixture.ts b/tests/unit/fixtures/stream-failure-persistent-classification.fixture.ts new file mode 100644 index 0000000000..c589a4bd02 --- /dev/null +++ b/tests/unit/fixtures/stream-failure-persistent-classification.fixture.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-stream-failure-code-")); +const TEST_DATA_DIR = path.join(TEST_ROOT, "data"); +const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins"); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR; + +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true }); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const failureUsage = await import("../../../open-sse/handlers/chatCore/failureUsage.ts"); +const usageHistory = await import("../../../src/lib/usage/usageHistory.ts"); +const { createStreamFailureFinalizers } = + await import("../../../open-sse/utils/streamFailureFinalization.ts"); + +test.after(() => { + core.resetDbInstance(); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR; + fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("stream failure persists only the projected public classification", () => { + const opaqueCode = "opaque-stream-code-secret-9382746"; + let completionCode: string | null | undefined; + let persistedCode: string | undefined; + let classifierCode: string | undefined; + const { handleStreamFailure } = createStreamFailureFinalizers({ + isFailureCompletionRecorded: () => false, + onStreamComplete: (payload) => { + completionCode = payload.errorCode; + }, + persistFailureUsage: (_status, errorCode) => { + persistedCode = errorCode; + }, + onStreamFailure: (failure) => { + classifierCode = failure.code; + }, + }); + + assert.equal( + handleStreamFailure({ status: 502, message: "upstream failed", code: opaqueCode }), + true + ); + assert.equal(completionCode, "bad_gateway"); + assert.equal(persistedCode, "bad_gateway"); + assert.equal(classifierCode, opaqueCode); +}); + +test("pre-response failures persist only the projected public classification", async () => { + const opaqueCode = "opaque-pre-response-code-secret-6382951"; + const projectedCode = failureUsage.projectFailureUsageErrorCode({ + statusCode: 502, + message: "upstream request failed", + errorCode: opaqueCode, + errorType: "opaque-pre-response-type-secret-9472013", + }); + + assert.equal(projectedCode, "bad_gateway"); + + const provider = "persistent-error-code-boundary"; + await usageHistory.saveRequestUsage( + failureUsage.buildFailureUsageRecord({ + provider, + model: "model", + connectionId: null, + apiKeyInfo: null, + effectiveServiceTier: "standard", + isCombo: false, + comboStrategy: null, + statusCode: 502, + errorCode: projectedCode, + latencyMs: 1, + }) + ); + + const rows = await usageHistory.getUsageHistory({ provider }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.errorCode, "bad_gateway"); + assert.doesNotMatch(JSON.stringify(rows), /opaque-pre-response|6382951|9472013/); +}); diff --git a/tests/unit/free-catalog-no-confidence-field.test.ts b/tests/unit/free-catalog-no-confidence-field.test.ts index bb546b1668..39be8d8635 100644 --- a/tests/unit/free-catalog-no-confidence-field.test.ts +++ b/tests/unit/free-catalog-no-confidence-field.test.ts @@ -20,6 +20,9 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_ENTRY_KEYS = [ "creditTokens", "displayName", + // Who may claim a quota (e.g. a regional identity check), not how much the row can + // be trusted — the totals split it into its own gated bucket instead of rating it. + "eligibilityGate", "freeType", "hardStopGuaranteed", "modelId", diff --git a/tests/unit/free-model-catalog-gated-bucket.test.ts b/tests/unit/free-model-catalog-gated-bucket.test.ts new file mode 100644 index 0000000000..73cbd0fea5 --- /dev/null +++ b/tests/unit/free-model-catalog-gated-bucket.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computeFreeModelTotals, + FREE_MODEL_BUDGETS, + type FreeModelBudget, +} from "@omniroute/open-sse/config/freeModelCatalog.ts"; + +/** + * `eligibilityGate` changes COUNTING only: a gated row leaves the steady + * headline (and the pool count) and lands in `gatedRecurringTokens`, + * pool-deduped exactly like the headline. The regime stays what it is. + */ +const base = { creditTokens: 0, tos: "caution" as const }; +const entries: Array = [ + { + provider: "a", + modelId: "a1", + displayName: "A1", + monthlyTokens: 100, + freeType: "recurring-daily", + poolKey: "a-pool", + ...base, + }, + { + provider: "a", + modelId: "a2", + displayName: "A2", + monthlyTokens: 100, + freeType: "recurring-daily", + poolKey: "a-pool", + ...base, + }, + { + provider: "g", + modelId: "g1", + displayName: "G1", + monthlyTokens: 60, + freeType: "recurring-daily", + poolKey: "g-pool", + eligibilityGate: "regional-identity", + ...base, + }, + { + provider: "g", + modelId: "g2", + displayName: "G2", + monthlyTokens: 60, + freeType: "recurring-daily", + poolKey: "g-pool", + eligibilityGate: "regional-identity", + ...base, + }, + { + provider: "h", + modelId: "h1", + displayName: "H1", + monthlyTokens: 7, + freeType: "recurring-monthly", + poolKey: null, + eligibilityGate: "regional-identity", + ...base, + }, + { + provider: "u", + modelId: "u1", + displayName: "U1", + monthlyTokens: 0, + freeType: "recurring-uncapped", + poolKey: "u-pool", + eligibilityGate: "regional-identity", + ...base, + }, +]; + +test("gated entries leave the steady headline and the pool count", () => { + const t = computeFreeModelTotals({ entries }); + assert.equal(t.steadyRecurringTokens, 100); + assert.equal(t.poolCount, 1); + assert.equal(t.firstMonthRealisticTokens, 100); +}); + +test("gated entries are summed apart, pool-deduped, with their providers listed", () => { + const t = computeFreeModelTotals({ entries }); + assert.equal(t.gatedRecurringTokens, 67); // g-pool once (60) + h1 (7); the uncapped u1 adds 0 + assert.deepEqual(t.gatedProviders, ["g", "h"]); +}); + +test("a disabled gated entry contributes nothing", () => { + const t = computeFreeModelTotals({ + entries: entries.map((e) => (e.provider === "h" ? { ...e, enabled: false } : e)), + }); + assert.equal(t.gatedRecurringTokens, 60); + assert.deepEqual(t.gatedProviders, ["g"]); +}); + +test("excludeTosAvoid applies to gated entries too", () => { + const t = computeFreeModelTotals({ + excludeTosAvoid: true, + entries: entries.map((e) => (e.provider === "g" ? { ...e, tos: "avoid" as const } : e)), + }); + assert.equal(t.gatedRecurringTokens, 7); + assert.deepEqual(t.gatedProviders, ["h"]); +}); + +test("a gated signup credit never enters the first-month figure", () => { + const baseline = computeFreeModelTotals({ entries }); + const withGatedCredit = computeFreeModelTotals({ + entries: [ + ...entries, + { + provider: "c", + modelId: "c1", + displayName: "C1", + monthlyTokens: 0, + freeType: "one-time-initial", + poolKey: null, + creditTokens: 1000, + tos: "caution", + eligibilityGate: "regional-identity", + }, + ], + }); + assert.equal( + withGatedCredit.firstMonthRealisticTokens, + baseline.firstMonthRealisticTokens, + "a gated one-time credit must not inflate the first-month headline" + ); + assert.equal( + withGatedCredit.steadyWithRecurringCreditsTokens, + baseline.steadyWithRecurringCreditsTokens + ); +}); + +test("a gated uncapped provider is not advertised as permanently free", () => { + const t = computeFreeModelTotals({ entries }); + // `u` is the gated recurring-uncapped row in the fixture above. + assert.ok(!t.uncappedProviders.includes("u"), "gated rows must stay out of uncappedProviders"); + assert.deepEqual(t.uncappedProviders, []); +}); + +test("the shipped catalog exposes the two new fields", () => { + const t = computeFreeModelTotals(); + assert.equal(typeof t.gatedRecurringTokens, "number"); + assert.ok(Array.isArray(t.gatedProviders)); +}); + +/** + * Invariant: the gated bucket only ever accounts for STEADY tokens. A gated row + * carrying credits would silently drop them from every figure (credits are filtered + * out of the credit sums, and `gatedRecurringTokens` only sums `monthlyTokens`), so + * such a row must not exist in the shipped catalog without the totals growing a + * matching gated-credit figure first. + */ +test("shipped gated rows carry no credit tokens", () => { + for (const m of FREE_MODEL_BUDGETS) { + if (!m.eligibilityGate) continue; + assert.equal( + m.creditTokens, + 0, + `${m.provider}/${m.modelId} is eligibility-gated but declares creditTokens=${m.creditTokens}` + ); + } +}); diff --git a/tests/unit/free-note-freshness.test.ts b/tests/unit/free-note-freshness.test.ts index 1f24629218..67769aa00b 100644 --- a/tests/unit/free-note-freshness.test.ts +++ b/tests/unit/free-note-freshness.test.ts @@ -14,6 +14,9 @@ test("longcat freeNote reflects the post-2026-05-29 5M tokens/day reality", () = assert.match(note("longcat"), /5M tokens\/day|LongCat-2\.0/i); }); -test("cerebras freeNote reflects the tightened 30K TPM", () => { - assert.match(note("cerebras"), /30K TPM|1M tokens\/day/i); +test("cerebras freeNote reflects the $5 card-gated signup credit (#11773)", () => { + const n = note("cerebras"); + assert.match(n, /\$5/); + assert.match(n, /payment method|credit card/i); + assert.equal(/1M tokens\/day|30K TPM/.test(n), false); }); diff --git a/tests/unit/free-providers-batch-2026-07.test.ts b/tests/unit/free-providers-batch-2026-07.test.ts index e58c6f5dd2..5c913d562e 100644 --- a/tests/unit/free-providers-batch-2026-07.test.ts +++ b/tests/unit/free-providers-batch-2026-07.test.ts @@ -47,12 +47,12 @@ test("providers with no published token quota never inflate the headline", () => } }); -test("nara is a single shared 5M/day pool, counted once", () => { +test("nara is a single shared 7M/day pool, counted once", () => { const rows = byProvider("nara"); assert.ok(rows.length >= 1); - // 5M tokens/day shared across all models => 150M/month, deduped by poolKey. + // 7M tokens/day shared across all plan models => 210M/month (re-audited 2026-09-02, GET /api/plans). assert.ok(rows.every((m) => m.poolKey === "nara-free")); - assert.ok(rows.every((m) => m.monthlyTokens === 150_000_000)); + assert.ok(rows.every((m) => m.monthlyTokens === 210_000_000)); assert.ok(rows.every((m) => m.freeType === "recurring-daily")); }); diff --git a/tests/unit/free-tier-catalog.test.ts b/tests/unit/free-tier-catalog.test.ts index 6f4b23e356..4a804261ca 100644 --- a/tests/unit/free-tier-catalog.test.ts +++ b/tests/unit/free-tier-catalog.test.ts @@ -7,13 +7,16 @@ import { } from "../../open-sse/config/freeTierCatalog.ts"; test("FREE_TIER_BUDGETS holds positive integer monthly-token budgets", () => { - assert.ok(Object.keys(FREE_TIER_BUDGETS).length >= 19); + // 2026-09-02 re-audit: gemini + ollama-cloud left (no published cap), nara joined; + // #12591 then dropped cerebras (one-time credit) → 17 legacy keys. + assert.ok(Object.keys(FREE_TIER_BUDGETS).length >= 17); for (const [id, tokens] of Object.entries(FREE_TIER_BUDGETS)) { assert.ok(Number.isInteger(tokens) && tokens > 0, `${id} must be a positive integer`); } assert.equal(FREE_TIER_BUDGETS.mistral, 1_000_000_000); assert.equal(FREE_TIER_BUDGETS["cloudflare-ai"], 122_000_000); - assert.equal(FREE_TIER_BUDGETS.cerebras, 30_000_000); + // #11773: Cerebras is a one-time $5 signup credit, not a recurring monthly grant. + assert.equal(FREE_TIER_BUDGETS.cerebras, undefined); // LongCat is excluded from this recurring-monthly catalog: its free tier is a // one-time 10M-token signup grant (not recurring), so it must not appear here. assert.equal(FREE_TIER_BUDGETS.longcat, undefined); @@ -27,16 +30,20 @@ test("FREE_TIER_TOS marks proxy-prohibited providers as avoid", () => { test("computeFreeTierTotals sums the documented budgets", () => { const t = computeFreeTierTotals(); - assert.equal(t.providerCount, 19); - assert.ok(t.documentedMonthlyTokens >= 1_350_000_000); - assert.ok(t.documentedMonthlyTokens <= 1_450_000_000); + // 2026-09-02 re-audit: gemini + ollama-cloud left the legacy map (no published cap), + // nara joined, groq → 30M; #12591 dropped cerebras → 17 providers, legacy sum 1,475,025,000. + assert.equal(t.providerCount, 17); + assert.ok(t.documentedMonthlyTokens >= 1_425_000_000); + assert.ok(t.documentedMonthlyTokens <= 1_525_000_000); assert.equal(typeof t.headline, "string"); - assert.match(t.headline, /1\.3/); + // 2026-09-02 re-audit + #12591 (cerebras dropped): headline reads "over 1.48B …". + assert.match(t.headline, /1\.4/); }); test("computeFreeTierTotals can exclude ToS-avoid providers", () => { const all = computeFreeTierTotals(); const clean = computeFreeTierTotals({ excludeTosAvoid: true }); assert.equal(all.documentedMonthlyTokens - clean.documentedMonthlyTokens, 25_000); - assert.equal(clean.providerCount, 18); + // 2026-09-02 re-audit + #12591: 17 legacy providers, minus kiro (ToS avoid) → 16. + assert.equal(clean.providerCount, 16); }); diff --git a/tests/unit/free-tier-reaudit-2026-09.test.ts b/tests/unit/free-tier-reaudit-2026-09.test.ts new file mode 100644 index 0000000000..32323bf893 --- /dev/null +++ b/tests/unit/free-tier-reaudit-2026-09.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + FREE_MODEL_BUDGETS, + computeFreeModelTotals, +} from "@omniroute/open-sse/config/freeModelCatalog.ts"; +import { FREE_TIER_BUDGETS } from "@omniroute/open-sse/config/freeTierCatalog.ts"; +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; + +/** + * 2026-09-02 re-audit against the providers' own pages — the official pages + * cited in the `// evidence:` comments next to each entry in + * `open-sse/config/freeModelCatalog.data.ts`. Each test pins one verified + * fact so the catalog cannot drift back to an invented number. + */ +const rows = (id: string) => FREE_MODEL_BUDGETS.filter((m) => m.provider === id); +const ids = (id: string) => + rows(id) + .map((m) => m.modelId) + .sort(); + +test("gemini publishes no per-model free limits any more — uncapped, never summed", () => { + const g = rows("gemini"); + assert.ok(g.length >= 1); + assert.ok(g.every((m) => m.freeType === "recurring-uncapped" && m.monthlyTokens === 0)); + assert.ok(computeFreeModelTotals().uncappedProviders.includes("gemini")); +}); + +test("ollama-cloud free plan has no published token cap — uncapped, never summed", () => { + const o = rows("ollama-cloud"); + assert.ok(o.length >= 1); + assert.ok(o.every((m) => m.freeType === "recurring-uncapped" && m.monthlyTokens === 0)); + assert.ok(computeFreeModelTotals().uncappedProviders.includes("ollama-cloud")); +}); + +test("groq free plan: 200K TPD per model × 30 = 6M per model, each cap independent", () => { + assert.deepEqual(ids("groq"), [ + "openai/gpt-oss-120b", + "openai/gpt-oss-20b", + "openai/gpt-oss-safeguard-20b", + "qwen/qwen3.6-27b", + "qwen/qwen3.8-27b", + ]); + for (const m of rows("groq")) { + assert.equal(m.monthlyTokens, 6_000_000, m.modelId); + assert.equal(m.poolKey, null, `${m.modelId} is a per-model cap, not a shared pool`); + assert.equal(m.freeType, "recurring-daily"); + assert.equal(m.hardStopGuaranteed, true); + } + // retired from the free tier on 2026-07-17 / 2026-08-16 (console.groq.com/docs/deprecations) + for (const dead of [ + "llama-3.3-70b-versatile", + "meta-llama/llama-4-scout-17b-16e-instruct", + "qwen/qwen3-32b", + ]) { + assert.ok(!ids("groq").includes(dead), `${dead} must not be in the free catalog`); + } + const registryIds = new Set(REGISTRY.groq.models.map((m) => m.id)); + for (const id of ids("groq")) assert.ok(registryIds.has(id), `${id} must be routable`); +}); + +test("nara free plan: 7M tokens/day account-wide → 210M/month, one pool, the 8 plan models", () => { + const free = [ + "agnes-2.0-flash", + "agnes-2.5-flash", + "laguna-s-2.1", + "minimax-m3-free", + "mistral-large", + "mistral-medium-3-5", + "qwen3.8-27b", + "stepfun-3.7-flash", + ]; + assert.deepEqual(ids("nara"), free); + for (const m of rows("nara")) { + assert.equal(m.monthlyTokens, 210_000_000, m.modelId); + assert.equal(m.poolKey, "nara-free"); + assert.equal(m.freeType, "recurring-daily"); + } + assert.deepEqual(REGISTRY.nara.models.map((m) => m.id).sort(), free); +}); + +test("mistral keeps its 1B pool only with a dated console verification on record", () => { + const src = readFileSync( + new URL("../../open-sse/config/freeModelCatalog.data.ts", import.meta.url), + "utf8" + ); + const m = rows("mistral"); + assert.ok(m.length >= 1); + const pooled = m.filter((r) => r.monthlyTokens > 0); + if (pooled.length > 0) { + // All-or-nothing: a mixed 1B/0 state is neither console-verified nor honestly uncapped. + assert.equal(pooled.length, m.length, "every mistral row must carry the pooled 1B"); + assert.ok( + pooled.every( + (r) => + r.monthlyTokens === 1_000_000_000 && + r.poolKey === "mistral" && + r.freeType === "recurring-monthly" + ) + ); + assert.match( + src, + /evidence: console-verified 20\d\d-\d\d-\d\d por \S+ \(https:\/\/console\.mistral\.ai/ + ); + } else { + assert.ok(m.every((r) => r.monthlyTokens === 0 && r.freeType === "recurring-uncapped")); + } +}); + +test("legacy provider-level catalog agrees with the per-model catalog for the re-audited providers", () => { + assert.equal(FREE_TIER_BUDGETS.gemini, undefined); + assert.equal(FREE_TIER_BUDGETS["ollama-cloud"], undefined); + assert.equal(FREE_TIER_BUDGETS.groq, 30_000_000); + assert.equal(FREE_TIER_BUDGETS.nara, 210_000_000); +}); + +test("modelscope: 250 魔粒/day → 6M/month, one pool, behind mainland real-name verification", () => { + const m = rows("modelscope"); + assert.ok(m.length >= 1); + for (const r of m) { + assert.equal(r.monthlyTokens, 6_000_000, r.modelId); + assert.equal(r.poolKey, "modelscope-free"); + assert.equal(r.freeType, "recurring-daily"); + assert.equal(r.eligibilityGate, "regional-identity"); + assert.equal(r.tos, "caution"); + } + const t = computeFreeModelTotals(); + assert.equal(t.gatedRecurringTokens, 6_000_000); + assert.deepEqual(t.gatedProviders, ["modelscope"]); + assert.ok(!t.uncappedProviders.includes("modelscope")); +}); + +test("a shared pool never mixes gated and ungated entries", () => { + const byPool = new Map>(); + for (const r of FREE_MODEL_BUDGETS) { + if (!r.poolKey) continue; + const gate = r.eligibilityGate ?? "none"; + const seen = byPool.get(r.poolKey) ?? new Set(); + seen.add(gate); + byPool.set(r.poolKey, seen); + } + for (const [poolKey, gates] of byPool) { + assert.equal( + gates.size, + 1, + `pool ${poolKey} mixes eligibility gates: ${[...gates].join(", ")}` + ); + } +}); diff --git a/tests/unit/free-tier-summary-radar-overlay.test.ts b/tests/unit/free-tier-summary-radar-overlay.test.ts index 59090b1c3c..b63bdab68d 100644 --- a/tests/unit/free-tier-summary-radar-overlay.test.ts +++ b/tests/unit/free-tier-summary-radar-overlay.test.ts @@ -54,6 +54,7 @@ const STALE_GEN_AT = "2026-01-02T12:00:00.000Z"; const FETCHED_AT = `${FREE_CATALOG_CURATED_AT}T18:00:00.000Z`; const OVERLAY_TOKENS = 1_234_567; const DISABLED_TOKENS = 9_999_999; +const GATED_TOKENS = 4_242_000; async function authCookieHeader(): Promise { const secret = new TextEncoder().encode(process.env.JWT_SECRET); @@ -115,6 +116,34 @@ function feedPayload(tier: "community" | "live") { }; } +function seedGatedCache() { + const payload = feedPayload("community"); + payload.models.push({ + provider: "test-radar", + modelId: "overlay-gated-model", + displayName: "Overlay Gated Model", + familyId: null, + freeType: "recurring-daily", + budget: { kind: "shared_pool", poolId: "overlay-gated", tokensPerMonth: GATED_TOKENS }, + limits: { rpm: null, rpd: null, tpm: null, tpd: null }, + contextWindow: null, + capabilities: { tools: false, vision: false, thinking: false }, + trainsOnPrompts: null, + tosRisk: "ok", + setup: null, + enabled: true, + eligibilityGate: "regional-identity", + } as (typeof payload.models)[number]); + radarDb.setRadarCache({ + version: "2026.08.25.1", + generatedAt: GEN_AT, + tier: "community", + payload: JSON.stringify(payload), + signature: "test-signature-not-verified-on-read", + fetchedAt: FETCHED_AT, + }); +} + function resetState() { core.resetDbInstance(); try { @@ -364,3 +393,25 @@ test("stale overlay => a locally disabled model stays out of the totals", async "a locally disabled model must not be counted when the feed is withheld" ); }); + +// --- eligibility-gated entries from the feed stay out of the headline -------- + +test("a gated overlay entry lands in gatedRecurringTokens, never in the steady headline", async () => { + resetState(); + setFeatureFlagOverride("RADAR_ENABLED", "true"); + seedGatedCache(); + + const body = await getBody(false); + + assert.equal(body.catalogSource, "radar-overlay"); + assert.equal( + body.gatedRecurringTokens, + (computeFreeModelTotals().gatedRecurringTokens as number) + GATED_TOKENS, + "the feed-only gated pool joins the gated bucket on top of the baseline's own" + ); + assert.equal( + body.steadyRecurringTokens, + (computeFreeModelTotals().steadyRecurringTokens as number) + OVERLAY_TOKENS, + "the gated pool must not move the steady headline" + ); +}); diff --git a/tests/unit/gemini-malformed-required-and-bare-map-12269.test.ts b/tests/unit/gemini-malformed-required-and-bare-map-12269.test.ts new file mode 100644 index 0000000000..fd1c51a98c --- /dev/null +++ b/tests/unit/gemini-malformed-required-and-bare-map-12269.test.ts @@ -0,0 +1,267 @@ +/** + * Regression for #12269 — skills injection 400s Antigravity Gemini because two + * schema shapes survive `cleanJSONSchemaForAntigravity` and Gemini's proto + * rejects them: + * + * 1. A property carrying boolean `required: true`. `cleanupRequired()` only + * acts when `required` is an array, so the scalar passes through; Gemini + * declares `required` as `repeated string`. + * 2. A nested bare property map (`{ opts: { limit: { type: "number" } } }`). + * `normalizeInputSchema()` only expands string shorthands at the skill root. + * + * Diego named the missing pre-pass after CLIProxyAPI + * `normalizeMalformedSchemaObjects`: promote boolean `required` onto the parent + * array, lift a bare property map into `{ type: "object", properties }`. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { cleanJSONSchemaForAntigravity } = await import( + "../../open-sse/translator/helpers/geminiHelper.ts" +); +const { buildGeminiTools } = await import( + "../../open-sse/translator/helpers/geminiToolsSanitizer.ts" +); + +function paramsOf(tools: ReturnType): Record { + const first = tools[0] as { functionDeclarations?: Array<{ parameters?: unknown }> }; + return first.functionDeclarations?.[0]?.parameters as Record; +} + +function hasBooleanRequired(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + if (Array.isArray(value)) return value.some(hasBooleanRequired); + const record = value as Record; + if (typeof record.required === "boolean") return true; + return Object.values(record).some(hasBooleanRequired); +} + +test("#12269 lifts boolean required:true off a string property onto the parent array", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + query: { type: "string", required: true }, + }, + }) as Record; + + const properties = cleaned.properties as Record>; + assert.equal(properties.query.type, "string"); + assert.equal("required" in properties.query, false, "scalar required must leave the property"); + assert.deepEqual(cleaned.required, ["query"]); + assert.equal(hasBooleanRequired(cleaned), false); +}); + +test("#12269 drops required:false instead of promoting it", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + query: { type: "string", required: false }, + hint: { type: "string" }, + }, + }) as Record; + + const properties = cleaned.properties as Record>; + assert.equal("required" in properties.query, false); + assert.equal(cleaned.required, undefined); +}); + +test("#12269 lifts a nested bare property map into type/object + properties", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + opts: { limit: { type: "number" } }, + }, + }) as Record; + + const properties = cleaned.properties as Record>; + const opts = properties.opts; + assert.equal(opts.type, "object"); + assert.equal("limit" in opts, false, "bare key must move under properties"); + const optsProps = opts.properties as Record>; + assert.equal(optsProps.limit.type, "number"); +}); + +test("#12269 boolean required and bare map survive buildGeminiTools together", () => { + const tools = buildGeminiTools([ + { + type: "function", + function: { + name: "skill_search", + parameters: { + type: "object", + properties: { + query: { type: "string", required: true }, + opts: { limit: { type: "number" } }, + }, + }, + }, + }, + ]); + + const params = paramsOf(tools); + const properties = params.properties as Record>; + assert.equal(properties.query.type, "string"); + assert.equal("required" in properties.query, false); + assert.deepEqual(params.required, ["query"]); + const opts = properties.opts; + assert.equal(opts.type, "object"); + assert.equal((opts.properties as Record>).limit.type, "number"); + assert.equal(hasBooleanRequired(params), false); +}); + +test("#12269 does not wrap schema-keyword objects as property maps", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + options: { + additionalProperties: { type: "string" }, + }, + }, + }) as Record; + + const properties = cleaned.properties as Record>; + assert.deepEqual(properties.options, {}); + assert.equal("properties" in properties.options, false); +}); + +test("#12269 strips unsupported validation keywords without wrapping them as property maps", () => { + // These keys are in GEMINI_UNSUPPORTED_SCHEMA_KEYS (Gemini 400s on them); + // they must be REMOVED, and removal must not go through the bare-map lift + // (which would turn `{minLength: 1}` into `{type:"object", properties:{...}}`). + for (const [keyword, value] of Object.entries({ + minLength: 1, + maxLength: 8, + multipleOf: 2, + minItems: 1, + maxItems: 4, + uniqueItems: true, + })) { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + value: { [keyword]: value }, + }, + }) as Record; + + const properties = cleaned.properties as Record>; + assert.deepEqual(properties.value, {}, `unsupported ${keyword} must be stripped`); + assert.equal("properties" in properties.value, false); + } +}); + +test("#12269 preserves supported validation keywords without wrapping them", () => { + // `minimum`/`maximum`/`pattern` are accepted by Antigravity and must survive + // untouched; `minProperties`/`maxProperties` are not in the strip set either. + for (const [keyword, value] of Object.entries({ + minimum: 0, + maximum: 10, + pattern: "^[a-z]+$", + minProperties: 1, + })) { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + value: { [keyword]: value }, + }, + }) as Record; + + const properties = cleaned.properties as Record>; + assert.deepEqual(properties.value, { [keyword]: value }, `supported ${keyword} must be preserved`); + assert.equal("properties" in properties.value, false); + } +}); + +test("#12269 recursively lifts more than one nested bare property map", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + opts: { settings: { limit: { type: "number" } } }, + }, + }) as Record; + + const opts = (cleaned.properties as Record>).opts; + const settings = (opts.properties as Record>).settings; + assert.equal(opts.type, "object"); + assert.equal(settings.type, "object"); + assert.equal( + (settings.properties as Record>).limit.type, + "number" + ); +}); + +test("#12269 preserves a pre-existing parent required entry without duplication", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + query: { type: "string", required: true }, + }, + required: ["query"], + }) as Record; + + assert.deepEqual(cleaned.required, ["query"]); +}); + +test("#12269 removes every non-array property-level required value", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + numeric: { type: "string", required: 1 }, + textual: { type: "string", required: "yes" }, + nil: { type: "string", required: null }, + }, + }) as Record; + + const properties = cleaned.properties as Record>; + assert.equal("required" in properties.numeric, false); + assert.equal("required" in properties.textual, false); + assert.equal("required" in properties.nil, false); + assert.equal(cleaned.required, undefined); +}); + +test("#12269 does not promote an object whose only child is an array", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + malformed: { values: [1, 2, 3] }, + }, + }) as Record; + + const malformed = (cleaned.properties as Record>).malformed; + assert.equal(malformed.type, undefined); + assert.equal(malformed.properties, undefined); +}); + +test("#12269 promotes required:true from a typed object child before cleaning it", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { + config: { + type: "object", + required: true, + properties: { + timeout: { type: "number" }, + }, + }, + }, + }) as Record; + + assert.deepEqual(cleaned.required, ["config"]); + const properties = cleaned.properties as Record>; + assert.equal("required" in properties.config, false); +}); + +test("#12269 preserves a well-formed object schema byte-stable on required/properties", () => { + const input = { + type: "object", + properties: { + query: { type: "string" }, + limit: { type: "number" }, + }, + required: ["query"], + }; + const cleaned = cleanJSONSchemaForAntigravity(input) as Record; + const properties = cleaned.properties as Record>; + assert.equal(properties.query.type, "string"); + assert.equal(properties.limit.type, "number"); + assert.deepEqual(cleaned.required, ["query"]); +}); diff --git a/tests/unit/gemini-responses-error-redaction.test.ts b/tests/unit/gemini-responses-error-redaction.test.ts new file mode 100644 index 0000000000..f8814b3e8c --- /dev/null +++ b/tests/unit/gemini-responses-error-redaction.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { translateResponse, initState } from "../../open-sse/translator/index.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +test("Gemini keeps raw failure wording internal but projects response.completed.error", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + const hostileMessage = + "Gemini failed at /srv/omniroute/private-runtime.ts:71:3 token=sk-gemini-secret-123456"; + + const translated = translateResponse( + FORMATS.GEMINI, + FORMATS.OPENAI_RESPONSES, + { + response: { + error: { + code: 503, + status: "UNAVAILABLE", + message: hostileMessage, + api_key: "sk-gemini-secret-abcdef", + }, + }, + }, + state + ); + assert.equal(translated?.length ?? 0, 0); + assert.match(state.upstreamError?.message ?? "", /private-runtime\.ts/); + + const flushed = translateResponse(FORMATS.GEMINI, FORMATS.OPENAI_RESPONSES, null, state); + const completed = flushed.find((event) => event?.data?.type === "response.completed"); + assert.ok(completed); + assert.equal(completed.data.response.status, "failed"); + + const publicError = JSON.stringify(completed.data.response.error); + assert.doesNotMatch(publicError, /private-runtime\.ts/); + assert.doesNotMatch(publicError, /sk-gemini-secret/); + assert.doesNotMatch(publicError, /api_key/); + assert.equal(completed.data.response.error.code, "503"); +}); diff --git a/tests/unit/gen-budget-card-svg.test.ts b/tests/unit/gen-budget-card-svg.test.ts new file mode 100644 index 0000000000..a976ad3b6d --- /dev/null +++ b/tests/unit/gen-budget-card-svg.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { computeFreeModelTotals } from "@omniroute/open-sse/config/freeModelCatalog.ts"; + +const fmt = (n: number) => (n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : Math.round(n / 1e6) + "M"); + +test("the budget card prints the catalog's own totals (no regex-parsed subset)", () => { + const out = path.join(mkdtempSync(path.join(os.tmpdir(), "budget-card-")), "card.svg"); + execFileSync( + process.execPath, + ["--import", "tsx/esm", "scripts/research/gen-budget-card-svg.mjs", "--out", out], + { stdio: "pipe" } + ); + const svg = readFileSync(out, "utf8"); + const t = computeFreeModelTotals(); + assert.ok(svg.includes(`~${fmt(t.steadyRecurringTokens)}`), "steady figure"); + assert.ok(svg.includes(`~${fmt(t.firstMonthRealisticTokens)}`), "first-month figure"); + assert.ok(svg.includes(`${t.uncappedProviders.length} permanently-free`), "uncapped count"); + if (t.gatedRecurringTokens > 0) { + assert.ok(svg.includes("behind regional identity verification"), "gated line"); + } + + // The committed card is a generated artifact: it must be exactly what the + // generator produces today, or the docs ship a stale picture of the totals. + const committed = readFileSync( + path.join(import.meta.dirname, "../../docs/screenshots/free-tier-budget-card.svg"), + "utf8" + ); + assert.equal( + svg, + committed, + "docs/screenshots/free-tier-budget-card.svg is stale — regenerate with " + + "`node --import tsx/esm scripts/research/gen-budget-card-svg.mjs`" + ); +}); + +test("--out without a path fails loudly instead of writing to undefined", () => { + assert.throws(() => + execFileSync( + process.execPath, + ["--import", "tsx/esm", "scripts/research/gen-budget-card-svg.mjs", "--out"], + { stdio: "pipe" } + ) + ); +}); diff --git a/tests/unit/generation-throughput.test.ts b/tests/unit/generation-throughput.test.ts new file mode 100644 index 0000000000..bf71f68efb --- /dev/null +++ b/tests/unit/generation-throughput.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + attachTokensPerSecond, + generationDurationMs, + tokensPerSecond, +} from "../../open-sse/utils/generationThroughput.ts"; +import { filterUsageForFormat } from "../../open-sse/utils/usageTracking.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { createStreamTiming } from "../../open-sse/utils/streamTiming.ts"; +import { + buildOmniRouteResponseMetaHeaders, + buildOmniRouteSseMetadataComment, +} from "../../src/domain/omnirouteResponseMeta.ts"; +import { OMNIROUTE_RESPONSE_HEADERS } from "../../src/shared/constants/headers.ts"; + +test("#12616 tok/s excludes TTFT (200 tokens over 2s generation after 3s TTFT)", () => { + const generationMs = generationDurationMs(5000, 3000); + assert.equal(generationMs, 2000); + assert.equal(tokensPerSecond(200, generationMs), 100); +}); + +test("#12616 tok/s is omitted when TTFT is unknown (do not use tokens/total_latency)", () => { + assert.equal(generationDurationMs(5000, null), null); + assert.equal(tokensPerSecond(200, null), null); + const usage = attachTokensPerSecond({ prompt_tokens: 10, completion_tokens: 200 }, null); + assert.equal((usage as { tokens_per_second?: number }).tokens_per_second, undefined); +}); + +test("#12616 tok/s is omitted when generation duration is not positive", () => { + assert.equal(generationDurationMs(3000, 3000), null); + assert.equal(generationDurationMs(2000, 3000), null); + assert.equal(tokensPerSecond(0, 2000), null); +}); + +test("#12616 filterUsageForFormat keeps tokens_per_second for OpenAI and Claude", () => { + const usage = { prompt_tokens: 10, completion_tokens: 20, tokens_per_second: 42.5 }; + const openai = filterUsageForFormat(usage, FORMATS.OPENAI) as Record; + const claude = filterUsageForFormat( + { input_tokens: 10, output_tokens: 20, tokens_per_second: 42.5 }, + FORMATS.CLAUDE + ) as Record; + assert.equal(openai.tokens_per_second, 42.5); + assert.equal(claude.tokens_per_second, 42.5); +}); + +test("#12616 headers omit tok/s without ttftMs and emit it when TTFT is known", () => { + const without = buildOmniRouteResponseMetaHeaders({ + provider: "openai", + model: "gpt-4o-mini", + latencyMs: 5000, + usage: { prompt_tokens: 11, completion_tokens: 200 }, + }); + assert.equal(without[OMNIROUTE_RESPONSE_HEADERS.tokensPerSecond], undefined); + + const withTtft = buildOmniRouteResponseMetaHeaders({ + provider: "openai", + model: "gpt-4o-mini", + latencyMs: 5000, + ttftMs: 3000, + usage: { prompt_tokens: 11, completion_tokens: 200 }, + }); + assert.equal(withTtft[OMNIROUTE_RESPONSE_HEADERS.tokensPerSecond], "100.000"); +}); + +test("#12616 SSE comment carries tok/s from usage.tokens_per_second when TTFT is unknown", () => { + const comment = buildOmniRouteSseMetadataComment({ + provider: "openai", + model: "gpt-4o-mini", + latencyMs: 50, + usage: { prompt_tokens: 4, completion_tokens: 2, tokens_per_second: 12.5 }, + }); + assert.match(comment, /^: x-omniroute-tokens-per-second=12.500/m); +}); + +test("#12616 StreamTiming.withTps attaches tok/s after first forward", async () => { + const t = createStreamTiming(); + t.markForward(); + await new Promise((r) => setTimeout(r, 25)); + const usage = t.withTps({ prompt_tokens: 1, completion_tokens: 100 }); + const tps = (usage as { tokens_per_second?: number }).tokens_per_second; + assert.equal(typeof tps, "number"); + assert.ok(tps! > 0); +}); diff --git a/tests/unit/github-copilot-model-discovery.test.ts b/tests/unit/github-copilot-model-discovery.test.ts index 77c5b11a91..83ff7ee24e 100644 --- a/tests/unit/github-copilot-model-discovery.test.ts +++ b/tests/unit/github-copilot-model-discovery.test.ts @@ -38,6 +38,20 @@ const MOCK_COPILOT_MODELS_RESPONSE = { policy: { state: "enabled" }, capabilities: { type: "chat", limits: { max_context_window_tokens: 128000 } }, }, + { + id: "disabled-by-policy", + name: "Disabled by policy", + model_picker_enabled: true, + policy: { state: "disabled" }, + capabilities: { type: "chat" }, + }, + { + id: "hidden-from-picker", + name: "Hidden from picker", + model_picker_enabled: false, + policy: { state: "enabled" }, + capabilities: { type: "chat" }, + }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", @@ -80,6 +94,8 @@ test("#3120 parseGitHubCopilotModels keeps every entitled CHAT model (capability assert.equal(gpt.owned_by, "github"); assert.ok(!ids.includes("text-embedding-3-small"), "embeddings models are skipped"); assert.ok(!ids.includes("gpt-41-copilot"), "completion utility models are skipped"); + assert.ok(!ids.includes("disabled-by-policy"), "policy.state=disabled is not routable"); + assert.ok(!ids.includes("hidden-from-picker"), "model_picker_enabled=false is not routable"); }); test("#3121 a model NOT in the live response is not advertised (entitlement filtering)", () => { diff --git a/tests/unit/github-live-catalog-combo-12137.test.ts b/tests/unit/github-live-catalog-combo-12137.test.ts new file mode 100644 index 0000000000..ccfd1054e4 --- /dev/null +++ b/tests/unit/github-live-catalog-combo-12137.test.ts @@ -0,0 +1,86 @@ +/** + * #12137 — explicit GitHub combo members vs live synced catalog. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { catalogContainsModel } from "../../src/lib/db/models/activeSyncedCatalog.ts"; +import { + comboCheckProvider, + ghComboGate, +} from "../../src/sse/handlers/chat/githubLiveCatalogFilter.ts"; + +test("fail-open when the GitHub catalog is not authoritative yet", () => { + assert.equal( + catalogContainsModel( + { + authoritative: false, + models: [{ id: "claude-sonnet-5", name: "Claude Sonnet 5", source: "imported" }], + }, + "claude-sonnet-5" + ), + null + ); +}); + +test("rejects explicit members missing from an authoritative GitHub catalog", () => { + assert.equal( + catalogContainsModel( + { + authoritative: true, + models: [{ id: "claude-sonnet-5", name: "Claude Sonnet 5", source: "imported" }], + }, + "github/claude-fable-5" + ), + false + ); +}); + +test("accepts prefixed and bare ids that are in the live catalog", () => { + const catalog = { + authoritative: true, + models: [{ id: "claude-sonnet-5", name: "Claude Sonnet 5", source: "imported" }], + }; + assert.equal(catalogContainsModel(catalog, "claude-sonnet-5"), true); + assert.equal(catalogContainsModel(catalog, "github/claude-sonnet-5"), true); +}); + +test("comboCheckProvider applies the prefix-override guard", () => { + assert.equal(comboCheckProvider("github/claude-sonnet-5", { provider: "github" }), "github"); + assert.equal( + comboCheckProvider("github/claude-sonnet-5", { provider: "github" }, "github"), + "github" + ); + assert.equal( + comboCheckProvider("xiaomi/mimo-v2-flash", { provider: "xiaomi" }, "opengate"), + "opengate" + ); + assert.equal(comboCheckProvider("gh/claude-sonnet-5", { provider: "github" }, "gh"), "github"); +}); + +test("ghComboGate allows undetermined providers and fail-opens unsynced catalogs", async () => { + const scope = {}; + const unsynced = async () => ({ + authoritative: false, + models: [{ id: "claude-sonnet-5", name: "Claude Sonnet 5", source: "imported" as const }], + }); + assert.equal(await ghComboGate(scope, "", "claude-sonnet-5", unsynced), true); + assert.equal(await ghComboGate(scope, "openai", "gpt-4", unsynced), null); + assert.equal(await ghComboGate(scope, "github", "claude-sonnet-5", unsynced), null); +}); + +test("ghComboGate skips GitHub members missing from an authoritative catalog", async () => { + const scope = {}; + let loads = 0; + const load = async () => { + loads += 1; + return { + authoritative: true, + models: [{ id: "claude-sonnet-5", name: "Claude Sonnet 5", source: "imported" as const }], + }; + }; + assert.equal(await ghComboGate(scope, "github", "claude-fable-5", load), false); + assert.equal(await ghComboGate(scope, "github", "claude-sonnet-5", load), null); + assert.equal(await ghComboGate(scope, "github", "github/claude-sonnet-5", load), null); + assert.equal(loads, 1, "catalog fetch is memoized per request scope"); + assert.equal(await ghComboGate({}, "gh", "claude-fable-5", load), false); +}); diff --git a/tests/unit/helpers/runIsolatedBoundaryFixture.ts b/tests/unit/helpers/runIsolatedBoundaryFixture.ts new file mode 100644 index 0000000000..07cc672e28 --- /dev/null +++ b/tests/unit/helpers/runIsolatedBoundaryFixture.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); +const CHILD_PATH = "/usr/local/bin:/usr/bin:/bin"; +const CHILD_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +type IsolatedBoundaryFixtureOptions = { + fixtureUrl: URL; + expectedTests: number; + label: string; + timeoutMs?: number; +}; + +export function runIsolatedBoundaryFixture({ + fixtureUrl, + expectedTests, + label, + timeoutMs = 180_000, +}: IsolatedBoundaryFixtureOptions): void { + const root = mkdtempSync(join(tmpdir(), "omniroute-public-error-child-")); + const dataDir = join(root, "data"); + const pluginsDir = join(root, "plugins"); + mkdirSync(dataDir, { recursive: true }); + mkdirSync(pluginsDir, { recursive: true }); + + try { + const result = spawnSync( + process.execPath, + ["--import", "tsx/esm", "--test", "--test-reporter=tap", fileURLToPath(fixtureUrl)], + { + cwd: REPO_ROOT, + encoding: "utf8", + env: { + APP_LOG_TO_FILE: "false", + API_KEY_SECRET: "public-error-boundary-fixture-secret", + DATA_DIR: dataDir, + DISABLE_SQLITE_AUTO_BACKUP: "true", + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8", + NODE_ENV: "test", + OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK: "true", + OMNIROUTE_PLUGINS_DIR: pluginsDir, + PATH: CHILD_PATH, + TZ: "UTC", + }, + maxBuffer: CHILD_MAX_BUFFER_BYTES, + timeout: timeoutMs, + } + ); + const diagnostics = [ + `${label} child status=${String(result.status)} signal=${String(result.signal)}`, + result.error ? `error=${String(result.error)}` : "", + `stdout:\n${result.stdout}`, + `stderr:\n${result.stderr}`, + ] + .filter(Boolean) + .join("\n"); + + assert.equal(result.error, undefined, diagnostics); + assert.equal(result.signal, null, diagnostics); + assert.equal(result.status, 0, diagnostics); + assert.match(result.stdout, new RegExp(`# tests ${expectedTests}(?:\\r?\\n|$)`), diagnostics); + assert.match(result.stdout, new RegExp(`# pass ${expectedTests}(?:\\r?\\n|$)`), diagnostics); + assert.match(result.stdout, /# fail 0(?:\r?\n|$)/, diagnostics); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} diff --git a/tests/unit/i18n-cc-onboarding-placeholder-12302.test.ts b/tests/unit/i18n-cc-onboarding-placeholder-12302.test.ts new file mode 100644 index 0000000000..68fe24268b --- /dev/null +++ b/tests/unit/i18n-cc-onboarding-placeholder-12302.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// #12302: ccOnboardingKeyPlaceholder used raw angle brackets () in all 43 locale files. next-intl's IntlMessageFormat parser treated +// these as rich-text tags and threw INVALID_MESSAGE: INVALID_TAG, crashing the +// Claude Code onboarding block. The fix wraps values in ICU single quotes so +// angle brackets render literally. + +const messagesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../src/i18n/messages" +); + +function findNested(obj: unknown, key: string): string | undefined { + if (obj === null || typeof obj !== "object") return undefined; + for (const [k, v] of Object.entries(obj as Record)) { + if (k === key && typeof v === "string") return v; + if (v !== null && typeof v === "object") { + const found = findNested(v, key); + if (found !== undefined) return found; + } + } + return undefined; +} + +const localeFiles = readdirSync(messagesDir) + .filter((f) => f.endsWith(".json")) + .sort(); + +test("ccOnboardingKeyPlaceholder exists in all locale files", () => { + for (const file of localeFiles) { + const messages = JSON.parse(readFileSync(path.join(messagesDir, file), "utf8")); + const value = findNested(messages, "ccOnboardingKeyPlaceholder"); + assert.ok(value, `ccOnboardingKeyPlaceholder must exist in ${file}`); + } +}); + +test("ccOnboardingKeyPlaceholder compiles without INVALID_TAG in all locales (#12302)", async () => { + const { IntlMessageFormat } = await import("intl-messageformat"); + + for (const file of localeFiles) { + const messages = JSON.parse(readFileSync(path.join(messagesDir, file), "utf8")); + const value = findNested(messages, "ccOnboardingKeyPlaceholder"); + assert.ok(value, `ccOnboardingKeyPlaceholder must exist in ${file}`); + + const locale = file.replace(".json", ""); + let threw = false; + try { + const fmt = new IntlMessageFormat(value, locale); + fmt.format(); + } catch (err) { + threw = true; + assert.fail(`ccOnboardingKeyPlaceholder in ${file} threw during compilation: ${err}`); + } + assert.ok(!threw, `ccOnboardingKeyPlaceholder in ${file} must not throw`); + } +}); + +test("ccOnboardingKeyPlaceholder renders literal angle brackets in all locales", async () => { + const { IntlMessageFormat } = await import("intl-messageformat"); + + for (const file of localeFiles) { + const messages = JSON.parse(readFileSync(path.join(messagesDir, file), "utf8")); + const value = findNested(messages, "ccOnboardingKeyPlaceholder"); + assert.ok(value, `ccOnboardingKeyPlaceholder must exist in ${file}`); + + const locale = file.replace(".json", ""); + const fmt = new IntlMessageFormat(value, locale); + const result = String(fmt.format()); + + assert.ok( + result.includes("<"), + `ccOnboardingKeyPlaceholder in ${file} must render literal '<', got: ${result}` + ); + assert.ok( + result.includes(">"), + `ccOnboardingKeyPlaceholder in ${file} must render literal '>', got: ${result}` + ); + // Must NOT be treated as a tag — the output should NOT contain "INVALID_TAG" + // or empty output (which happens when tags are stripped). + assert.ok( + result.length > 0, + `ccOnboardingKeyPlaceholder in ${file} must not render empty string` + ); + } +}); diff --git a/tests/unit/kimi-k3-effort-tiers-12299.test.ts b/tests/unit/kimi-k3-effort-tiers-12299.test.ts new file mode 100644 index 0000000000..d4e0619c6b --- /dev/null +++ b/tests/unit/kimi-k3-effort-tiers-12299.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { + buildSyncedCapabilities, + mergeSyncedCapabilities, +} from "../../src/app/api/v1/models/syncedCapabilities.ts"; +import { + shouldExposeSyncedEffortVariants, + appendSyncedEffortVariants, +} from "../../open-sse/utils/syncedEffortVariants.ts"; + +// #12299: Kimi K3's supportedThinkingEfforts (["low", "high", "max"]) were +// suppressed on the BASE model by isSkippedEffortProvider in +// effectiveEffortTiers(), leaving catalog-only clients with no tiers to copy. +// Fix: publish effort_tiers on the base model while still preventing +// synthetic - variant generation for kimi providers. + +const KIMI_K3_TIERS = ["low", "high", "max"]; + +test("Kimi K3 base model publishes effort_tiers via buildSyncedCapabilities (#12299)", () => { + const caps = buildSyncedCapabilities( + { id: "k3", supportsThinking: true, supportedThinkingEfforts: KIMI_K3_TIERS }, + "kimi-coding-apikey" + ); + assert.ok(caps, "capabilities must be defined for kimi K3"); + assert.deepEqual( + caps.effort_tiers, + KIMI_K3_TIERS, + "kimi K3 base model must publish effort_tiers low/high/max" + ); +}); + +test("Kimi K3-256k base model publishes effort_tiers via buildSyncedCapabilities (#12299)", () => { + const caps = buildSyncedCapabilities( + { id: "k3-256k", supportsThinking: true, supportedThinkingEfforts: KIMI_K3_TIERS }, + "kimi-coding-apikey" + ); + assert.ok(caps, "capabilities must be defined for kimi K3-256k"); + assert.deepEqual( + caps.effort_tiers, + KIMI_K3_TIERS, + "kimi K3-256k base model must publish effort_tiers low/high/max" + ); +}); + +test("Kimi K3 merge path also publishes effort_tiers (#12299)", () => { + const merged = mergeSyncedCapabilities( + { tool_calling: true }, + { id: "k3", supportsThinking: true, supportedThinkingEfforts: KIMI_K3_TIERS }, + "kimi-coding-apikey" + ); + assert.ok(merged, "merged capabilities must be defined"); + assert.deepEqual( + merged.effort_tiers, + KIMI_K3_TIERS, + "merge path must publish kimi K3 effort_tiers" + ); + assert.equal(merged.tool_calling, true, "existing tool_calling must be preserved"); +}); + +test("shouldExposeSyncedEffortVariants still prevents synthetic kimi variants", () => { + // The base model should NOT generate synthetic - entries + assert.equal( + shouldExposeSyncedEffortVariants({ + id: "kimi/k3", + owned_by: "kimi-coding-apikey", + capabilities: { effort_tiers: KIMI_K3_TIERS }, + }), + false, + "must not generate synthetic kimi/k3-low, kimi/k3-high, etc." + ); +}); + +test("appendSyncedEffortVariants does not create kimi variant entries", () => { + const models = [ + { + id: "kimi-coding-apikey/k3", + owned_by: "kimi-coding-apikey", + capabilities: { effort_tiers: KIMI_K3_TIERS }, + }, + ]; + const result = appendSyncedEffortVariants(models); + assert.equal(result.length, 1, "must not add synthetic variant entries for kimi"); + assert.equal(result[0].id, "kimi-coding-apikey/k3", "original entry must be unchanged"); +}); + +test("kimi K3 static registry tiers match synced metadata", () => { + // Verify the static registry in runtime.ts has the correct tiers + const runtimePath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../open-sse/config/providers/registry/kimi/coding/runtime.ts" + ); + const content = readFileSync(runtimePath, "utf8"); + + // Verify the static thinking policies declare the same tiers + assert.ok( + content.includes('"low", "high", "max"'), + "KIMI_CODE_STATIC_THINKING_POLICIES.k3 must declare low/high/max" + ); +}); diff --git a/tests/unit/mcp-public-error-boundaries.test.ts b/tests/unit/mcp-public-error-boundaries.test.ts new file mode 100644 index 0000000000..92095c8f85 --- /dev/null +++ b/tests/unit/mcp-public-error-boundaries.test.ts @@ -0,0 +1,11 @@ +import test from "node:test"; + +import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts"; + +test("MCP public error boundaries pass in an isolated child process", () => { + runIsolatedBoundaryFixture({ + fixtureUrl: new URL("./fixtures/mcp-public-error-boundaries.fixture.ts", import.meta.url), + expectedTests: 4, + label: "MCP public error boundaries", + }); +}); diff --git a/tests/unit/moderations-handler.test.ts b/tests/unit/moderations-handler.test.ts index 68ec32847a..640d0a68bc 100644 --- a/tests/unit/moderations-handler.test.ts +++ b/tests/unit/moderations-handler.test.ts @@ -2,9 +2,8 @@ import test from "node:test"; import assert from "node:assert/strict"; const { handleModeration } = await import("../../open-sse/handlers/moderations.ts"); -const { MODERATION_PROVIDERS, getModerationProvider, parseModerationModel } = await import( - "../../open-sse/config/moderationRegistry.ts" -); +const { MODERATION_PROVIDERS, getModerationProvider, parseModerationModel } = + await import("../../open-sse/config/moderationRegistry.ts"); const originalFetch = globalThis.fetch; @@ -136,6 +135,76 @@ test("handleModeration returns upstream error payloads with CORS headers", async assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/); }); +test("handleModeration sanitizes structured upstream error bodies", async () => { + globalThis.fetch = async () => + Response.json( + { + error: { + message: "quota metadata at /srv/provider/private.json", + api_key: "credential-value-12345", + }, + }, + { status: 429 } + ); + + const response = await handleModeration({ + body: { model: "openai/text-moderation-latest", input: "check this" }, + credentials: { apiKey: "sk-test" }, + }); + const payload = (await response.json()) as { + error: { message: string; api_key?: string }; + }; + + assert.equal(response.status, 429); + assert.equal(payload.error.api_key, undefined); + assert.doesNotMatch(payload.error.message, /srv\/provider/i); + assert.doesNotMatch(JSON.stringify(payload), /credential-value-12345/i); +}); + +test("handleModeration canonicalizes blank, plaintext, and mislabeled upstream failures", async () => { + const scenarios = [ + { name: "blank", body: " ", contentType: "application/json" }, + { + name: "plaintext", + body: "access_token=moderation-plain-secret at /srv/private/moderation.txt", + contentType: "text/plain", + }, + { + name: "mislabeled", + body: "api_key=moderation-html-secret at /srv/private/error.html", + contentType: "application/json", + }, + ]; + + for (const scenario of scenarios) { + globalThis.fetch = async () => + new Response(scenario.body, { + status: 502, + headers: { "content-type": scenario.contentType }, + }); + const response = await handleModeration({ + body: { model: "openai/text-moderation-latest", input: "check this" }, + credentials: { apiKey: "sk-test" }, + }); + const text = await response.text(); + const payload = JSON.parse(text) as { error: { message: string } }; + + assert.equal(response.status, 502, scenario.name); + assert.match(response.headers.get("content-type") || "", /application\/json/i, scenario.name); + assert.match( + response.headers.get("access-control-allow-methods") || "", + /OPTIONS/, + scenario.name + ); + assert.equal(typeof payload.error.message, "string", scenario.name); + assert.doesNotMatch( + text, + /moderation-plain-secret|moderation-html-secret|srv\/private|/i, + scenario.name + ); + } +}); + test("handleModeration returns a 500 when the upstream request throws", async () => { globalThis.fetch = async () => { throw new Error("socket closed"); diff --git a/tests/unit/monitoring-health-cached-credential.test.ts b/tests/unit/monitoring-health-cached-credential.test.ts new file mode 100644 index 0000000000..91a8b0e127 --- /dev/null +++ b/tests/unit/monitoring-health-cached-credential.test.ts @@ -0,0 +1,111 @@ +/** + * #12532 — GET /api/monitoring/health must serve cached credentialHealth + * immediately and must not run live credential probes on the request path. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-health-cred-cache-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; +process.env.JWT_SECRET = "test-health-cred-cache-secret"; + +await import("../../src/lib/db/core.ts"); + +const { + getCachedCredentialHealthSummary, + getCredentialHealthSummary, + __test_resetCredentialHealthCache, + __test_putCredentialHealth, +} = await import("../../src/lib/credentialHealth/cache.ts"); + +const { GET, __test_resetMonitoringHealthPayloadCache } = + await import("../../src/app/api/monitoring/health/route.ts"); + +const { SignJWT } = await import("jose"); +const AUTH_TOKEN = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("30d") + .sign(new TextEncoder().encode(process.env.JWT_SECRET as string)); + +function authedRequest(): Request { + return new Request("http://localhost/api/monitoring/health", { + method: "GET", + headers: { cookie: `auth_token=${AUTH_TOKEN}` }, + }); +} + +const STALE_MS = 11 * 60 * 1000; + +test("getCachedCredentialHealthSummary includes expired and stale rows without deleting them", () => { + __test_resetCredentialHealthCache(); + const lastTested = new Date(Date.now() - STALE_MS); + __test_putCredentialHealth({ + connectionId: "conn-stale", + provider: "openai", + status: "active", + lastTested, + expiresAt: Date.now() - 1000, + }); + + const summary = getCachedCredentialHealthSummary(); + assert.deepEqual(summary, { + total: 1, + healthy: 1, + failed: 0, + unknown: 0, + stale: 1, + }); + assert.deepEqual(getCredentialHealthSummary(), summary); + assert.deepEqual(getCachedCredentialHealthSummary(), summary); +}); + +test("GET /api/monitoring/health returns the stale cached summary immediately", async () => { + __test_resetCredentialHealthCache(); + __test_resetMonitoringHealthPayloadCache(); + const lastTested = new Date(Date.now() - STALE_MS); + __test_putCredentialHealth({ + connectionId: "conn-stale-get", + provider: "anthropic", + status: "error", + lastTested, + expiresAt: Date.now() - 5000, + }); + + const started = Date.now(); + const res = await GET(authedRequest()); + const elapsedMs = Date.now() - started; + const body = (await res.json()) as { + credentialHealth?: { + total: number; + healthy: number; + failed: number; + unknown: number; + stale: number; + }; + }; + + assert.equal(res.status, 200); + assert.deepEqual(body.credentialHealth, { + total: 1, + healthy: 0, + failed: 1, + unknown: 0, + stale: 1, + }); + assert.ok(elapsedMs < 2000, `stale summary must return immediately, took ${elapsedMs}ms`); +}); + +test("monitoring health route never imports live credential probes", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/app/api/monitoring/health/route.ts"), + "utf8" + ); + assert.doesNotMatch(source, /testSingleConnection/); + assert.doesNotMatch(source, /credentialHealth\/scheduler/); + assert.doesNotMatch(source, /forceSweep/); + assert.match(source, /getCachedCredentialHealthSummary/); +}); diff --git a/tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts b/tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts index 595161d64e..d37fb55cb8 100644 --- a/tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts +++ b/tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts @@ -29,6 +29,7 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const testSettings = { resilienceSettings: { providerCooldown: { enabled: true, minRetryCooldownMs: 5000, maxRetryCooldownMs: 300000 }, + comboCooldownWait: { enabled: false }, }, }; diff --git a/tests/unit/ocr-handler-dispatch.test.ts b/tests/unit/ocr-handler-dispatch.test.ts index 2474f6b0b2..df8495b095 100644 --- a/tests/unit/ocr-handler-dispatch.test.ts +++ b/tests/unit/ocr-handler-dispatch.test.ts @@ -38,6 +38,86 @@ test("mistral path posts once and returns the upstream body", async () => { assert.equal(data.pages[0].markdown, "ok"); }); +test("OCR sanitizes structured upstream error bodies", async () => { + const opaqueIdentifier = "AbC9xY7pQ2mN8vR4kL6z"; + const res = await handleOcr({ + body: { + model: "mistral/mistral-ocr-latest", + document: { type: "image_url", image_url: "https://x/y.png" }, + }, + credentials: { apiKey: "sk" }, + fetchImpl: async () => + Response.json( + { + error: { + message: "quota metadata at /srv/provider/private.json", + type: opaqueIdentifier, + code: opaqueIdentifier, + reason: opaqueIdentifier, + api_key: "credential-value-12345", + }, + }, + { status: 429 } + ), + sleepImpl: noSleep, + }); + const payload = (await res.json()) as { + error: { message: string; type?: string; code?: string; reason?: string; api_key?: string }; + }; + + assert.equal(res.status, 429); + assert.equal(payload.error.api_key, undefined); + assert.doesNotMatch(payload.error.message, /srv\/provider/i); + assert.doesNotMatch( + JSON.stringify(payload), + new RegExp(`credential-value-12345|${opaqueIdentifier}`, "i") + ); +}); + +test("OCR canonicalizes blank, plaintext, and mislabeled upstream failures", async () => { + const scenarios = [ + { name: "blank", body: " ", contentType: "application/json" }, + { + name: "plaintext", + body: "access_token=ocr-plain-secret at /srv/private/ocr.txt", + contentType: "text/plain", + }, + { + name: "mislabeled", + body: "api_key=ocr-html-secret at /srv/private/ocr.html", + contentType: "application/json", + }, + ]; + + for (const scenario of scenarios) { + const res = await handleOcr({ + body: { + model: "mistral/mistral-ocr-latest", + document: { type: "image_url", image_url: "https://x/y.png" }, + }, + credentials: { apiKey: "sk" }, + fetchImpl: async () => + new Response(scenario.body, { + status: 502, + headers: { "content-type": scenario.contentType }, + }), + sleepImpl: noSleep, + }); + const text = await res.text(); + const payload = JSON.parse(text) as { error: { message: string } }; + + assert.equal(res.status, 502, scenario.name); + assert.match(res.headers.get("content-type") || "", /application\/json/i, scenario.name); + assert.match(res.headers.get("access-control-allow-methods") || "", /OPTIONS/, scenario.name); + assert.equal(typeof payload.error.message, "string", scenario.name); + assert.doesNotMatch( + text, + /ocr-plain-secret|ocr-html-secret|srv\/private|/i, + scenario.name + ); + } +}); + test("azure DI path polls Operation-Location until succeeded", async () => { const { impl, calls } = fetchStub([ { status: 202, headers: { "Operation-Location": "https://poll/op/1" } }, diff --git a/tests/unit/oneminai-stream-error-boundary.test.ts b/tests/unit/oneminai-stream-error-boundary.test.ts new file mode 100644 index 0000000000..88cc402e6e --- /dev/null +++ b/tests/unit/oneminai-stream-error-boundary.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const fixturePath = fileURLToPath( + new URL("../fixtures/oneminai-stream-error-boundary.fixture.ts", import.meta.url) +); + +type ChildFailure = Error & { + stdout?: string | Buffer; + stderr?: string | Buffer; +}; + +test( + "1min.ai stream-error boundary passes in an isolated persistence subprocess", + { timeout: 180_000 }, + async () => { + const originalDataDir = process.env.DATA_DIR; + const originalPluginsDir = process.env.OMNIROUTE_PLUGINS_DIR; + const originalFetch = globalThis.fetch; + const testRoot = mkdtempSync(join(tmpdir(), "omniroute-onemin-stream-error-child-")); + const testDataDir = join(testRoot, "data"); + const testPluginsDir = join(testRoot, "plugins"); + + mkdirSync(testDataDir, { recursive: true }); + mkdirSync(testPluginsDir, { recursive: true }); + const childEnv: NodeJS.ProcessEnv = { + APP_LOG_TO_FILE: "false", + DATA_DIR: testDataDir, + DISABLE_SQLITE_AUTO_BACKUP: "true", + NODE_ENV: "test", + OMNIROUTE_PLUGINS_DIR: testPluginsDir, + }; + for (const name of ["PATH", "NODE_PATH", "LANG", "LC_ALL", "TZ", "TMPDIR"] as const) { + const value = process.env[name]; + if (value !== undefined) childEnv[name] = value; + } + // A nested `node --test` must create its own runner context instead of + // inheriting the parent's private reporter channel. + delete childEnv.NODE_TEST_CONTEXT; + + try { + let stdout = ""; + let stderr = ""; + try { + const child = await execFileAsync( + process.execPath, + ["--import", "tsx/esm", "--test", "--test-concurrency=1", fixturePath], + { + cwd: process.cwd(), + encoding: "utf8", + env: childEnv, + maxBuffer: 2 * 1024 * 1024, + timeout: 170_000, + } + ); + stdout = child.stdout; + stderr = child.stderr; + } catch (error) { + const failure = error as ChildFailure; + assert.fail( + [ + `isolated 1min.ai fixture failed: ${failure.message}`, + failure.stdout ? String(failure.stdout) : "", + failure.stderr ? String(failure.stderr) : "", + ] + .filter(Boolean) + .join("\n") + ); + } + + const childOutput = `${stdout}\n${stderr}`; + assert.match(childOutput, /tests 8/); + assert.match(childOutput, /pass 8/); + assert.match(childOutput, /fail 0/); + assert.doesNotMatch(childOutput, /not ok|failed to drain|stayed pending/i); + } finally { + rmSync(testRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + + assert.equal(process.env.DATA_DIR, originalDataDir); + assert.equal(process.env.OMNIROUTE_PLUGINS_DIR, originalPluginsDir); + assert.equal(globalThis.fetch, originalFetch); + } +); diff --git a/tests/unit/overloaded-not-provider-breaker.test.ts b/tests/unit/overloaded-not-provider-breaker.test.ts new file mode 100644 index 0000000000..0ba022cb94 --- /dev/null +++ b/tests/unit/overloaded-not-provider-breaker.test.ts @@ -0,0 +1,169 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isModelCapacityOverloadError } from "../../src/shared/utils/circuitBreaker.ts"; +import { + shouldTripProviderBreakerForResult, + classifyProviderBreakerResult, +} from "../../src/sse/handlers/chatPredicates.ts"; +import { shouldRecordProviderBreakerFailure } from "../../open-sse/services/combo/comboPredicates.ts"; + +/** + * Live incident 2026-09-03 (X500 offical-fable): Anthropic returned + * STREAM_EARLY_EOF wrapping "Overloaded" as HTTP 502. That 502 opened the + * whole-provider `claude` breaker. The single-target combo then pre-skipped + * with ALL_TARGETS_SKIPPED in ~43ms even though the account, pin, and quota + * were healthy. Model capacity (529 / Overloaded) is not a provider outage. + */ + +const OTHER_COMBO_ARGS = { + isStreamReadinessFailure: false, + sameProviderNext: false, + skipProviderBreaker: false, + requestScopedFailure: false, +} as const; + +const LIVE_EOF_OVERLOADED = "Stream ended before producing a non-ping SSE event: Overloaded"; +const PLAIN_EOF = "Stream ended before producing a non-ping SSE event"; + +test("isModelCapacityOverloadError: live STREAM_EARLY_EOF Overloaded text", () => { + assert.equal(isModelCapacityOverloadError(LIVE_EOF_OVERLOADED), true); +}); + +test("isModelCapacityOverloadError: bare Overloaded / HTTP 529", () => { + assert.equal(isModelCapacityOverloadError("Overloaded"), true); + assert.equal(isModelCapacityOverloadError("[529]: Overloaded"), true); + assert.equal(isModelCapacityOverloadError(529), true); + assert.equal(isModelCapacityOverloadError({ message: "overloaded_error" }), true); +}); + +test("isModelCapacityOverloadError: a plain early EOF is NOT capacity", () => { + assert.equal(isModelCapacityOverloadError(PLAIN_EOF), false); + assert.equal(isModelCapacityOverloadError("502 Bad Gateway"), false); + assert.equal(isModelCapacityOverloadError(null), false); + assert.equal(isModelCapacityOverloadError(undefined), false); +}); + +test("combo: STREAM_EARLY_EOF Overloaded 502 does NOT record a whole-provider breaker failure", () => { + assert.equal( + shouldRecordProviderBreakerFailure({ + ...OTHER_COMBO_ARGS, + isStreamReadinessFailure: true, + isStreamEarlyEof: true, + status: 502, + error: LIVE_EOF_OVERLOADED, + }), + false + ); +}); + +test("combo: a plain STREAM_EARLY_EOF 502 still records a breaker failure", () => { + assert.equal( + shouldRecordProviderBreakerFailure({ + ...OTHER_COMBO_ARGS, + isStreamReadinessFailure: true, + isStreamEarlyEof: true, + status: 502, + error: PLAIN_EOF, + }), + true + ); +}); + +test("combo: HTTP 529 Overloaded does not record even if someone later adds 529 to the status set", () => { + assert.equal( + shouldRecordProviderBreakerFailure({ + ...OTHER_COMBO_ARGS, + status: 529, + error: "[529]: Overloaded", + }), + false + ); +}); + +test("combo: HTTP 529 status alone does not record a breaker failure", () => { + assert.equal( + shouldRecordProviderBreakerFailure({ + ...OTHER_COMBO_ARGS, + status: 529, + error: "upstream error", + }), + false + ); +}); + +test("combo: a genuine 502 without Overloaded still records", () => { + assert.equal( + shouldRecordProviderBreakerFailure({ + ...OTHER_COMBO_ARGS, + status: 502, + error: "upstream error", + }), + true + ); +}); + +test("single-model: STREAM_EARLY_EOF Overloaded 502 does NOT trip the provider breaker", () => { + assert.equal( + shouldTripProviderBreakerForResult( + { + status: 502, + errorCode: "STREAM_EARLY_EOF", + errorType: "stream_early_eof", + error: LIVE_EOF_OVERLOADED, + }, + false, + false + ), + false + ); +}); + +test("single-model: a genuine 502 without Overloaded still trips", () => { + assert.equal( + shouldTripProviderBreakerForResult( + { status: 502, errorCode: null, errorType: null, error: "upstream error" }, + false, + false + ), + true + ); +}); + +test("single-model: HTTP 529 does not trip", () => { + assert.equal( + shouldTripProviderBreakerForResult( + { status: 529, errorCode: null, errorType: null, error: "Overloaded" }, + false, + false + ), + false + ); +}); + +test("classifyProviderBreakerResult: Overloaded 502 on the single-model path is ignore", () => { + assert.equal( + classifyProviderBreakerResult( + { + success: false, + status: 502, + errorCode: "STREAM_EARLY_EOF", + errorType: "stream_early_eof", + error: LIVE_EOF_OVERLOADED, + }, + false, + false + ), + "ignore" + ); +}); + +test("classifyProviderBreakerResult: Overloaded 529 on the single-model path is ignore", () => { + assert.equal( + classifyProviderBreakerResult( + { success: false, status: 529, errorCode: null, errorType: null, error: "Overloaded" }, + false, + false + ), + "ignore" + ); +}); diff --git a/tests/unit/provider-connection-test-error-boundaries.test.ts b/tests/unit/provider-connection-test-error-boundaries.test.ts new file mode 100644 index 0000000000..4db74062b0 --- /dev/null +++ b/tests/unit/provider-connection-test-error-boundaries.test.ts @@ -0,0 +1,14 @@ +import test from "node:test"; + +import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts"; + +test("provider connection error boundaries pass in an isolated child process", () => { + runIsolatedBoundaryFixture({ + fixtureUrl: new URL( + "./fixtures/provider-connection-test-error-boundaries.fixture.ts", + import.meta.url + ), + expectedTests: 3, + label: "provider connection error boundaries", + }); +}); diff --git a/tests/unit/provider-last-error-sanitization.test.ts b/tests/unit/provider-last-error-sanitization.test.ts new file mode 100644 index 0000000000..e7dee5071a --- /dev/null +++ b/tests/unit/provider-last-error-sanitization.test.ts @@ -0,0 +1,11 @@ +import test from "node:test"; + +import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts"; + +test("provider last-error persistence passes in an isolated child process", () => { + runIsolatedBoundaryFixture({ + fixtureUrl: new URL("./fixtures/provider-last-error-sanitization.fixture.ts", import.meta.url), + expectedTests: 1, + label: "provider last-error persistence", + }); +}); diff --git a/tests/unit/provider-validation-error-sanitization.test.ts b/tests/unit/provider-validation-error-sanitization.test.ts new file mode 100644 index 0000000000..4a725216b9 --- /dev/null +++ b/tests/unit/provider-validation-error-sanitization.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import { + projectProviderValidationResultForPublicResponse, + toValidationErrorResult, +} from "../../src/lib/providers/validation/transport.ts"; + +test("provider validation sanitizes thrown error details", () => { + const result = toValidationErrorResult( + new Error( + "Provider probe failed at /srv/private/provider-key.json " + + "access_token=provider-secret\n at validate (/srv/private/validator.ts:42:7)" + ) + ); + + assert.equal(result.valid, false); + assert.match(result.error, /Provider probe failed/i); + assert.doesNotMatch(result.error, /srv\/private|provider-secret|validator\.ts|\bat validate\b/i); + assert.equal(result.unsupported, false); +}); + +test("provider validation fails closed for hostile thrown values", () => { + const hostile = new Proxy( + {}, + { + getPrototypeOf(): never { + throw new Error("access_token=prototype-secret at /srv/private/prototype.ts:1:2"); + }, + get(_target, property): unknown { + if (property === "code" || property === "isRetryable") { + throw new Error("access_token=metadata-secret at /srv/private/metadata.ts:1:2"); + } + if (property === "toString") { + return () => { + throw new Error("access_token=coercion-secret at /srv/private/coercion.ts:1:2"); + }; + } + return undefined; + }, + } + ); + + assert.deepEqual(toValidationErrorResult(hostile), { + valid: false, + error: "Validation failed", + unsupported: false, + }); +}); + +test("provider validation route sanitizes unexpected failures before persistent logging", () => { + const routeSource = fs.readFileSync( + new URL("../../src/app/api/providers/validate/route.ts", import.meta.url), + "utf8" + ); + + assert.match( + routeSource, + /console\.log\(\s*"Error validating API key:",\s*sanitizeErrorMessage\(error\) \|\| "Validation failed"\s*\)/ + ); + assert.doesNotMatch(routeSource, /console\.log\(\s*"Error validating API key:",\s*error\s*\)/); +}); + +test("provider validation final response projection sanitizes validator errors and warnings", () => { + const projected = projectProviderValidationResultForPublicResponse({ + valid: false, + error: + "Provider echoed access_token=response-secret at /srv/private/provider.json\n" + + " at validate (/srv/private/validator.ts:42:7)", + warning: "Retry after reading C:\\Users\\admin\\private\\warning.json", + method: "probe", + }); + const serialized = JSON.stringify(projected); + + assert.equal(projected.valid, false); + assert.equal(projected.method, "probe"); + assert.doesNotMatch( + serialized, + /response-secret|srv\/private|validator\.ts|C:\\Users|warning\.json/i + ); +}); + +test("provider validation projection preserves intentionally empty fields without synthetic text", () => { + const projected = projectProviderValidationResultForPublicResponse({ + valid: false, + error: "", + warning: "", + }); + + assert.equal(projected.error, ""); + assert.equal(projected.warning, ""); +}); + +test("provider validation route applies the final response projection", () => { + const routeSource = fs.readFileSync( + new URL("../../src/app/api/providers/validate/route.ts", import.meta.url), + "utf8" + ); + + assert.match(routeSource, /projectProviderValidationResultForPublicResponse\(/); +}); diff --git a/tests/unit/quota-connection-recovery.test.ts b/tests/unit/quota-connection-recovery.test.ts index fa96ae8f93..ddd535c4e1 100644 --- a/tests/unit/quota-connection-recovery.test.ts +++ b/tests/unit/quota-connection-recovery.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { CREDITS_EXHAUSTED_STATUS, isCreditsExhaustedReprobeCandidate, + isExpiredReprobeCandidate, isRecoverableCooldownConnection, selectRecoverableConnections, runConnectionRecoveryTick, @@ -216,3 +217,36 @@ describe("connectionRecovery — mixed timestamp encodings", () => { assert.equal(isRecoverableCooldownConnection(conn, nowMs), false); }); }); + +describe("connectionRecovery — expired reprobe", () => { + const nowMs = 1_700_000_000_000; + const thirtyMinMs = 30 * 60 * 1000; + + it("should reprobe expired after 30m unless lastErrorType is a real deactivation", () => { + const old = { + id: "e-1", + testStatus: "expired", + lastErrorAt: new Date(nowMs - thirtyMinMs - 1000).toISOString(), + lastErrorType: "unauthorized", + }; + assert.equal(isExpiredReprobeCandidate(old, nowMs), true); + assert.equal( + isExpiredReprobeCandidate({ ...old, lastErrorType: "invalid_grant" }, nowMs), + false + ); + }); + + it("selectRecoverableConnections includes stale expired rows", () => { + const selected = selectRecoverableConnections( + [ + { + id: "e-1", + testStatus: "expired", + lastErrorAt: new Date(nowMs - thirtyMinMs - 1000).toISOString(), + }, + ], + nowMs + ); + assert.deepEqual(selected.map((c) => c.id), ["e-1"]); + }); +}); diff --git a/tests/unit/radar-feed-eligibility-gate.test.ts b/tests/unit/radar-feed-eligibility-gate.test.ts new file mode 100644 index 0000000000..e93158079f --- /dev/null +++ b/tests/unit/radar-feed-eligibility-gate.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { RadarFeedSchema } from "../../src/lib/radar/feedSchema.ts"; +import { applyFeed, type FeedModel, type MergedEntry } from "../../src/lib/radar/applyFeed.ts"; +import { baselineToMergedEntries } from "../../src/lib/radar/index.ts"; + +const fixture = JSON.parse( + readFileSync(new URL("../fixtures/radar-feed-canonical.json", import.meta.url), "utf8") +) as { models: Array> }; + +test("feed schema: eligibilityGate is optional, nullable and closed to unknown values", () => { + const absent = RadarFeedSchema.parse(structuredClone(fixture)); + assert.equal(absent.models[0]!.eligibilityGate, undefined); + const gated = structuredClone(fixture); + gated.models[0]!.eligibilityGate = "regional-identity"; + assert.equal(RadarFeedSchema.parse(gated).models[0]!.eligibilityGate, "regional-identity"); + const nulled = structuredClone(fixture); + nulled.models[0]!.eligibilityGate = null; + assert.equal(RadarFeedSchema.parse(nulled).models[0]!.eligibilityGate, null); + const bad = structuredClone(fixture); + bad.models[0]!.eligibilityGate = "vip"; + assert.throws(() => RadarFeedSchema.parse(bad)); +}); + +function feedModel(overrides: Partial = {}): FeedModel { + return { + provider: "modelscope", + modelId: "Qwen/Qwen3.5-397B-A17B", + displayName: "Qwen3.5 397B A17B (ModelScope)", + familyId: null, + freeType: "recurring-daily", + budget: { kind: "shared_pool", poolId: "modelscope-free", tokensPerMonth: 6_000_000 }, + limits: { rpm: null, rpd: null, tpm: null, tpd: null }, + contextWindow: null, + capabilities: { tools: null, vision: null, thinking: null }, + trainsOnPrompts: null, + tosRisk: "caution", + setup: null, + enabled: true, + ...overrides, + }; +} +const KEY = "modelscope:Qwen/Qwen3.5-397B-A17B"; +const noLocal = () => ({ + localOverrides: new Map>(), + tombstones: new Set(), +}); +const gatedBaseline = () => + baselineToMergedEntries([ + { + provider: "modelscope", + modelId: "Qwen/Qwen3.5-397B-A17B", + displayName: "Qwen3.5 397B A17B (ModelScope)", + monthlyTokens: 6_000_000, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: "modelscope-free", + tos: "caution", + eligibilityGate: "regional-identity", + }, + ]); + +test("baseline entries keep their gate through baselineToMergedEntries", () => { + assert.equal(gatedBaseline()[0]!.eligibilityGate, "regional-identity"); +}); + +test("a feed-only entry carries its gate into the merged catalog", () => { + const [m] = applyFeed({ + baseline: [], + feed: [feedModel({ eligibilityGate: "regional-identity" })], + ...noLocal(), + }); + assert.equal(m!.eligibilityGate, "regional-identity"); + assert.equal(m!.origin, "radar"); +}); + +test("a feed that does not know the field preserves the baseline gate; an explicit null clears it", () => { + const kept = applyFeed({ baseline: gatedBaseline(), feed: [feedModel()], ...noLocal() }); + assert.equal(kept[0]!.eligibilityGate, "regional-identity"); + const cleared = applyFeed({ + baseline: gatedBaseline(), + feed: [feedModel({ eligibilityGate: null })], + ...noLocal(), + }); + assert.equal(cleared[0]!.eligibilityGate, undefined); +}); + +test("a local override on the gate wins over the feed", () => { + const localOverrides = new Map>([ + [KEY, { eligibilityGate: "regional-identity" }], + ]); + const [m] = applyFeed({ + baseline: [], + feed: [feedModel({ eligibilityGate: null })], + localOverrides, + tombstones: new Set(), + }); + assert.equal(m!.eligibilityGate, "regional-identity"); + assert.equal(m!.origin, "local"); +}); + +test("a local override sets the gate even when the feed clears it on a baseline entry", () => { + const ungatedBaseline = baselineToMergedEntries([ + { + provider: "modelscope", + modelId: "Qwen/Qwen3.5-397B-A17B", + displayName: "Qwen3.5 397B A17B (ModelScope)", + monthlyTokens: 6_000_000, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: "modelscope-free", + tos: "caution", + }, + ]); + assert.equal(ungatedBaseline[0]!.eligibilityGate, undefined); + const [m] = applyFeed({ + baseline: ungatedBaseline, + feed: [feedModel({ eligibilityGate: null })], + localOverrides: new Map>([ + [KEY, { eligibilityGate: "regional-identity" }], + ]), + tombstones: new Set(), + }); + assert.equal(m!.eligibilityGate, "regional-identity"); + assert.equal(m!.origin, "local"); +}); diff --git a/tests/unit/repro-9630-combo-false-503.test.ts b/tests/unit/repro-9630-combo-false-503.test.ts index 16eae79b05..b3fbbc5bdc 100644 --- a/tests/unit/repro-9630-combo-false-503.test.ts +++ b/tests/unit/repro-9630-combo-false-503.test.ts @@ -70,7 +70,11 @@ test("#9630: combo returns truthful error, not false ALL_ACCOUNTS_INACTIVE, when }, isModelAvailable: async () => true, log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }, - settings: null, + settings: { + resilienceSettings: { + comboCooldownWait: { enabled: false }, + }, + }, relayOptions: null, allCombos: null, }); diff --git a/tests/unit/request-log-management-boundary.test.ts b/tests/unit/request-log-management-boundary.test.ts new file mode 100644 index 0000000000..2b619774db --- /dev/null +++ b/tests/unit/request-log-management-boundary.test.ts @@ -0,0 +1,11 @@ +import test from "node:test"; + +import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts"; + +test("request-log management boundaries pass in an isolated child process", () => { + runIsolatedBoundaryFixture({ + fixtureUrl: new URL("./fixtures/request-log-management-boundary.fixture.ts", import.meta.url), + expectedTests: 3, + label: "request-log management boundaries", + }); +}); diff --git a/tests/unit/request-log-payloads.test.ts b/tests/unit/request-log-payloads.test.ts index 46aa84792d..098eaf12e8 100644 --- a/tests/unit/request-log-payloads.test.ts +++ b/tests/unit/request-log-payloads.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; const { normalizePayloadForLog, + protectErrorPayloadForLog, protectPayloadForLog, serializePayloadForStorage, parseStoredPayload, @@ -65,6 +66,426 @@ test("redacts web-impersonation body credentials but preserves non-secret 'capab }); }); +test("redacts challenge and handoff credentials from persistent request logs", () => { + const protectedPayload = protectPipelinePayloads({ + providerRequest: { + model: "browser-session-model", + recaptchaV3Token: "recaptcha-secret", + nested: { + recaptchaToken: "recaptcha-alias-secret", + turnstileToken: "turnstile-secret", + proofToken: "proof-secret", + resumeToken: "resume-secret", + prepare_token: "prepare-secret", + }, + }, + }); + + assert.deepEqual(protectedPayload?.providerRequest, { + model: "browser-session-model", + recaptchaV3Token: "[REDACTED]", + nested: { + recaptchaToken: "[REDACTED]", + turnstileToken: "[REDACTED]", + proofToken: "[REDACTED]", + resumeToken: "[REDACTED]", + prepare_token: "[REDACTED]", + }, + }); +}); + +test("sanitizes pipeline error messages before persistent request logs", () => { + const protectedPayload = protectPipelinePayloads({ + error: { + timestamp: "2026-09-02T00:00:00.000Z", + error: + "Provider failed access_token=pipeline-secret at /srv/private/provider.json\n" + + " at dispatch (/srv/private/dispatcher.ts:42:7)", + requestBody: { + max_tokens: 512, + temperature: 0.2, + prompt: "Inspect /tmp/example.ts without changing it", + }, + }, + }); + const serialized = JSON.stringify(protectedPayload); + + assert.doesNotMatch(serialized, /pipeline-secret|srv\/private|dispatcher\.ts|\bat dispatch\b/i); + assert.deepEqual(protectedPayload?.error?.requestBody, { + max_tokens: 512, + temperature: 0.2, + prompt: "Inspect /tmp/example.ts without changing it", + }); +}); + +test("sanitizes only nested error and warning subtrees in persisted response bodies", () => { + const payload = { + content: "Normal output mentions /tmp/public-example.ts and must remain intact", + usage: { completion_tokens: 7 }, + error: { + message: "access_token=response-secret at /srv/private/provider.json", + stack: "Error: response-secret\n at dispatch (/srv/private/dispatcher.ts:42:7)", + }, + warning: "Retry after reading C:\\Users\\admin\\private\\warning.json", + }; + + const protectedLegacyPayload = protectPayloadForLog(payload) as typeof payload; + const protectedPipeline = protectPipelinePayloads({ + providerResponse: { body: payload }, + clientResponse: { body: payload }, + }); + const serialized = JSON.stringify({ protectedLegacyPayload, protectedPipeline }); + + assert.doesNotMatch( + serialized, + /response-secret|srv\/private|dispatcher\.ts|C:\\Users|warning\.json/i + ); + assert.equal(protectedLegacyPayload.content, payload.content); + assert.deepEqual(protectedLegacyPayload.usage, payload.usage); + assert.equal(protectedPipeline?.providerResponse?.body?.content, payload.content); + assert.equal(protectedPipeline?.clientResponse?.body?.content, payload.content); +}); + +test("sanitizes in-band error marker objects even when an upstream uses HTTP 200", () => { + const protectedPayload = protectPayloadForLog({ + events: [ + { + type: "error", + content: + "access_token=in-band-secret at /srv/private/in-band.json\n" + + " at dispatch (/srv/private/in-band.ts:3:2)", + }, + ], + content: "Normal sibling content stays available", + }) as { events: Array<{ type: string; content: string }>; content: string }; + + assert.doesNotMatch( + JSON.stringify(protectedPayload.events), + /in-band-secret|srv\/private|in-band\.ts|\bat dispatch\b/i + ); + assert.equal(protectedPayload.content, "Normal sibling content stays available"); +}); + +test("sanitizes serialized error JSON nested below a neutral payload key", () => { + const protectedPayload = protectPayloadForLog({ + payload: JSON.stringify({ + type: "error", + message: "access_token=serialized-secret at /srv/private/serialized.json", + }), + }) as { payload: string }; + + assert.doesNotMatch(protectedPayload.payload, /serialized-secret|srv\/private/i); + assert.equal((JSON.parse(protectedPayload.payload) as { type: string }).type, "error"); +}); + +test("preserves deep successful payloads and still sanitizes deep error leaves", () => { + const successLeaf = { content: "deep successful content", usage: { total_tokens: 2 } }; + const errorLeaf = { + error: { + message: "access_token=deep-error-secret at /srv/private/deep.json", + }, + }; + let deepSuccess: Record = successLeaf; + let deepError: Record = errorLeaf; + for (let depth = 0; depth < 18; depth += 1) { + deepSuccess = { [`level_${depth}`]: deepSuccess }; + deepError = { [`level_${depth}`]: deepError }; + } + + assert.deepEqual(protectPayloadForLog(deepSuccess), deepSuccess); + assert.doesNotMatch( + JSON.stringify(protectPayloadForLog(deepError)), + /deep-error-secret|srv\/private/i + ); +}); + +test("error-mode log protection summarizes opaque binary bodies without enumerating bytes", () => { + assert.equal(protectErrorPayloadForLog(new Uint8Array([1, 2, 3, 4])), "[binary 4 bytes]"); + assert.equal(protectErrorPayloadForLog(Buffer.from([5, 6, 7])), "[binary 3 bytes]"); +}); + +test("error-mode log protection summarizes nested binary bodies without enumerating bytes", () => { + assert.deepEqual( + protectErrorPayloadForLog({ + data: new Uint8Array([11, 22, 33, 44]), + nested: { + body: Buffer.from([55, 66, 77]), + raw: new Uint8Array([88, 99]).buffer, + }, + }), + { + data: "[binary 4 bytes]", + nested: { + body: "[binary 3 bytes]", + raw: "[binary 2 bytes]", + }, + } + ); +}); + +test("sanitizes error frames split across persisted SSE chunks", () => { + const protectedPipeline = protectPipelinePayloads({ + streamChunks: { + provider: [ + '[12:00:00.000] data: {"error":{"message":"access_token=stream-secret at /srv/private/', + 'provider.json","stack":"Error: stream-secret\\n at dispatch (/srv/private/dispatcher.ts:42:7)"}}\n\n', + ], + }, + }); + const storedChunks = protectedPipeline?.streamChunks?.provider ?? []; + const serialized = JSON.stringify(storedChunks); + + assert.doesNotMatch(serialized, /stream-secret|srv\/private|dispatcher\.ts|\bat dispatch\b/i); + assert.match(serialized, /error/); +}); + +test("sanitizes plaintext SSE error events without treating metadata as data frames", () => { + const metadata = 'metadata: {"error":{"message":"healthy diagnostic"}}'; + const protectedPipeline = protectPipelinePayloads({ + streamChunks: { + provider: [ + `${metadata}\nevent: error\ndata: access_token=plain-sse-secret at /srv/private/plain.txt\n\n`, + ], + }, + }); + const storedChunks = protectedPipeline?.streamChunks?.provider ?? []; + const serialized = JSON.stringify(storedChunks); + + assert.doesNotMatch(serialized, /plain-sse-secret|srv\/private|plain\.txt/i); + assert.match(serialized, /event: error/); + assert.equal(storedChunks[0].includes(metadata), true); +}); + +test("sanitizes discriminated SSE and raw NDJSON error records", () => { + const protectedPipeline = protectPipelinePayloads({ + streamChunks: { + provider: [ + 'data: {"type":"error","message":"access_token=sse-json-secret at /srv/private/sse.json"}\n\n', + '{"type":"error","subType":"upstream","message":"Bearer ndjson-secret at C:\\\\Users\\\\admin\\\\private.json"}\n', + '{"type":"error","content":"Error: api_key=lmarena-secret\\n at dispatch (/srv/private/lmarena.ts:8:2)"}\n', + ], + }, + }); + const storedChunks = protectedPipeline?.streamChunks?.provider ?? []; + const serialized = JSON.stringify(storedChunks); + + assert.doesNotMatch( + serialized, + /sse-json-secret|ndjson-secret|lmarena-secret|srv\/private|C:\\\\Users|\bat dispatch\b/i + ); + assert.equal(storedChunks[0].includes('"type":"error"'), true); +}); + +test("sanitizes response last_error aliases in objects, SSE, and NDJSON", () => { + const hostile = "access_token=last-error-secret at /srv/private/last-error.ts"; + const objectPayload = protectPayloadForLog({ + response: { status: "failed", last_error: { message: hostile } }, + }); + const protectedPipeline = protectPipelinePayloads({ + streamChunks: { + provider: [ + `data: ${JSON.stringify({ response: { status: "failed", last_error: { message: hostile } } })}\n\n`, + `${JSON.stringify({ response: { status: "failed", lastError: { message: hostile } } })}\n`, + ], + }, + }); + const serialized = JSON.stringify({ objectPayload, protectedPipeline }); + + assert.doesNotMatch(serialized, /last-error-secret|srv\/private|last-error\.ts/i); + assert.match(serialized, /last_error|lastError/); +}); + +test("sanitizes response.failed messages without rewriting unrelated deep diagnostics", () => { + const hostile = "Bearer response-failed-secret at /srv/private/response-failed.ts:8:2"; + const diagnostics = { + trace: hostile, + output: { trace: hostile }, + level1: { level2: { level3: { level4: { level5: { label: "legitimate diagnostic" } } } } }, + }; + const output = [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "safe direct partial output" }], + }, + { type: "reasoning", reasoning_content: "private direct reasoning" }, + ]; + const objectPayload = protectPayloadForLog({ + type: "response.failed", + message: hostile, + diagnostics, + output, + }); + const protectedPipeline = protectPipelinePayloads({ + streamChunks: { + provider: [ + `event: response.failed\ndata: ${JSON.stringify({ message: hostile, diagnostics })}\n\n`, + `${JSON.stringify({ type: "response.failed", message: hostile, diagnostics })}\n`, + ], + }, + }); + const serialized = JSON.stringify({ objectPayload, protectedPipeline }); + + assert.doesNotMatch(serialized, /response-failed-secret|srv\/private|response-failed\.ts/i); + assert.deepEqual( + (objectPayload as { diagnostics: typeof diagnostics }).diagnostics.level1, + diagnostics.level1 + ); + assert.deepEqual((objectPayload as { output: unknown }).output, [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "safe direct partial output", annotations: [] }], + }, + ]); + assert.doesNotMatch(serialized, /private direct reasoning/); + assert.match(serialized, /response\.failed/); +}); + +test("projects nested output when the SSE event alone marks response.failed", () => { + const protectedPipeline = protectPipelinePayloads({ + streamChunks: { + provider: [ + `event: response.failed\ndata: ${JSON.stringify({ + response: { + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "safe event partial output" }], + }, + { type: "reasoning", reasoning_content: "private event reasoning" }, + ], + }, + })}\n\n`, + ], + }, + }); + const serialized = JSON.stringify(protectedPipeline); + + assert.match(serialized, /safe event partial output/); + assert.doesNotMatch(serialized, /private event reasoning|"reasoning"/); +}); + +test("sanitizes response.completed failed siblings in objects, SSE, and NDJSON", () => { + const hostile = "Bearer completed-failed-secret at /srv/private/completed-failed.ts:8:2"; + const partialOutput = [ + { + id: "msg_partial", + type: "message", + role: "assistant", + status: "in_progress", + diagnostics: { trace: hostile }, + content: [ + { + type: "output_text", + text: "partial safe output", + annotations: [{ type: "url_citation", url: "file:///srv/private/citation" }], + }, + { type: "output_text", phase: "commentary", text: "private commentary" }, + { type: "refusal", refusal: "safe refusal" }, + ], + }, + { + id: "msg_roleless", + type: "message", + content: [{ type: "output_text", text: "private roleless output" }], + }, + { + type: "reasoning", + reasoning_content: "private chain of thought", + encrypted_content: "private encrypted reasoning", + }, + { + type: "function_call", + name: "read_private_file", + arguments: '{"api_key":"private tool argument"}', + }, + ]; + const projectedOutput = [ + { + id: "msg_partial", + type: "message", + role: "assistant", + status: "in_progress", + content: [ + { type: "output_text", text: "partial safe output", annotations: [] }, + { type: "refusal", refusal: "safe refusal" }, + ], + }, + ]; + const completedFailure = { + type: "response.completed", + message: hostile, + response: { + status: "failed", + detail: hostile, + description: hostile, + error: { message: "Upstream request failed" }, + output: partialOutput, + }, + }; + const objectPayload = protectPayloadForLog(completedFailure); + const protectedPipeline = protectPipelinePayloads({ + streamChunks: { + provider: [ + `event: response.completed\ndata: ${JSON.stringify({ message: hostile, response: completedFailure.response })}\n\n`, + `${JSON.stringify(completedFailure)}\n`, + ], + }, + }); + const serialized = JSON.stringify({ objectPayload, protectedPipeline }); + + assert.doesNotMatch( + serialized, + /completed-failed-secret|srv\/private|completed-failed\.ts|private commentary|private roleless|private chain|private encrypted|private tool/i + ); + assert.match(serialized, /"annotations":\[\]/); + assert.doesNotMatch(serialized, /"url_citation"|"diagnostics"|"function_call"|"reasoning"/); + assert.match(serialized, /partial safe output/); + assert.match(serialized, /safe refusal/); + assert.deepEqual( + (objectPayload as { response: { output: typeof projectedOutput } }).response.output, + projectedOutput + ); +}); + +test("sanitizes upstream error bodies by status while preserving successful response bodies", () => { + const successBody = { + message: "Normal response mentions /tmp/public-example.ts and remains diagnostic content", + usage: { total_tokens: 3 }, + }; + const protectedJsonError = protectPipelinePayloads({ + providerResponse: { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "application/json" }, + body: { + message: "access_token=json-body-secret at /srv/private/upstream.json", + detail: "Error: api_key=body-stack-secret\n at dispatch (/srv/private/body.ts:4:2)", + }, + }, + }); + const protectedPlaintextError = protectPipelinePayloads({ + providerResponse: { + status: 503, + body: "Bearer plaintext-body-secret at C:\\Users\\admin\\upstream.txt", + }, + }); + const protectedSuccess = protectPipelinePayloads({ + providerResponse: { status: 200, body: successBody }, + }); + const serialized = JSON.stringify({ protectedJsonError, protectedPlaintextError }); + + assert.doesNotMatch( + serialized, + /json-body-secret|body-stack-secret|plaintext-body-secret|srv\/private|C:\\\\Users|\bat dispatch\b/i + ); + assert.equal(protectedJsonError?.providerResponse?.status, 502); + assert.equal(protectedPlaintextError?.providerResponse?.status, 503); + assert.deepEqual(protectedSuccess?.providerResponse?.body, successBody); +}); + test("omits encrypted reasoning values from structured log payloads", () => { const encryptedContent = "encrypted".repeat(128); const payload = { diff --git a/tests/unit/reset-aware-request-scope-12600.test.ts b/tests/unit/reset-aware-request-scope-12600.test.ts new file mode 100644 index 0000000000..d035175c2b --- /dev/null +++ b/tests/unit/reset-aware-request-scope-12600.test.ts @@ -0,0 +1,234 @@ +import test, { afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const genericModule = await import("../../open-sse/services/genericQuotaFetcher.ts"); +const scoringModule = await import("../../open-sse/services/combo/quotaScoring.ts"); +const familyModule = await import("../../open-sse/services/antigravityQuotaFamily.ts"); +const preflightModule = await import("../../open-sse/services/quotaPreflight.ts"); + +const { convertUsageToQuotaInfo, fetchGenericQuota, invalidateGenericQuotaCache } = genericModule; +const { scoreResetAwareQuota, resolveResetAwareConfig } = scoringModule; +const { getQuotaFetchScope } = familyModule; +const { getQuotaWindows } = preflightModule; + +const resetAt5h = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(); +const resetAt7d = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString(); + +const usage = { + quotas: { + "gemini-3.7-flash-high": { + used: 30, + total: 1000, + remainingPercentage: 97, + resetAt: resetAt5h, + }, + "claude-opus-4-6-thinking": { + used: 1000, + total: 1000, + remainingPercentage: 0, + resetAt: resetAt7d, + }, + "gpt-oss-120b-medium": { + used: 900, + total: 1000, + remainingPercentage: 10, + resetAt: resetAt5h, + }, + gemini_weekly: { + used: 10, + total: 1000, + remainingPercentage: 99, + resetAt: resetAt7d, + }, + claude_gpt_weekly: { + used: 1000, + total: 1000, + remainingPercentage: 0, + resetAt: resetAt7d, + }, + unrelated_weekly: { + used: 1000, + total: 1000, + remainingPercentage: 0, + resetAt: resetAt7d, + }, + }, +}; + +test("reset-aware Gemini scoring ignores depleted Claude family quota", () => { + const quota = convertUsageToQuotaInfo(usage, { + provider: "agy", + requestedModel: "agy/gemini-3.7-flash-high", + }); + + assert.ok(quota); + assert.equal(quota.window5h?.percentUsed, 0.03); + assert.equal(quota.window7d?.percentUsed, 0.01); + assert.equal(quota.percentUsed, 0.03); + assert.equal(quota.limitReached, false); + assert.equal(quota.windows?.["claude-opus-4-6-thinking"], undefined); + assert.equal(quota.windows?.["gpt-oss-120b-medium"], undefined); + assert.equal(quota.windows?.claude_gpt_weekly, undefined); + assert.equal(quota.windows?.unrelated_weekly, undefined); + assert.ok(scoreResetAwareQuota(quota, resolveResetAwareConfig({})).score > 0.3); +}); + +test("opposite-family-only telemetry fails open as unknown", () => { + const gemini = convertUsageToQuotaInfo( + { quotas: { claude_gpt_weekly: usage.quotas.claude_gpt_weekly } }, + { provider: "agy", requestedModel: "gemini-3.7-flash-high" } + ); + const claude = convertUsageToQuotaInfo( + { quotas: { gemini_weekly: usage.quotas.gemini_weekly } }, + { provider: "antigravity", requestedModel: "claude-opus-4-6-thinking" } + ); + + assert.equal(gemini, null); + assert.equal(claude, null); + assert.equal(scoreResetAwareQuota(gemini, resolveResetAwareConfig({})).score, 0.5); + assert.equal(scoreResetAwareQuota(claude, resolveResetAwareConfig({})).score, 0.5); +}); + +test("Claude family excludes unknown weekly buckets", () => { + const quota = convertUsageToQuotaInfo( + { + quotas: { + "claude-opus-4-6-thinking": { + used: 100, + total: 1000, + remainingPercentage: 90, + resetAt: resetAt5h, + }, + claude_gpt_weekly: { + used: 100, + total: 1000, + remainingPercentage: 90, + resetAt: resetAt7d, + }, + unrelated_weekly: usage.quotas.unrelated_weekly, + }, + }, + { provider: "agy", requestedModel: "claude-opus-4-6-thinking" } + ); + + assert.ok(quota); + assert.equal(quota.limitReached, false); + assert.equal(quota.windows?.unrelated_weekly, undefined); + assert.equal(quota.window7d?.percentUsed, 0.1); +}); + +test("unscoped provider-limits conversion retains conservative global windows", () => { + const quota = convertUsageToQuotaInfo(usage); + + assert.ok(quota); + assert.equal(quota.window5h?.percentUsed, 1); + assert.equal(quota.window7d?.percentUsed, 1); + assert.equal(quota.limitReached, true); +}); + +test("reset-aware fetch scope is family-wide for Antigravity and * otherwise", () => { + assert.equal(getQuotaFetchScope("agy", "gemini-3.7-flash-high"), "family:gemini"); + assert.equal(getQuotaFetchScope("antigravity", "claude-opus-4-6-thinking"), "family:claude"); + assert.equal(getQuotaFetchScope("codex", "gpt-5"), "*"); +}); + +test("buildAutoCandidates uses the shared Antigravity fetch-scope helper", () => { + const combo = fs.readFileSync(new URL("../../open-sse/services/combo.ts", import.meta.url), "utf8"); + const strategies = fs.readFileSync( + new URL("../../open-sse/services/combo/quotaStrategies.ts", import.meta.url), + "utf8" + ); + + assert.match(combo, /getQuotaFetchScope\(/); + assert.doesNotMatch( + combo, + /provider === "antigravity" \|\| provider === "agy"\s*\n\s*\? getQuotaScopedModelForProvider/ + ); + assert.match(strategies, /getQuotaFetchScope\(/); + assert.doesNotMatch(strategies, /function getQuotaFetchScope/); +}); + +afterEach(() => { + genericModule.__testing?.resetUsageFetcher?.(); + genericModule.__testing?.clearCache?.(); +}); + +test("fetchGenericQuota scopes Gemini windows and still catalogs sibling families", async () => { + let fetches = 0; + genericModule.__testing.setUsageFetcher(async () => { + fetches += 1; + return usage; + }); + + const quota = await fetchGenericQuota("conn-gemini", { + provider: "agy", + requestedModel: "agy/gemini-3.7-flash-high", + }); + + assert.ok(quota); + assert.equal(quota.window5h?.percentUsed, 0.03); + assert.equal(quota.limitReached, false); + assert.equal(quota.windows?.claude_gpt_weekly, undefined); + assert.equal(quota.windows?.["claude-opus-4-6-thinking"], undefined); + const windows = getQuotaWindows("agy"); + assert.equal(windows.includes("claude_gpt_weekly"), true); + assert.equal(windows.includes("gemini_weekly"), true); + assert.equal(fetches, 1); +}); + +test("invalidateGenericQuotaCache clears every family-scoped entry for a connection", async () => { + let fetches = 0; + genericModule.__testing.setUsageFetcher(async () => { + fetches += 1; + return usage; + }); + + const connectionId = "conn-both-families"; + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "gemini-3.7-flash-high", + }); + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "claude-opus-4-6-thinking", + }); + assert.equal(fetches, 2); + + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "gemini-3.7-flash-high", + }); + assert.equal(fetches, 2); + + invalidateGenericQuotaCache("agy", connectionId); + + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "gemini-3.7-flash-high", + }); + await fetchGenericQuota(connectionId, { + provider: "agy", + requestedModel: "claude-opus-4-6-thinking", + }); + assert.equal(fetches, 4); +}); + +test("non-Antigravity generic quota cache stays per connection, not per model", async () => { + let fetches = 0; + genericModule.__testing.setUsageFetcher(async () => { + fetches += 1; + return usage; + }); + + const connectionId = "conn-kimi"; + await fetchGenericQuota(connectionId, { + provider: "kimi", + requestedModel: "kimi-k2.5", + }); + await fetchGenericQuota(connectionId, { + provider: "kimi", + requestedModel: "kimi-k2.7", + }); + assert.equal(fetches, 1); +}); diff --git a/tests/unit/responses-continuation-store.test.ts b/tests/unit/responses-continuation-store.test.ts index 4f414c4e16..0b0bce17c3 100644 --- a/tests/unit/responses-continuation-store.test.ts +++ b/tests/unit/responses-continuation-store.test.ts @@ -131,6 +131,85 @@ test("resolvePreviousResponseState reads output from a wrapped (streaming) clien }); }); +test("resolvePreviousResponseState chains off effectiveInput, not the pre-reconstruction clientRawRequest.body", () => { + // Live incident (2026-09-03): clientRawRequest.body is deliberately captured + // BEFORE chat.ts's own previous_response_id reconstruction runs + // (captureDeferredClientRawBody's whole point -- it must reflect the raw + // client bytes for audit/guardrail purposes, not what OmniRoute rewrote the + // request into). For a turn that was ITSELF a continuation, body.input is + // just the client's own trimmed delta -- a handful of tool-call items with + // no leading system/user message. Chaining a LATER continuation off that + // instead of the request's real effective input compounds into a + // progressively truncated reconstruction, which the upstream provider then + // rejects outright ("Please ensure that function call turn comes + // immediately after a user turn..."). effectiveInput is captured AFTER + // reconstruction and must be what this function chains off. + insertCallLog({ + id: "log-continued-turn", + responseId: "resp_continued", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-continued-turn.json", + }); + writeArtifact("2026-01-01/log-continued-turn.json", { + clientRawRequest: { + // What the client actually sent this turn: just the new delta, relying + // on OmniRoute to have reconstructed full history server-side. + body: { + input: [{ type: "function_call_output", call_id: "call_1", output: "42" }], + }, + // What this request ACTUALLY dispatched with, after chat.ts's own + // reconstruction expanded the prior turn's stored input+output back in. + effectiveInput: [ + { type: "message", role: "user", content: "hi" }, + { type: "message", role: "assistant", content: "calling a tool" }, + { type: "function_call", call_id: "call_1", name: "get_answer", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "42" }, + ], + }, + providerRequest: { body: { input: [] } }, + clientResponse: { + id: "resp_continued", + output: [{ type: "message", role: "assistant", content: "the answer is 42" }], + }, + }); + + const result = store.resolvePreviousResponseState("resp_continued", "key-1"); + assert.deepEqual(result, { + input: [ + { type: "message", role: "user", content: "hi" }, + { type: "message", role: "assistant", content: "calling a tool" }, + { type: "function_call", call_id: "call_1", name: "get_answer", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "42" }, + ], + output: [{ type: "message", role: "assistant", content: "the answer is 42" }], + }); +}); + +test("resolvePreviousResponseState falls back to clientRawRequest.body.input when effectiveInput is absent (pre-fix artifacts)", () => { + insertCallLog({ + id: "log-legacy-no-effective-input", + responseId: "resp_legacy", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-legacy-no-effective-input.json", + }); + writeArtifact("2026-01-01/log-legacy-no-effective-input.json", { + clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + clientResponse: { + id: "resp_legacy", + output: [{ type: "message", role: "assistant", content: "hello" }], + }, + }); + + const result = store.resolvePreviousResponseState("resp_legacy", "key-1"); + assert.deepEqual(result, { + input: [{ type: "message", role: "user", content: "hi" }], + output: [{ type: "message", role: "assistant", content: "hello" }], + }); +}); + test("resolvePreviousResponseState returns null for an unknown response id", () => { const result = store.resolvePreviousResponseState("resp_does_not_exist", "key-1"); assert.equal(result, null); diff --git a/tests/unit/skills-executor.test.ts b/tests/unit/skills-executor.test.ts index 99975c06b1..17349e84b2 100644 --- a/tests/unit/skills-executor.test.ts +++ b/tests/unit/skills-executor.test.ts @@ -4,8 +4,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-executor-")); +const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-executor-")); +const TEST_DATA_DIR = path.join(TEST_ROOT, "data"); +const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins"); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR; +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true }); process.env.DATA_DIR = TEST_DATA_DIR; +process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR; const coreDb = await import("../../src/lib/db/core.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); @@ -47,7 +54,11 @@ test.beforeEach(async () => { test.after(() => { resetSkillsRuntime(); coreDb.resetDbInstance(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR; + fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); test("skillExecutor executes a registered handler and persists execution history", async () => { @@ -78,6 +89,119 @@ test("skillExecutor executes a registered handler and persists execution history assert.equal(listed[0].id, execution.id); }); +test("skillExecutor sanitizes failed outputs and nested error subtrees before persistence", async () => { + await registerEchoSkill(); + const hostile = + "tool failed access_token=skill-output-secret at /srv/private/skill-output.ts\n" + + " at run (/srv/private/skill-output.ts:8:2)"; + + skillExecutor.registerHandler("echo-handler", async () => ({ + success: false, + status: 502, + statusText: hostile, + headers: { authorization: "Bearer skill-output-secret" }, + body: hostile, + stdout: hostile, + stderr: hostile, + })); + + const failedOutput = await skillExecutor.execute( + "echo@1.0.0", + { value: "failure" }, + { apiKeyId: "key-a", sessionId: "session-output" } + ); + const storedFailure = skillExecutor.getExecution(failedOutput.id); + const failureSerialized = JSON.stringify({ failedOutput, storedFailure }); + + assert.equal((failedOutput.output as Record)?.status, 502); + assert.doesNotMatch( + failureSerialized, + /skill-output-secret|srv\/private|skill-output\.ts|\bat run\b/i + ); + + skillExecutor.registerHandler("echo-handler", async () => ({ + success: true, + payload: { + value: "preserve me", + error: { message: hostile }, + }, + warning: hostile, + })); + const successfulOutput = await skillExecutor.execute( + "echo@1.0.0", + { value: "success" }, + { apiKeyId: "key-a", sessionId: "session-success" } + ); + const storedSuccess = skillExecutor.getExecution(successfulOutput.id); + const successSerialized = JSON.stringify({ successfulOutput, storedSuccess }); + + assert.equal( + ((successfulOutput.output as Record)?.payload as Record) + ?.value, + "preserve me" + ); + assert.doesNotMatch( + successSerialized, + /skill-output-secret|srv\/private|skill-output\.ts|\bat run\b/i + ); +}); + +test("skillExecutor treats failure discriminators and aliased error objects as boundary failures", async () => { + await registerEchoSkill(); + const hostile = "Bearer skill-discriminator-secret at /srv/private/skill-discriminator.ts:8:2"; + + for (const result of [ + { type: "error", message: hostile }, + { status: "failed", reason: hostile }, + ]) { + skillExecutor.registerHandler("echo-handler", async () => result); + const execution = await skillExecutor.execute( + "echo@1.0.0", + { value: "discriminated-failure" }, + { apiKeyId: "key-a", sessionId: "session-discriminated" } + ); + const stored = skillExecutor.getExecution(execution.id); + assert.equal(execution.status, "error"); + assert.equal(stored?.status, "error"); + assert.doesNotMatch( + JSON.stringify({ execution, stored }), + /skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i + ); + } + + const shared = { message: hostile }; + skillExecutor.registerHandler("echo-handler", async () => ({ + success: true, + payload: { error: shared }, + alias: shared, + })); + const aliased = await skillExecutor.execute( + "echo@1.0.0", + { value: "alias" }, + { apiKeyId: "key-a", sessionId: "session-alias" } + ); + assert.equal(aliased.status, "success"); + assert.doesNotMatch( + JSON.stringify({ aliased, stored: skillExecutor.getExecution(aliased.id) }), + /skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i + ); + + const cyclic: Record = { success: true, error: shared }; + cyclic.self = cyclic; + skillExecutor.registerHandler("echo-handler", async () => cyclic); + const cycleSafe = await skillExecutor.execute( + "echo@1.0.0", + { value: "cycle" }, + { apiKeyId: "key-a", sessionId: "session-cycle" } + ); + assert.equal(cycleSafe.status, "success"); + assert.doesNotThrow(() => JSON.stringify(cycleSafe.output)); + assert.doesNotMatch( + JSON.stringify({ cycleSafe, stored: skillExecutor.getExecution(cycleSafe.id) }), + /skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i + ); +}); + test("skillExecutor blocks execution when Skills are disabled in settings", async () => { await registerEchoSkill(); await settingsDb.updateSettings({ skillsEnabled: false }); @@ -122,7 +246,10 @@ test("skillExecutor turns handler errors and timeouts into error executions", as await registerEchoSkill(); skillExecutor.registerHandler("echo-handler", async () => { - throw new Error("handler exploded"); + throw new Error( + "handler exploded access_token=skill-db-secret at /srv/private/skill-executor.ts\n" + + " at execute (/srv/private/skill-executor.ts:21:5)" + ); }); const failed = await skillExecutor.execute( @@ -134,6 +261,16 @@ test("skillExecutor turns handler errors and timeouts into error executions", as assert.equal(failed.status, "error"); assert.equal(failed.output, null); assert.match(failed.errorMessage, /handler exploded/); + assert.doesNotMatch( + String(failed.errorMessage), + /skill-db-secret|srv\/private|skill-executor\.ts|\bat execute\b/i + ); + const storedFailure = skillExecutor.getExecution(failed.id); + assert.match(String(storedFailure?.errorMessage), /handler exploded/); + assert.doesNotMatch( + String(storedFailure?.errorMessage), + /skill-db-secret|srv\/private|skill-executor\.ts|\bat execute\b/i + ); skillExecutor.registerHandler( "echo-handler", diff --git a/tests/unit/skills-interception.test.ts b/tests/unit/skills-interception.test.ts index f6c6e600f2..2cc8c6acc8 100644 --- a/tests/unit/skills-interception.test.ts +++ b/tests/unit/skills-interception.test.ts @@ -4,12 +4,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-interception-")); +const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-interception-")); +const TEST_DATA_DIR = path.join(TEST_ROOT, "data"); +const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins"); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR; +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true }); process.env.DATA_DIR = TEST_DATA_DIR; +process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR; const coreDb = await import("../../src/lib/db/core.ts"); const { skillRegistry } = await import("../../src/lib/skills/registry.ts"); const { skillExecutor } = await import("../../src/lib/skills/executor.ts"); +const { builtinSkills } = await import("../../src/lib/skills/builtins.ts"); const { interceptToolCalls, extractToolCalls, handleToolCallExecution, buildWebSearchCallItem } = await import("../../src/lib/skills/interception.ts"); const { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } = @@ -71,7 +79,11 @@ test.beforeEach(async () => { test.after(() => { resetRuntime(); coreDb.resetDbInstance(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR; + else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR; + fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); test("buildWebSearchCallItem emits a native web_search_call item only for successful web-search fallback results", () => { @@ -231,6 +243,91 @@ test("interceptToolCalls returns outputs, execution errors and missing-skill err ]); }); +test("skill errors are sanitized before OpenAI tool-result response shapes", async () => { + const hostileMessage = + "skill failure access_token=skill-public-secret at /srv/private/skill-handler.ts\n" + + " at execute (/srv/private/skill-handler.ts:17:4)"; + skillExecutor.registerHandler("broken-handler", async () => { + throw new Error(hostileMessage); + }); + + const chatResult = await handleToolCallExecution( + { + choices: [ + { + message: { + tool_calls: [{ id: "chat-error", function: { name: "broken@1.0.0", arguments: "{}" } }], + }, + }, + ], + }, + "gpt-4o-mini", + executionContext + ); + const responsesResult = await handleToolCallExecution( + { + object: "response", + output: [ + { + type: "function_call", + call_id: "responses-error", + name: "broken@1.0.0", + arguments: "{}", + }, + ], + }, + "openai", + executionContext + ); + const thrownResult = await interceptToolCalls( + [{ id: "thrown-error", name: "/srv/private/missing.ts", arguments: {} }], + executionContext + ); + const serialized = JSON.stringify({ chatResult, responsesResult, thrownResult }); + + assert.match(serialized, /skill failure|Skill not found/i); + assert.doesNotMatch( + serialized, + /skill-public-secret|srv\/private|skill-handler\.ts|\bat execute\b/i + ); +}); + +test("failed builtin outputs are sanitized before public tool results", async () => { + const hostile = + "builtin failed access_token=builtin-output-secret at /srv/private/builtin-output.ts\n" + + " at run (/srv/private/builtin-output.ts:9:4)"; + const mutableBuiltins = builtinSkills as unknown as Record< + string, + ( + input: Record, + context: Record + ) => Promise> + >; + const originalHttpRequest = mutableBuiltins.http_request; + + try { + mutableBuiltins.http_request = async () => ({ + success: false, + status: 502, + headers: { authorization: "Bearer builtin-output-secret" }, + body: hostile, + }); + const results = await interceptToolCalls( + [{ id: "builtin-failure", name: "http_request", arguments: { url: "https://example.com" } }], + { ...executionContext, builtinToolNames: ["http_request"] } + ); + const serialized = JSON.stringify(results); + + assert.equal((results[0]?.result as Record)?.status, 502); + assert.doesNotMatch( + serialized, + /builtin-output-secret|srv\/private|builtin-output\.ts|\bat run\b/i + ); + } finally { + mutableBuiltins.http_request = originalHttpRequest; + } +}); + test("handleToolCallExecution appends OpenAI tool results and leaves empty responses untouched", async () => { const openaiResponse = await handleToolCallExecution( { diff --git a/tests/unit/stream-failure-persistent-classification.test.ts b/tests/unit/stream-failure-persistent-classification.test.ts new file mode 100644 index 0000000000..481cf8cc6f --- /dev/null +++ b/tests/unit/stream-failure-persistent-classification.test.ts @@ -0,0 +1,14 @@ +import test from "node:test"; + +import { runIsolatedBoundaryFixture } from "./helpers/runIsolatedBoundaryFixture.ts"; + +test("stream failure persistence boundaries pass in an isolated child process", () => { + runIsolatedBoundaryFixture({ + fixtureUrl: new URL( + "./fixtures/stream-failure-persistent-classification.fixture.ts", + import.meta.url + ), + expectedTests: 2, + label: "stream failure persistence boundaries", + }); +}); diff --git a/tests/unit/stream-passthrough-error-redaction.test.ts b/tests/unit/stream-passthrough-error-redaction.test.ts new file mode 100644 index 0000000000..9da6457def --- /dev/null +++ b/tests/unit/stream-passthrough-error-redaction.test.ts @@ -0,0 +1,446 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createSSEStream } from "../../open-sse/utils/stream.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +type Failure = { status: number; message: string; code?: string; type?: string }; + +async function collectUntilFailure( + chunks: string[], + sourceFormat: string, + convertedLog: string[], + mode: "passthrough" | "translate" = "passthrough", + targetFormat: string = FORMATS.OPENAI +): Promise<{ output: string; error: unknown; failure: Failure | null }> { + let failure: Failure | null = null; + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); + controller.close(); + }, + }); + const reader = source + .pipeThrough( + createSSEStream({ + mode, + ...(mode === "translate" ? { targetFormat } : {}), + sourceFormat, + ...(mode === "passthrough" ? { clientResponseFormat: sourceFormat } : {}), + provider: "hostile-upstream", + model: "hostile-model", + body: { input: "hello" }, + reqLogger: { + appendConvertedChunk(value: string) { + convertedLog.push(value); + }, + }, + onFailure(payload) { + failure = payload; + return true; + }, + }) + ) + .getReader(); + + let output = ""; + let error: unknown = null; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + output += new TextDecoder().decode(result.value); + } + } catch (caught) { + error = caught; + } + return { output, error, failure }; +} + +function assertNoHostileDetail(value: string): void { + assert.doesNotMatch(value, /private-runtime\.ts/); + assert.doesNotMatch(value, /sk-stream-secret/); + assert.doesNotMatch(value, /api_key/); +} + +test("translated root error frames notify onFailure and terminate with a public-safe error", async () => { + const convertedLog: string[] = []; + const raw = { + error: { + type: "server_error", + code: "opaque-provider-code", + message: + "translated failure at /srv/omniroute/private-runtime.ts:47:6 token=sk-stream-secret-xlate", + api_key: "sk-stream-secret-abcdef", + }, + }; + const result = await collectUntilFailure( + [`data: ${JSON.stringify(raw)}\n\n`], + FORMATS.CLAUDE, + convertedLog, + "translate" + ); + + assert.ok(result.error, "a translated upstream error must terminate the stream"); + assert.match(result.output, /event: error/); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.ok(result.failure, "translated failures must reach the internal classifier"); + assert.match(result.failure.message, /private-runtime\.ts/); + assert.equal(result.failure.code, "opaque-provider-code"); + assertNoHostileDetail(String(result.error)); +}); + +test("translated failed response.completed events cannot become successful Chat completions", async () => { + const convertedLog: string[] = []; + const raw = { + type: "response.completed", + response: { + id: "resp_translate_failed", + status: "failed", + output: [], + error: { + type: "server_error", + code: "translated_completed_failure", + message: + "completed translate failure at /srv/omniroute/private-runtime.ts:58:4 token=sk-stream-secret-completed-translate", + }, + }, + }; + const result = await collectUntilFailure( + [`data: ${JSON.stringify(raw)}\n\n`], + FORMATS.OPENAI, + convertedLog, + "translate", + FORMATS.OPENAI_RESPONSES + ); + + assert.ok(result.error, "a failed Responses completion must terminate translated Chat output"); + assert.match(result.output, /"error"/); + assert.doesNotMatch(result.output, /"finish_reason":"stop"/); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.ok(result.failure, "the translated failure must reach fallback classification"); + assert.equal(result.failure.code, "translated_completed_failure"); + assert.match(result.failure.message, /private-runtime\.ts/); + assertNoHostileDetail(String(result.error)); +}); + +test("a translated failed response.completed tail without a newline still terminates", async () => { + const convertedLog: string[] = []; + const raw = { + type: "response.completed", + response: { + id: "resp_translate_failed_tail", + status: "failed", + output: [], + error: { + code: "translated_completed_tail_failure", + message: + "completed tail failure at /srv/omniroute/private-runtime.ts:59:4 token=sk-stream-secret-completed-tail", + }, + }, + }; + const result = await collectUntilFailure( + [`data: ${JSON.stringify(raw)}`], + FORMATS.OPENAI, + convertedLog, + "translate", + FORMATS.OPENAI_RESPONSES + ); + + assert.ok(result.error, "a buffered failed Responses completion must terminate in flush"); + assert.match(result.output, /"error"/); + assert.doesNotMatch(result.output, /"finish_reason":"stop"/); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.ok(result.failure); + assert.equal(result.failure.code, "translated_completed_tail_failure"); + assert.match(result.failure.message, /private-runtime\.ts/); + assertNoHostileDetail(String(result.error)); +}); + +test("Responses response.failed is projected before forwarding, logging, and onFailure", async () => { + const convertedLog: string[] = []; + const raw = { + type: "response.failed", + response: { + id: "resp_hostile-/srv/omniroute/private-runtime.ts-token=sk-stream-secret-id", + model: "provider-model token=sk-stream-secret-model", + status: "failed", + output: [ + { + id: "msg_partial", + type: "message", + role: "assistant", + status: "in_progress", + diagnostics: { + stack: "at /srv/omniroute/private-runtime.ts:47:2", + api_key: "sk-stream-secret-output-diagnostics", + }, + content: [ + { + type: "output_text", + text: "safe partial output", + annotations: [ + { + type: "url_citation", + url: "https://example.invalid/?token=sk-stream-secret-annotation", + title: "at /srv/omniroute/private-runtime.ts:48:2", + }, + ], + }, + { + type: "output_text", + phase: "commentary", + text: "hidden nested commentary must not be public", + }, + { type: "refusal", refusal: "safe refusal" }, + ], + }, + { + id: "msg_commentary", + type: "message", + role: "assistant", + phase: "commentary", + content: [ + { + type: "output_text", + text: "hidden commentary at /srv/omniroute/private-runtime.ts:49:2", + }, + ], + }, + { + id: "msg_roleless", + type: "message", + content: [ + { + type: "output_text", + text: "roleless output must not be public", + annotations: [], + }, + ], + }, + { + id: "reasoning_private", + type: "reasoning", + encrypted_content: "sk-stream-secret-encrypted-reasoning", + summary: [ + { + type: "summary_text", + text: "at /srv/omniroute/private-runtime.ts:50:2", + }, + ], + }, + { + id: "call_private", + type: "function_call", + call_id: "call_private", + name: "read_private_file", + arguments: + '{"path":"/srv/omniroute/private-runtime.ts","api_key":"sk-stream-secret-tool"}', + }, + { + id: "provider_private", + type: "provider_diagnostics", + diagnostics: { + stack: "at /srv/omniroute/private-runtime.ts:51:2", + api_key: "sk-stream-secret-unknown-item", + }, + }, + ], + error: { + type: "server_error", + code: "server_error", + message: "failed at /srv/omniroute/private-runtime.ts:44:2 token=sk-stream-secret-123456", + api_key: "sk-stream-secret-abcdef", + }, + last_error: { + code: "server_error", + message: + "last failure at /srv/omniroute/private-runtime.ts:45:2 token=sk-stream-secret-last", + }, + message: + "sibling failure at /srv/omniroute/private-runtime.ts:46:2 token=sk-stream-secret-sibling", + diagnosis: { stack: "at /srv/omniroute/private-runtime.ts:46:2" }, + settings: { api_key: "sk-stream-secret-response-setting" }, + usage: { + input_tokens: 4, + output_tokens: 2, + total_tokens: 6, + input_tokens_details: { + cached_tokens: 1, + "sk-stream-secret-detail-key": 99, + }, + }, + }, + }; + const result = await collectUntilFailure( + [`event: response.failed\ndata: ${JSON.stringify(raw)}\n\n`], + FORMATS.OPENAI_RESPONSES, + convertedLog + ); + + assert.ok(result.error, "a failed Responses event must terminate the stream"); + assert.match(result.output, /response\.failed/); + assert.match(result.output, /"last_error":\{/); + assert.match(result.output, /safe partial output/); + assert.match(result.output, /safe refusal/); + assert.match(result.output, /"annotations":\[\]/); + assert.doesNotMatch(result.output, /hidden nested commentary must not be public/); + assert.doesNotMatch(result.output, /roleless output must not be public/); + assert.match(result.output, /"cached_tokens":1/); + assert.doesNotMatch(result.output, /\[truncated\]/); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.doesNotMatch( + result.output, + /"diagnosis"|"diagnostics"|"settings"|"encrypted_content"|"function_call"|"provider_diagnostics"|"phase"|"url_citation"/ + ); + assert.doesNotMatch( + convertedLog.join("\n"), + /"diagnosis"|"diagnostics"|"settings"|"encrypted_content"|"function_call"|"provider_diagnostics"|"phase"|"url_citation"/ + ); + assert.ok(result.failure); + assert.match(result.failure.message, /private-runtime\.ts/); + assertNoHostileDetail(String(result.error)); +}); + +test("failed response.completed events omit provider-only diagnostic siblings", async () => { + const convertedLog: string[] = []; + const raw = { + type: "response.completed", + response: { + id: "resp_failed_completed", + object: "response", + created_at: 1_777_777_777, + completed_at: 1_777_777_778, + status: "failed", + output: [], + error: { + code: "server_error", + message: + "completed failure at /srv/omniroute/private-runtime.ts:55:2 token=sk-stream-secret-completed", + }, + diagnosis: { stack: "at /srv/omniroute/private-runtime.ts:55:2" }, + settings: { api_key: "sk-stream-secret-completed-setting" }, + }, + }; + const result = await collectUntilFailure( + [`event: response.completed\ndata: ${JSON.stringify(raw)}\n\n`], + FORMATS.OPENAI_RESPONSES, + convertedLog + ); + + assert.ok(result.error, "a failed response.completed event must terminate the stream"); + assert.match(result.output, /"type":"response\.completed"/); + assert.match(result.output, /"id":"resp_failed_completed"/); + assert.match(result.output, /"created_at":1777777777/); + assert.match(result.output, /"completed_at":1777777778/); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.doesNotMatch(result.output, /"diagnosis"|"settings"/); + assert.doesNotMatch(convertedLog.join("\n"), /"diagnosis"|"settings"/); + assert.ok(result.failure); + assert.match(result.failure.message, /private-runtime\.ts/); + assertNoHostileDetail(String(result.error)); +}); + +test("OpenAI root error frames without a top-level type remain failures after projection", async () => { + const convertedLog: string[] = []; + const raw = { + error: { + type: "server_error", + code: "server_error", + message: "root failed at /srv/omniroute/private-runtime.ts:48:7 token=sk-stream-secret-root", + api_key: "sk-stream-secret-abcdef", + }, + }; + const result = await collectUntilFailure( + [`data: ${JSON.stringify(raw)}\n\n`], + FORMATS.OPENAI, + convertedLog + ); + + assert.ok(result.error, "an OpenAI error envelope must terminate the stream"); + assert.match(result.output, /"error"/); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.ok(result.failure); + assert.match(result.failure.message, /private-runtime\.ts/); + assertNoHostileDetail(String(result.error)); +}); + +test("OpenAI string error frames preserve raw classification but publish only safe text", async () => { + const convertedLog: string[] = []; + const raw = { + error: "string failure at /srv/omniroute/private-runtime.ts:49:8 token=sk-stream-secret-string", + }; + const result = await collectUntilFailure( + [`data: ${JSON.stringify(raw)}\n\n`], + FORMATS.OPENAI, + convertedLog + ); + + assert.ok(result.error, "a string OpenAI error must terminate the stream"); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.ok(result.failure); + assert.match(result.failure.message, /private-runtime\.ts/); + assertNoHostileDetail(String(result.error)); +}); + +test("Claude type:error is projected before forwarding and terminates the stream", async () => { + const convertedLog: string[] = []; + const raw = { + type: "error", + error: { + type: "server_error", + code: "server_error", + message: + "claude failed at /srv/omniroute/private-runtime.ts:51:3 token=sk-stream-secret-123456", + api_key: "sk-stream-secret-abcdef", + }, + }; + const result = await collectUntilFailure( + [`event: error\ndata: ${JSON.stringify(raw)}\n\n`], + FORMATS.CLAUDE, + convertedLog + ); + + assert.ok(result.error, "a Claude error event must terminate the stream"); + assert.match(result.output, /event: error/); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.ok(result.failure); + assert.match(result.failure.message, /private-runtime\.ts/); + assertNoHostileDetail(String(result.error)); +}); + +test("a final response.failed frame without a trailing newline is projected before flush", async () => { + const convertedLog: string[] = []; + const raw = { + type: "response.failed", + response: { + status: "failed", + error: { + code: "server_error", + message: + "tail failed at /srv/omniroute/private-runtime.ts:61:8 token=sk-stream-secret-123456", + api_key: "sk-stream-secret-abcdef", + }, + }, + }; + const result = await collectUntilFailure( + [`event: response.failed\ndata: ${JSON.stringify(raw)}`], + FORMATS.OPENAI_RESPONSES, + convertedLog + ); + + assert.ok(result.error, "a buffered failed event must terminate during flush"); + assert.match(result.output, /response\.failed/); + assertNoHostileDetail(result.output); + assertNoHostileDetail(convertedLog.join("\n")); + assert.ok(result.failure); + assert.match(result.failure.message, /private-runtime\.ts/); + assertNoHostileDetail(String(result.error)); +}); diff --git a/tests/unit/stream-readiness.test.ts b/tests/unit/stream-readiness.test.ts index 7e2ea9c242..04432fc62c 100644 --- a/tests/unit/stream-readiness.test.ts +++ b/tests/unit/stream-readiness.test.ts @@ -451,27 +451,24 @@ test("ensureStreamReadiness preserves buffered chunks when stream starts", async assert.match(text, / world/); }); -test("ensureStreamReadiness preserves its buffered prefix until a delayed consumer observes a later error", async () => { - const prefix = `data: ${JSON.stringify({ - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { role: "assistant", content: "prefix before failure" }, - finish_reason: null, - }, - ], - })}\n\n`; - let pullCount = 0; +test("ensureStreamReadiness replays buffered chunks before a subsequent source error", async () => { + let reads = 0; const response = new Response( new ReadableStream({ pull(controller) { - pullCount += 1; - if (pullCount === 1) { - controller.enqueue(encoder.encode(prefix)); + reads += 1; + if (reads === 1) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "prefix" } }], + })}\n\n` + ) + ); return; } - controller.error(new Error("later upstream failure")); + controller.error(Object.assign(new Error("terminal source failure"), { statusCode: 502 })); }, }), { status: 200, headers: { "Content-Type": "text/event-stream" } } @@ -479,13 +476,96 @@ test("ensureStreamReadiness preserves its buffered prefix until a delayed consum const result = await ensureStreamReadiness(response, { timeoutMs: 100 }); assert.equal(result.ok, true); - await new Promise((resolve) => setTimeout(resolve, 25)); + assert.ok(result.response.body); + const reader = result.response.body.getReader(); + const first = await reader.read(); - const reader = result.response.body!.getReader(); + assert.equal(first.done, false); + assert.match(new TextDecoder().decode(first.value), /prefix/); + await assert.rejects(reader.read(), /terminal source failure/); +}); + +test("ensureStreamReadiness replays multiple buffered chunks in order before an error", async () => { + let reads = 0; + const response = new Response( + new ReadableStream({ + pull(controller) { + reads += 1; + if (reads === 1) { + controller.enqueue(encoder.encode(": keepalive\n\n")); + return; + } + if (reads === 2) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "ready" } }], + })}\n\n` + ) + ); + return; + } + controller.error(new Error("failure after buffered prefix")); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + + const result = await ensureStreamReadiness(response, { timeoutMs: 100 }); + assert.equal(result.ok, true); + assert.ok(result.response.body); + const reader = result.response.body.getReader(); + const first = await reader.read(); + const second = await reader.read(); + + assert.equal(first.done, false); + assert.equal(second.done, false); + assert.match(new TextDecoder().decode(first.value), /keepalive/); + assert.match(new TextDecoder().decode(second.value), /ready/); + await assert.rejects(reader.read(), /failure after buffered prefix/); +}); + +test("ensureStreamReadiness cancellation is bounded when upstream cancel never settles", async () => { + let cancelCalls = 0; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "prefix" } }], + })}\n\n` + ) + ); + }, + pull() { + return new Promise(() => {}); + }, + cancel() { + cancelCalls += 1; + return new Promise(() => {}); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + + const result = await ensureStreamReadiness(response, { timeoutMs: 100 }); + assert.equal(result.ok, true); + assert.ok(result.response.body); + const reader = result.response.body.getReader(); const first = await reader.read(); assert.equal(first.done, false); - assert.match(new TextDecoder().decode(first.value), /prefix before failure/); - await assert.rejects(() => reader.read(), /later upstream failure/); + + await Promise.race([ + reader.cancel("client disconnected"), + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("readiness cancellation stayed pending")), 500) + ), + ]); + await reader.cancel("duplicate cancellation"); + assert.equal(cancelCalls, 1); }); test("ensureStreamReadiness honors configured timeouts above 2000ms", async () => { diff --git a/tests/unit/synced-capabilities-learned-effort-override.test.ts b/tests/unit/synced-capabilities-learned-effort-override.test.ts index f33d08dc56..16f124b05b 100644 --- a/tests/unit/synced-capabilities-learned-effort-override.test.ts +++ b/tests/unit/synced-capabilities-learned-effort-override.test.ts @@ -62,6 +62,9 @@ test("merge path keeps vision AND applies the learned override", () => { // Exclusion gate (#7694): codex/glm/kimi already own a conflicting // `-{effort}` suffix mechanism — the blind opencode-plugin mapping must never // see effort_tiers for them, learned or synced, or it double-handles the suffix. +// #12299 exempts only Kimi K3's BASE model entries (asserted in +// tests/unit/kimi-k3-effort-tiers-12299.test.ts) — non-K3 kimi models such as +// "excluded-model" below stay excluded alongside codex/glm. for (const ownedBy of ["codex", "glm", "glm-cn", "glmt", "kimi", "kimi-coding-apikey"]) { test(`build: excluded provider "${ownedBy}" never gets effort_tiers (synced)`, () => { const caps = buildSyncedCapabilities( diff --git a/tests/unit/token-health-check-cursor.test.ts b/tests/unit/token-health-check-cursor.test.ts index f05933e2bb..12284bc3ae 100644 --- a/tests/unit/token-health-check-cursor.test.ts +++ b/tests/unit/token-health-check-cursor.test.ts @@ -430,7 +430,7 @@ test("checkConnection: a banned Cursor connection stays skipped regardless of la }); }); -test("checkConnection: a credits_exhausted Cursor connection stays skipped regardless of lastErrorType", async () => { +test("checkConnection: a credits_exhausted Cursor connection is still swept", async () => { await resetStorage(); await withCursorEnv(async () => { const id = await createCursorConnection({ @@ -444,7 +444,9 @@ test("checkConnection: a credits_exhausted Cursor connection stays skipped regar await tokenHealthCheck.checkConnection(before); const after = await freshConn(id); - assert.deepEqual(after, before); + assert.notEqual(after.testStatus, "credits_exhausted"); + assert.ok(after.lastHealthCheckAt); + assert.notEqual(after.updatedAt, before.updatedAt); }); }); diff --git a/tests/unit/ui/free-budget-card-gated.test.tsx b/tests/unit/ui/free-budget-card-gated.test.tsx new file mode 100644 index 0000000000..55be1c908e --- /dev/null +++ b/tests/unit/ui/free-budget-card-gated.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: FreeBudgetCard } = + await import("../../../src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard"); + +const summary = { + steadyRecurringTokens: 1_503_225_000, + steadyWithRecurringCreditsTokens: 1_504_225_000, + firstMonthRealisticTokens: 2_129_725_000, + usedThisMonth: 0, + remaining: 1_503_225_000, + modelCount: 453, + poolCount: 35, + perModel: [], + boostMonthlyTokens: 24_000_000, + uncappedProviders: ["gemini"], + gatedRecurringTokens: 6_000_000, + gatedProviders: ["modelscope"], + catalogUpdatedAt: null, + noCredentialProviders: [], +}; + +describe("FreeBudgetCard — eligibility-gated line", () => { + beforeAll(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + }); + + it("renders the gated callout with its providers when gatedRecurringTokens > 0", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify(summary), { status: 200 })) + ); + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + await act(async () => { + root.render(); + }); + await act(async () => {}); + expect(el.textContent).toContain("gated"); + expect(el.textContent).toContain("modelscope"); + }); + + it("hides the callout when the gated total is zero", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ ...summary, gatedRecurringTokens: 0, gatedProviders: [] }), + { status: 200 } + ) + ) + ); + const el = document.createElement("div"); + document.body.appendChild(el); + await act(async () => { + createRoot(el).render(); + }); + await act(async () => {}); + expect(el.textContent).not.toContain("gated"); + }); +}); diff --git a/tests/unit/upstream-error-passthrough.test.ts b/tests/unit/upstream-error-passthrough.test.ts index 84458df5f7..a89303c964 100644 --- a/tests/unit/upstream-error-passthrough.test.ts +++ b/tests/unit/upstream-error-passthrough.test.ts @@ -4,6 +4,7 @@ import { shouldPassthroughUpstreamError, buildPassthroughErrorResponse, } from "../../open-sse/utils/upstreamErrorPassthrough.ts"; +import { buildSanitizedUpstreamErrorResponse } from "../../open-sse/utils/upstreamErrorResponse.ts"; test("upstream error passthrough", async (t) => { await t.test("4xx com corpo JSON de erro do provider é elegível", () => { @@ -53,13 +54,24 @@ test("upstream error passthrough", async (t) => { }), false ); + for (const message of [ + String.raw`rejected api_key\t=opaque-tab-secret-9382746`, + String.raw`rejected api_key\u0009=opaque-unicode-tab-9382746`, + String.raw`rejected Bearer\\topaque-bearer-secret-9382746`, + "spawn failed: helper --api-key opaque-cli-key-9382746", + 'spawn failed: helper --token "opaque cli token 9382746"', + "spawn failed: helper --password 'opaque-cli-password-9382746'", + `upstream echoed hf_${"A".repeat(34)}`, + ]) { + assert.equal(shouldPassthroughUpstreamError(422, { error: { message } }), false, message); + } } ); await t.test( "corpo de capacidade/quota sem segredo continua elegível (contrato Claude Code preservado)", () => { - // The common case must still relay verbatim so Claude Code can match the - // wording to auto-disable capabilities. + // The common safe case must preserve wording so Claude Code can match it + // after recursive sanitization and auto-disable capabilities. assert.equal( shouldPassthroughUpstreamError(400, { error: { message: "thinking.type: adaptive is not supported" }, @@ -74,7 +86,7 @@ test("upstream error passthrough", async (t) => { ); } ); - await t.test("buildPassthroughErrorResponse preserva corpo byte-a-byte", async () => { + await t.test("buildPassthroughErrorResponse preserves an already-safe JSON body", async () => { const body = { type: "error", error: { type: "invalid_request_error", message: "thinking.type: nope" }, @@ -89,9 +101,85 @@ test("upstream error passthrough", async (t) => { }); }); +test("passthrough preserves multiline capability wording without stack frames", async () => { + const message = "validation failed\nthinking.type: adaptive is not supported"; + const res = buildPassthroughErrorResponse(400, { + type: "error", + error: { type: "invalid_request_error", message }, + }); + assert.ok(res); + const body = (await res.json()) as { error?: { message?: string } }; + assert.equal(body.error?.message, message); +}); + +test("passthrough removes basename and URL stack frames while preserving prose URLs", async () => { + const hostileMessages = [ + "boom\n at handler (server.js:12:3)", + String.raw`boom\n at handler (server.js:12:3)`, + "boom at handler (http://127.0.0.1:3000/_next/server.js:12:3)", + "boom at handler (webpack-internal:///app/server.js:12:3)", + "boom\n at handler (http://127.0.0.1:3000/_next/server.js?build=abc:12:3)", + String.raw`boom\n at handler (webpack-internal:///app/server.js#chunk:12:3)`, + String.raw`boom at handler (\Windows\Temp\server.js:12:3)`, + "boom\nhandler@file:///home/runner/private.js:12:3", + String.raw`boom\nhandler@/home/runner/private.cts:12:3`, + "boom\nhandler@https://127.0.0.1:3000/_next/server.mts?build=abc:12:3", + "boom at handler (http://127.0.0.1:3000/_next/chunks/route:12:3)", + ]; + + for (const message of hostileMessages) { + const response = buildPassthroughErrorResponse(400, { + type: "error", + error: { type: "invalid_request_error", message }, + }); + assert.ok(response); + const body = (await response.json()) as { error?: { message?: string } }; + assert.equal(body.error?.message, "boom"); + } + + const prose = "See https://example.com/docs/error for recovery guidance"; + const proseResponse = buildPassthroughErrorResponse(400, { + type: "error", + error: { type: "invalid_request_error", message: prose }, + }); + assert.ok(proseResponse); + const proseBody = (await proseResponse.json()) as { error?: { message?: string } }; + assert.equal(proseBody.error?.message, prose); + + const proseWithCoordinates = "See https://example.com/docs/error:12:3 for recovery guidance"; + const proseWithCoordinatesResponse = buildPassthroughErrorResponse(400, { + type: "error", + error: { type: "invalid_request_error", message: proseWithCoordinates }, + }); + assert.ok(proseWithCoordinatesResponse); + const proseWithCoordinatesBody = (await proseWithCoordinatesResponse.json()) as { + error?: { message?: string }; + }; + assert.equal(proseWithCoordinatesBody.error?.message, proseWithCoordinates); +}); + +test("canonical upstream JSON projection redacts URL credentials", async () => { + const response = buildSanitizedUpstreamErrorResponse({ + status: 422, + rawBody: JSON.stringify({ + error: { + message: + "proxy failed https://svc-user:p4ss-opaque-9382@internal.example/v1?" + + "X-Amz-Signature=amz-secret&sig=sas-secret", + }, + }), + fallbackMessage: "Upstream validation failed", + }); + const serialized = await response.text(); + + assert.equal(response.status, 422); + assert.doesNotMatch(serialized, /svc-user|p4ss-opaque|amz-secret|sas-secret/i); + assert.match(serialized, /\[REDACTED\]/); +}); + test("createErrorResult opt-in passthrough (opts.passthrough)", async (t) => { await t.test( - "com opts.passthrough e corpo elegível, result.response é o corpo upstream verbatim", + "com opts.passthrough e corpo elegível, result.response preserva o JSON upstream seguro", async () => { const { createErrorResult } = await import("../../open-sse/utils/error.ts"); const upstreamBody = { diff --git a/tests/unit/usage-pending-sweep.test.ts b/tests/unit/usage-pending-sweep.test.ts index d3de475b16..070f9fe0c9 100644 --- a/tests/unit/usage-pending-sweep.test.ts +++ b/tests/unit/usage-pending-sweep.test.ts @@ -112,3 +112,88 @@ test("invalid pending sweep max age falls back to one hour", () => { else process.env.MAX_PENDING_REQUEST_AGE_MS = previous; } }); + +// Live incident: a combo dispatch calls trackPendingRequest once PER TARGET +// ATTEMPT (open-sse/handlers/chatCore.ts's single "started" call site, hit +// again on every fallback), each previously generating its OWN fresh id. A +// dashboard tab polling /api/logs/ for the FIRST attempt went stale the +// moment that attempt finalized and the combo silently retried under a +// different id -- the tab had no way to discover the new id, even though the +// request kept streaming successfully. Fix: reuse the same pending id for +// every attempt sharing a correlationId (already passed as metadata on every +// "started" call, already stable across a combo's retries). +test("trackPendingRequest reuses the same id across a combo's target-attempt retries sharing a correlationId", () => { + clearPendingRequests(); + + const firstId = trackPendingRequest("model-a", "provider-a", "conn-a", true, { + correlationId: "corr-retry-1", + }); + assert.ok(firstId, "first attempt should produce an id"); + + // First target attempt finalizes (fails) -- the combo retries with a + // different target, but the SAME client-facing request/correlation. + trackPendingRequest("model-a", "provider-a", "conn-a", false); + assert.equal(getPendingById().has(firstId), false, "finalized attempt is removed by id"); + + const secondId = trackPendingRequest("model-b", "provider-b", "conn-b", true, { + correlationId: "corr-retry-1", + }); + + assert.equal(secondId, firstId, "retry attempt must reuse the first attempt's id"); + assert.equal(getPendingById().has(firstId), true, "reused id is live again under the new attempt"); + assert.equal(getPendingById().get(firstId)?.model, "model-b", "entry reflects the NEW attempt's target"); + + clearPendingRequests(); +}); + +test("trackPendingRequest never reuses an id across two different correlationIds", () => { + clearPendingRequests(); + + const idA = trackPendingRequest("model-a", "provider-a", "conn-a", true, { + correlationId: "corr-unrelated-1", + }); + const idB = trackPendingRequest("model-a", "provider-a", "conn-b", true, { + correlationId: "corr-unrelated-2", + }); + + assert.ok(idA && idB); + assert.notEqual(idA, idB, "unrelated client requests must never share a pending id"); + + clearPendingRequests(); +}); + +test("trackPendingRequest without a correlationId keeps generating a fresh id every attempt (unchanged behavior)", () => { + clearPendingRequests(); + + const idA = trackPendingRequest("model-a", "provider-a", "conn-a", true); + trackPendingRequest("model-a", "provider-a", "conn-a", false); + const idB = trackPendingRequest("model-a", "provider-a", "conn-a", true); + + assert.ok(idA && idB); + assert.notEqual(idA, idB, "no correlationId means no cross-attempt identity to reuse"); + + clearPendingRequests(); +}); + +test("sweepStalePendingRequests evicts stale correlation-id-to-pending-id mappings so an old id can never resurface", () => { + clearPendingRequests(); + + const firstId = trackPendingRequest("model-a", "provider-a", "conn-a", true, { + correlationId: "corr-stale-mapping", + }); + assert.ok(firstId); + trackPendingRequest("model-a", "provider-a", "conn-a", false); + + // Sweep with a max age of 0 so the just-recorded correlation mapping (whose + // touchedAt is "now") is immediately treated as stale, mirroring what a + // real 1-hour-later sweep does to a genuinely abandoned mapping. + sweepStalePendingRequests(Date.now() + HOUR_MS + MINUTE_MS, HOUR_MS); + + const secondId = trackPendingRequest("model-b", "provider-b", "conn-b", true, { + correlationId: "corr-stale-mapping", + }); + + assert.notEqual(secondId, firstId, "an evicted mapping must not resurrect the old id"); + + clearPendingRequests(); +}); diff --git a/tests/unit/with-chat-admission-10786.test.ts b/tests/unit/with-chat-admission-10786.test.ts index dc5f28c127..4c1c7c0a20 100644 --- a/tests/unit/with-chat-admission-10786.test.ts +++ b/tests/unit/with-chat-admission-10786.test.ts @@ -25,6 +25,10 @@ test("withChatAdmission does not invoke the handler when a second large body is const first = await admitChatRequest(chatRequest("http://x/v1/responses", body), options); assert.equal(first.admit, true); + // Occupy the #10437 healthy-headroom slot so the wrapper still hits today's 503 + // path (withChatAdmission does not inject heapPressureCheck). + const headroom = controller.tryAcquireHealthyHeadroom(); + assert.ok(headroom); let called = false; const wrapped = withChatAdmission(async () => { @@ -39,6 +43,7 @@ test("withChatAdmission does not invoke the handler when a second large body is const json = await res.json(); assert.equal(json.error.code, "chat_admission_busy"); first.lease?.release(); + headroom.release(); }); test("withChatAdmission invokes the handler and forwards the admitted request", async () => {