From 2d5bd412610fe0ad385c42b4e4c653fc066e733b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:24:47 -0300 Subject: [PATCH 01/61] fix(api): stabilize relay SSRF-guard binding for minified builds (#6149) (#6224) --- CHANGELOG.md | 4 + .../api/settings/proxy/deno-deploy/route.ts | 5 +- .../api/settings/proxy/vercel-deploy/route.ts | 7 +- tests/unit/relay-minified-fn-6149.test.ts | 144 ++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 tests/unit/relay-minified-fn-6149.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd429a96d..56eef95e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [3.8.45] — TBD +### 🔧 Bug Fixes + +- **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) + ### ⚡ Performance & Infrastructure - **perf(test):** test-suite loader quick wins ([#6214](https://github.com/diegosouzapw/OmniRoute/pull/6214)) — the 19 test scripts switch `--import tsx` → `--import tsx/esm` (the repo is pure ESM; the unused CJS hook cost ~1.3s per test process × 2,462 processes — CI fast-path unit shards dropped 14.8→7.5 min, −49%), tsx bumped to ^4.23.0 (tsx#809 startup-regression fix), **37 orphan `.test.mjs` files (224 cases) recovered** into the canonical glob (they matched no runner and never ran in any CI job; `check:test-discovery` now scans `.mjs` too), and ci.yml/quality.yml unit jobs now call the canonical npm script `test:unit:ci:shard` (single source of truth — closes two silent drifts: missing `setupPolyfill` import in CI and `memory/`+`usage/` dirs absent from the fast-path glob). `tests/unit/dashboard/**` keeps the full tsx hook in its own invocation (`@lobehub/icons` es/ build internally `require()`s ESM-syntax files). diff --git a/src/app/api/settings/proxy/deno-deploy/route.ts b/src/app/api/settings/proxy/deno-deploy/route.ts index 8b305770fa..7ec8764d87 100644 --- a/src/app/api/settings/proxy/deno-deploy/route.ts +++ b/src/app/api/settings/proxy/deno-deploy/route.ts @@ -74,8 +74,11 @@ export function resolveRelayTarget( // SAME source used by the server and by the unit tests — embedded here via // Function#toString so the worker enforces byte-for-byte the audited policy. // Mirrors the Vercel-relay guard so a future audit can diff the two. +// The guard is bound to a LITERAL const name (not a bare declaration) so the +// hardcoded call site below resolves even when the SWC-minified standalone build +// mangles the source function's own name in `.toString()` output (#6149). function buildRelayWorker(relayAuth: string): string { - return `${resolveRelayTarget.toString()} + return `const resolveRelayTarget = ${resolveRelayTarget.toString()}; function isPrivateHostname(h) { if (!h) return true; diff --git a/src/app/api/settings/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts index eb46cfb336..42f432f2c6 100644 --- a/src/app/api/settings/proxy/vercel-deploy/route.ts +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -20,10 +20,13 @@ function buildRelayFunction(relayAuth: string): string { // Node-side helpers from the Edge runtime); it blocks RFC1918, loopback, // link-local, IPv6 ULA, and embedded credentials on the x-relay-target host. // `resolveRelayTarget` (shared with the Deno worker) closes the x-relay-path - // host-confusion hole and is embedded verbatim via Function#toString. + // host-confusion hole and is embedded verbatim via Function#toString. It is + // bound to a LITERAL const name (not a bare declaration) so the hardcoded + // call site below resolves even when the SWC-minified standalone build mangles + // the source function's own name in `.toString()` output (#6149). return `export const config = { runtime: "edge" }; -${resolveRelayTarget.toString()} +const resolveRelayTarget = ${resolveRelayTarget.toString()}; function isPrivateHostname(h) { if (!h) return true; diff --git a/tests/unit/relay-minified-fn-6149.test.ts b/tests/unit/relay-minified-fn-6149.test.ts new file mode 100644 index 0000000000..71c8751a6f --- /dev/null +++ b/tests/unit/relay-minified-fn-6149.test.ts @@ -0,0 +1,144 @@ +// Regression guard for #6149 — relay worker throws +// `ReferenceError: resolveRelayTarget is not defined` on minified standalone +// (SWC) Docker builds. +// +// Root cause: both the Vercel and Deno relay generators embed the shared SSRF +// guard as a BARE function declaration via `${resolveRelayTarget.toString()}`, +// but the worker body CALLS the hardcoded string literal `resolveRelayTarget(...)`. +// In the SWC-minified standalone build the SOURCE identifier gets mangled, so +// `.toString()` emits `function (...)` — the worker defines `` +// while the template still calls `resolveRelayTarget` → ReferenceError at runtime. +// Unminified source tests never catch this because the source name is intact. +// +// The fix embeds the guard under a NAME-STABLE binding — +// `const resolveRelayTarget = ${resolveRelayTarget.toString()};` — so the const +// name is a literal in the template (immune to minification) and resolves the +// hardcoded call regardless of the mangled inner function name. +// +// This test reproduces the defect WITHOUT a real build: it simulates the +// minifier by renaming ONLY the embedded guard function's own declared name +// (located precisely via `resolveRelayTarget.toString()`), then eval-runs the +// emitted worker in a `node:vm` sandbox and asserts the handler still resolves +// the guard instead of throwing ReferenceError. A structural assertion (stable +// `const resolveRelayTarget =` binding) backs it up. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { resolveRelayTarget } from "../../src/app/api/settings/proxy/deno-deploy/route"; +import { __buildRelayFunctionForTest } from "../../src/app/api/settings/proxy/vercel-deploy/route"; +import { __buildRelayWorkerForTest } from "../../src/app/api/settings/proxy/deno-deploy/route"; + +const RELAY_AUTH = "testrelayauth"; +const TARGET = "https://api.anthropic.com"; +const PATH = "/v1/messages"; + +// Minimal WHATWG-ish stubs so the generated worker can run under node:vm. +class FakeHeaders { + m: Map; + constructor(init?: FakeHeaders) { + this.m = new Map(); + if (init && init.m) for (const [k, v] of init.m) this.m.set(k, v); + } + get(k: string): string | null { + const key = k.toLowerCase(); + return this.m.has(key) ? (this.m.get(key) as string) : null; + } + set(k: string, v: string): void { + this.m.set(k.toLowerCase(), v); + } + delete(k: string): void { + this.m.delete(k.toLowerCase()); + } + forEach(fn: (v: string, k: string) => void): void { + this.m.forEach((v, k) => fn(v, k)); + } +} + +class FakeResponse { + body: unknown; + status: number; + headers: unknown; + constructor(body: unknown, init?: { status?: number; headers?: unknown }) { + this.body = body; + this.status = init?.status ?? 200; + this.headers = init?.headers; + } +} + +function buildRequest(): { method: string; body: string; headers: FakeHeaders } { + const headers = new FakeHeaders(); + headers.set("x-relay-auth", RELAY_AUTH); + headers.set("x-relay-target", TARGET); + headers.set("x-relay-path", PATH); + return { method: "POST", body: "payload", headers }; +} + +/** + * Simulate the SWC minifier: rename ONLY the embedded guard's declared function + * name (the source `resolveRelayTarget` identifier that gets mangled), leaving + * the hardcoded string-literal call sites in the template untouched — exactly + * what happens in the standalone build. + */ +function minify(worker: string): string { + const guardSrc = resolveRelayTarget.toString(); + // First occurrence inside the guard source is its own declaration name. + const mangledGuard = guardSrc.replace("resolveRelayTarget", "m0mangled0m"); + return worker.replace(guardSrc, mangledGuard); +} + +async function runWorker( + worker: string, + kind: "vercel" | "deno" +): Promise { + const ctx: Record = { + URL, + Headers: FakeHeaders, + Response: FakeResponse, + console, + fetch: async () => ({ body: "ok", status: 200, headers: new FakeHeaders() }), + }; + let captured: ((req: unknown) => Promise) | undefined; + ctx.Deno = { serve: (h: (req: unknown) => Promise) => (captured = h) }; + vm.createContext(ctx); + + let code = worker; + if (kind === "vercel") { + code = code + .replace(/export const config[^\n]*\n/, "") + .replace("export default async function handler", "__vercelHandler = async function handler"); + } + vm.runInContext(code, ctx); + if (kind === "vercel") { + captured = ctx.__vercelHandler as (req: unknown) => Promise; + } + assert.ok(captured, `${kind} worker did not register a handler`); + return captured(buildRequest()); +} + +describe("#6149 relay worker binds SSRF guard to a stable name", () => { + it("Vercel worker: emitted source embeds a stable `const resolveRelayTarget =` binding", () => { + const worker = __buildRelayFunctionForTest(RELAY_AUTH); + assert.match( + worker, + /const\s+resolveRelayTarget\s*=/, + "worker must bind the guard to a literal const name so minification cannot dangle the call" + ); + }); + + it("Deno worker: emitted source embeds a stable `const resolveRelayTarget =` binding", () => { + const worker = __buildRelayWorkerForTest(RELAY_AUTH); + assert.match(worker, /const\s+resolveRelayTarget\s*=/); + }); + + it("Vercel worker: resolves the guard after minification mangles the source fn name", async () => { + const worker = minify(__buildRelayFunctionForTest(RELAY_AUTH)); + const res = await runWorker(worker, "vercel"); + assert.equal(res.status, 200, "handler must reach the upstream fetch, not throw ReferenceError"); + }); + + it("Deno worker: resolves the guard after minification mangles the source fn name", async () => { + const worker = minify(__buildRelayWorkerForTest(RELAY_AUTH)); + const res = await runWorker(worker, "deno"); + assert.equal(res.status, 200); + }); +}); From c04ce386f1aab81a8babd880f0c90771399233ad Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:54:32 -0300 Subject: [PATCH 02/61] fix(mcp): forward extra context through static tool loops (#6178) (#6228) --- CHANGELOG.md | 4 ++ open-sse/mcp-server/server.ts | 38 +++++------ tests/unit/mcp-extra-forward-6178.test.ts | 82 +++++++++++++++++++++++ 3 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 tests/unit/mcp-extra-forward-6178.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56eef95e4c..067aec7c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ - **ci:** heavy-pipeline dedup ([#6215](https://github.com/diegosouzapw/OmniRoute/pull/6215)) — the release-PR pipeline ran the unit suite 4× per sync (95 jobs, 208 machine-min; the v3.8.44 cycle fired 123 such runs, 88 cancelled). Now: Node 24/26 compat matrices move to a daily `nightly-compat.yml` (−28%/run; resolves the active release branch, opens a tracking issue on failure), coverage is collected inside the unit shards themselves via c8/`NODE_V8_COVERAGE` (−18%/run; the Coverage Shard ×8 matrix is gone — nodejs/node's own CI pattern), the ~40-job per-language i18n matrix becomes 1 job (the account has 20 concurrent-job slots total), and heavy jobs skip **draft** PRs — paired with `/generate-release` now opening the living release PR as draft (flipped ready at the new Phase 0a.0a), killing the per-merge churn for the whole cycle. Validated by a full `workflow_dispatch` of the new pipeline: 35 jobs, 0 failures, 23 min, merged coverage 80.16% (> ratchet baseline). - **feat(quality):** no-new-warnings per PR ([#6218](https://github.com/diegosouzapw/OmniRoute/pull/6218)) — native ESLint bulk suppressions (≥9.24) freeze the pre-existing debt (476 files / 4,273 violations in `config/quality/eslint-suppressions.json`); `npm run lint`, lint-staged (pre-commit) and a new fork-aware `lint-guard` job in quality.yml all run suppressions-aware, so a NEW warning goes red in the PR that introduces it instead of accruing invisibly (+41/+88 per cycle) and being blind-rebaselined at release. 3 warn rules promoted to error in `src/**` (`react-hooks/exhaustive-deps`, `@next/next/no-img-element`, `import/no-anonymous-default-export`); `collect-metrics` measures under the frozen baseline (ratchet metric = net-NEW debt; baseline tightened 4,279→0 in-PR per require-tighten); fork PRs run report-only (contributors are never blocked — the maintainer campaigns fix via co-authorship). Baseline stock shrinks via `--prune-suppressions` at release reconciliation. +### 🔧 Bug Fixes + +- fix(mcp): forward the MCP request `extra` context through static tool loops so stdio callers keep their scope/identity ([#6178](https://github.com/diegosouzapw/OmniRoute/issues/6178)) + --- ## [3.8.44] — TBD diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index cbf95b1956..718e226a33 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -996,11 +996,11 @@ export function createMcpServer(): McpServer { }, withScopeEnforcement( toolDef.name, - async (args) => { + async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); // @ts-ignore - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); + 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); @@ -1023,11 +1023,11 @@ export function createMcpServer(): McpServer { }, withScopeEnforcement( toolDef.name, - async (args) => { + async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); // @ts-ignore - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); + 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); @@ -1048,11 +1048,11 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { + withScopeEnforcement(toolDef.name, async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); // @ts-expect-error - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); + 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); @@ -1073,11 +1073,11 @@ export function createMcpServer(): McpServer { }, withScopeEnforcement( toolDef.name, - async (args) => { + async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); // @ts-ignore: handler expected specific object - const result = await toolDef.handler(parsedArgs); + 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); @@ -1100,11 +1100,11 @@ export function createMcpServer(): McpServer { }, withScopeEnforcement( toolDef.name, - async (args) => { + async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); // @ts-ignore - handler type lost through dynamic Object.values() access - const result = await toolDef.handler(parsedArgs); + 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); @@ -1125,7 +1125,7 @@ export function createMcpServer(): McpServer { description: string; scopes: readonly string[]; inputSchema: { parse: (input: unknown) => unknown }; - handler: (parsedArgs: unknown) => Promise; + handler: (parsedArgs: unknown, extra?: unknown) => Promise; }) => { server.registerTool( toolDef.name, @@ -1136,10 +1136,10 @@ export function createMcpServer(): McpServer { }, withScopeEnforcement( toolDef.name, - async (args) => { + async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - const result = await toolDef.handler(parsedArgs); + const result = await toolDef.handler(parsedArgs, extra); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], }; @@ -1165,11 +1165,11 @@ export function createMcpServer(): McpServer { }, withScopeEnforcement( toolDef.name, - async (args) => { + async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); // @ts-ignore: handler expected specific object - const result = await toolDef.handler(parsedArgs); + 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); @@ -1192,11 +1192,11 @@ export function createMcpServer(): McpServer { }, withScopeEnforcement( toolDef.name, - async (args) => { + async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); // @ts-ignore: handler expected specific object - const result = await toolDef.handler(parsedArgs); + 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); @@ -1219,11 +1219,11 @@ export function createMcpServer(): McpServer { }, withScopeEnforcement( toolDef.name, - async (args) => { + async (args, extra) => { try { const parsedArgs = toolDef.inputSchema.parse(args ?? {}); // @ts-ignore: handler expected specific object - const result = await toolDef.handler(parsedArgs); + 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); diff --git a/tests/unit/mcp-extra-forward-6178.test.ts b/tests/unit/mcp-extra-forward-6178.test.ts new file mode 100644 index 0000000000..177541616f --- /dev/null +++ b/tests/unit/mcp-extra-forward-6178.test.ts @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression guard for #6178: the static MCP tool-registration loops in +// open-sse/mcp-server/server.ts wrapped handlers as `async (args) => { … }`, +// dropping the MCP request `extra` argument that `withScopeEnforcement` +// forwards. On the stdio transport `omniroute_ccr_retrieve` therefore fell back +// to an anonymous caller (`resolveMcpCallerApiKeyId()` AsyncLocalStorage is +// undefined off the HTTP path, and `extra` was gone), so its principal-scoped +// CCR store lookup used the `__anon__` bucket and never matched the block the +// real caller stored. The fix threads `extra` through every static loop: +// `async (args, extra) => await toolDef.handler(parsedArgs, extra)`. +// +// This drives the REAL registration loop: it builds the live MCP server via +// createMcpServer(), stores a CCR block under a concrete principal, then invokes +// the registered omniroute_ccr_retrieve handler with an `extra` carrying that +// principal as the caller id (clientId) — exactly what the SDK passes on a tool +// call. If `extra` is dropped, the caller resolves to "anonymous", the store key +// misses, and retrieval errors out. + +const { createMcpServer } = await import("../../open-sse/mcp-server/server.ts"); +const { storeBlock, resetCcrStore } = await import( + "../../open-sse/services/compression/engines/ccr/index.ts" +); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +type RegisteredTool = { + handler: (args: unknown, extra?: unknown) => Promise<{ + content?: Array<{ type: string; text: string }>; + isError?: boolean; + }>; +}; + +function getRegisteredHandler(server: unknown, toolName: string) { + 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 on the live MCP server`); + return tool.handler; +} + +test("static tool loops forward `extra` so stdio callers keep their scope/identity (#6178)", async () => { + resetCcrStore(); + + const principal = "apikey-6178"; + const verbatim = "VERBATIM-CCR-BLOCK-6178: the original content the caller stored."; + const hash = storeBlock(verbatim, principal); + + const server = createMcpServer(); + const retrieve = getRegisteredHandler(server, "omniroute_ccr_retrieve"); + + // Simulate a stdio tool call: no HTTP AsyncLocalStorage principal, but the MCP + // `extra` carries the caller identity (clientId) + granted scopes. + const extra = { + authInfo: { clientId: principal, scopes: ["read:compression"] }, + }; + + const result = await retrieve({ hash }, extra); + const text = result.content?.[0]?.text ?? ""; + const payload = JSON.parse(text) as { content?: string; error?: string }; + + // With `extra` forwarded, the handler resolves the real principal, the CCR + // store key matches, and the verbatim block comes back. If the loop dropped + // `extra` (the #6178 bug), the caller resolves to "anonymous" and this fails + // with a "CCR block not found" error. + assert.equal( + result.isError, + undefined, + `retrieve must not error; got: ${payload.error ?? "(no error)"}` + ); + assert.equal( + payload.content, + verbatim, + "the forwarded `extra` principal must match the stored block and return it verbatim" + ); +}); + +test.after(() => { + resetCcrStore(); + resetDbInstance(); +}); From 8cb7f00821a15f0b864869d343c83c195a211004 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:56:22 -0300 Subject: [PATCH 03/61] fix(services): 9Router embed route + pre-spawn port probe (#6205) (#6227) --- CHANGELOG.md | 1 + .../embed/{[...path] => [[...path]]}/route.ts | 11 +- src/lib/services/ServiceSupervisor.ts | 27 +++++ src/lib/services/bootstrap.ts | 4 + src/lib/services/embedPath.ts | 19 ++++ src/lib/services/portProbe.ts | 104 ++++++++++++++++++ src/lib/services/reverseProxy.ts | 5 +- src/lib/services/types.ts | 8 ++ tests/unit/ninerouter-embed-port-6205.test.ts | 100 +++++++++++++++++ tests/unit/services/ServiceSupervisor.test.ts | 25 +++++ tests/unit/services/embed-proxy.test.ts | 4 +- 11 files changed, 301 insertions(+), 7 deletions(-) rename src/app/(dashboard)/dashboard/providers/services/[name]/embed/{[...path] => [[...path]]}/route.ts (81%) create mode 100644 src/lib/services/embedPath.ts create mode 100644 src/lib/services/portProbe.ts create mode 100644 tests/unit/ninerouter-embed-port-6205.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 067aec7c42..c2f9cc25dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🔧 Bug Fixes - **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) +- **fix(services):** 9Router embed panel no longer 404s (optional catch-all route) and the supervisor probes the port before spawning to avoid raw EADDRINUSE ([#6205](https://github.com/diegosouzapw/OmniRoute/issues/6205)). Regression guards: `tests/unit/ninerouter-embed-port-6205.test.ts`, `tests/unit/services/ServiceSupervisor.test.ts`. (thanks @jonlwheat2-gif) ### ⚡ Performance & Infrastructure diff --git a/src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts b/src/app/(dashboard)/dashboard/providers/services/[name]/embed/[[...path]]/route.ts similarity index 81% rename from src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts rename to src/app/(dashboard)/dashboard/providers/services/[name]/embed/[[...path]]/route.ts index dd4e690820..9630180710 100644 --- a/src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts +++ b/src/app/(dashboard)/dashboard/providers/services/[name]/embed/[[...path]]/route.ts @@ -1,7 +1,11 @@ /** * Reverse-proxy handler for embedded service UIs. * - * Route: /dashboard/providers/services/[name]/embed/[...path] + * Route: /dashboard/providers/services/[name]/embed/[[...path]] + * + * Optional catch-all ([[...path]]) so the segment-less prefix + * `/embed/` (the panel root — #6205) also matches; a required catch-all + * ([...path]) does not match a zero-segment path and falls through to 404. * * Thin wrapper — all proxy logic lives in @/lib/services/reverseProxy.ts. * @@ -17,11 +21,12 @@ import { proxyRequest } from "@/lib/services/reverseProxy"; export const dynamic = "force-dynamic"; -type RouteParams = { name: string; path: string[] }; +// Optional catch-all: `path` is `undefined` for the segment-less `/embed/` root. +type RouteParams = { name: string; path?: string[] }; async function handleProxy(request: Request, params: RouteParams): Promise { const { name, path } = params; - return proxyRequest(request, path, { + return proxyRequest(request, path ?? [], { name, publicPrefix: `/dashboard/providers/services/${name}/embed`, htmlRewrite: true, diff --git a/src/lib/services/ServiceSupervisor.ts b/src/lib/services/ServiceSupervisor.ts index 395fdd64fe..0125bd2056 100644 --- a/src/lib/services/ServiceSupervisor.ts +++ b/src/lib/services/ServiceSupervisor.ts @@ -7,6 +7,7 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getServiceRow, updateServiceField, setToolStatus } from "@/lib/db/versionManager"; import { RingBuffer } from "./ringBuffer"; import { HealthChecker } from "./healthCheck"; +import { decidePreSpawn, probeBeforeSpawn } from "./portProbe"; import type { ServiceConfig, ServiceState, ServiceStatus, LogLine, HealthState } from "./types"; const CRASH_FAST_THRESHOLD_MS = 5_000; @@ -61,6 +62,32 @@ export class ServiceSupervisor extends EventEmitter { this.setState("starting"); this.lastError = null; + // Pre-spawn probe (#6205): avoid a raw EADDRINUSE crash when a prior + // instance is still holding the port. A healthy instance is adopted; a + // held-but-unhealthy port surfaces a clear error instead of a stack. + // Opt-in per ServiceConfig so the default spawn path is unchanged. + if (this.config.probeBeforeSpawn) { + const probe = await probeBeforeSpawn(this.config.healthUrl(), this.config.port); + const decision = decidePreSpawn(probe, this.config.port); + + if (decision.action === "adopt") { + // Something healthy already serves this port — treat it as running + // rather than spawning a duplicate that would die with EADDRINUSE. + this.checker.start(); + this.startedAt = new Date().toISOString(); + this.setState("running"); + await setToolStatus(this.config.tool, "running"); + return this.getStatus(); + } + + if (decision.action === "error") { + this.lastError = sanitizeErrorMessage(decision.message); + this.setState("error"); + await setToolStatus(this.config.tool, "error", undefined, this.lastError); + return this.getStatus(); + } + } + const { command, args, env, cwd } = this.config.spawnArgs(); const child = spawn(command, args, { diff --git a/src/lib/services/bootstrap.ts b/src/lib/services/bootstrap.ts index 5dea02e9d4..fd5ff475e8 100644 --- a/src/lib/services/bootstrap.ts +++ b/src/lib/services/bootstrap.ts @@ -105,6 +105,10 @@ export async function bootstrapEmbeddedServices(): Promise { healthIntervalMs: cfg.healthIntervalMs, stopTimeoutMs: cfg.stopTimeoutMs, logsBufferBytes: cfg.logsBufferBytes, + // #6205: embedded services bind a fixed port — probe before spawning so + // an orphaned prior instance yields adopt/clear-error instead of a raw + // EADDRINUSE crash. + probeBeforeSpawn: true, }); registerSupervisor(supervisor); diff --git a/src/lib/services/embedPath.ts b/src/lib/services/embedPath.ts new file mode 100644 index 0000000000..1b669035df --- /dev/null +++ b/src/lib/services/embedPath.ts @@ -0,0 +1,19 @@ +/** + * Pure helpers for the embedded-service reverse proxy path handling. + * + * Kept dependency-free so the behavior can be unit-tested without pulling in + * the registry / DB / htmlRewriter that `reverseProxy.ts` imports. + */ + +/** + * Map the `[[...path]]` catch-all segments to an upstream request path. + * + * The segment-less panel root (`/embed/`, matched only because the route is an + * OPTIONAL catch-all — #6205) yields an empty segment array, which must map to + * `"/"` so the embedded service serves its index page instead of `/undefined`. + * + * @param pathSegments The catch-all segments, e.g. `["ui", "index.html"]` or `[]`. + */ +export function toUpstreamPath(pathSegments: string[]): string { + return pathSegments.length > 0 ? "/" + pathSegments.join("/") : "/"; +} diff --git a/src/lib/services/portProbe.ts b/src/lib/services/portProbe.ts new file mode 100644 index 0000000000..99405462a8 --- /dev/null +++ b/src/lib/services/portProbe.ts @@ -0,0 +1,104 @@ +/** + * Pre-spawn port/health probe for embedded services (#6205). + * + * Before the supervisor spawns a service child, it probes the service's port + * and health endpoint. This turns two failure modes into graceful outcomes + * instead of a raw `EADDRINUSE` stack trace crashing the child: + * + * - A healthy prior instance is already answering → ADOPT it (skip spawn). + * - The port is held but nothing healthy answers → surface a CLEAR error. + * - The port is free → SPAWN normally. + * + * `decidePreSpawn` is a pure function so the decision logic is unit-testable + * without binding a real port or spawning a process. + */ + +import { createConnection } from "node:net"; + +/** Result of probing the service before spawning. */ +export interface PreSpawnProbe { + /** true when the service's healthUrl answered with a 2xx. */ + healthy: boolean; + /** true when something is already listening on the service's port. */ + portInUse: boolean; +} + +/** Outcome of the pre-spawn decision. */ +export type PreSpawnDecision = + | { action: "spawn" } + | { action: "adopt" } + | { action: "error"; message: string }; + +const HEALTH_PROBE_TIMEOUT_MS = 3_000; +const PORT_PROBE_TIMEOUT_MS = 1_000; + +/** + * Decide what to do before spawning, given a probe of the port + health. + * + * Pure — no I/O — so it can be exhaustively unit-tested. + */ +export function decidePreSpawn(probe: PreSpawnProbe, port: number): PreSpawnDecision { + // A healthy instance is already serving on the port — adopt it rather than + // spawn a duplicate that would immediately die with EADDRINUSE. + if (probe.healthy) { + return { action: "adopt" }; + } + // Port is held but nothing healthy answers: an orphaned or unrelated process + // is squatting on it. Surface a clear, actionable error instead of letting + // the child crash with a raw EADDRINUSE stack. + if (probe.portInUse) { + return { + action: "error", + message: + `Port ${port} is already in use but the service did not respond to a health ` + + `check. An orphaned previous instance or an unrelated process may be holding ` + + `the port — stop it (or free the port) and try starting the service again.`, + }; + } + // Port is free and nothing is answering — safe to spawn. + return { action: "spawn" }; +} + +/** TCP connect check: resolves true when something accepts a connection. */ +function isPortInUse(port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host: "127.0.0.1", port }, () => { + socket.destroy(); + resolve(true); + }); + socket.setTimeout(timeoutMs); + socket.on("error", () => resolve(false)); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} + +/** Health check: resolves true when healthUrl answers with a 2xx. */ +async function isHealthy(healthUrl: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(healthUrl, { signal: controller.signal }); + return res.ok; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +/** + * Probe the service's port + health endpoint before spawning. + * + * @param healthUrl The service's health endpoint URL. + * @param port The service's registered port. + */ +export async function probeBeforeSpawn(healthUrl: string, port: number): Promise { + const [healthy, portInUse] = await Promise.all([ + isHealthy(healthUrl, HEALTH_PROBE_TIMEOUT_MS), + isPortInUse(port, PORT_PROBE_TIMEOUT_MS), + ]); + return { healthy, portInUse }; +} diff --git a/src/lib/services/reverseProxy.ts b/src/lib/services/reverseProxy.ts index 2e04c8db15..7cf047425d 100644 --- a/src/lib/services/reverseProxy.ts +++ b/src/lib/services/reverseProxy.ts @@ -21,6 +21,7 @@ import { getSupervisor } from "@/lib/services/registry"; import { getOrCreateApiKey } from "@/lib/services/apiKey"; import { rewriteHtml } from "@/lib/services/htmlRewriter"; +import { toUpstreamPath } from "@/lib/services/embedPath"; import { createErrorResponse } from "@/lib/api/errorResponse"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; @@ -90,7 +91,7 @@ export interface ReverseProxyConfig { * security-conflicting headers stripped. * * @param request The incoming Next.js route Request. - * @param pathSegments The `[...path]` catch-all segments, e.g. `["ui", "index.html"]`. + * @param pathSegments The `[[...path]]` catch-all segments, e.g. `["ui", "index.html"]` or `[]`. * @param config Proxy configuration (service name + public prefix). */ export async function proxyRequest( @@ -114,7 +115,7 @@ export async function proxyRequest( } const incomingUrl = new URL(request.url); - const upstreamPath = pathSegments.length > 0 ? "/" + pathSegments.join("/") : "/"; + const upstreamPath = toUpstreamPath(pathSegments); const upstreamUrl = `http://127.0.0.1:${port}${upstreamPath}${incomingUrl.search}`; // Build forwarded headers: strip hop-by-hop AND sensitive client headers. diff --git a/src/lib/services/types.ts b/src/lib/services/types.ts index 2d6285cd45..7e1df54598 100644 --- a/src/lib/services/types.ts +++ b/src/lib/services/types.ts @@ -13,6 +13,14 @@ export interface ServiceConfig { healthIntervalMs: number; stopTimeoutMs: number; logsBufferBytes: number; + /** + * When true (#6205), the supervisor probes the port + health endpoint before + * spawning: a healthy prior instance is adopted, a held-but-unhealthy port + * yields a clear error instead of a raw EADDRINUSE stack. Opt-in so the + * default spawn path (and existing supervisor tests) stays byte-identical — + * enabled for services that bind a fixed port (e.g. 9router). + */ + probeBeforeSpawn?: boolean; } export type ServiceState = diff --git a/tests/unit/ninerouter-embed-port-6205.test.ts b/tests/unit/ninerouter-embed-port-6205.test.ts new file mode 100644 index 0000000000..be967d2f53 --- /dev/null +++ b/tests/unit/ninerouter-embed-port-6205.test.ts @@ -0,0 +1,100 @@ +/** + * #6205 — 9Router embedded service: embed-panel 404 + pre-spawn EADDRINUSE. + * + * SUB-BUG A — the embed panel root (`/embed/`, zero segments after `embed/`) + * 404s because the proxy route was a REQUIRED catch-all `[...path]`, which does + * not match a segment-less path. Fix: OPTIONAL catch-all `[[...path]]` so the + * root matches, and `reverseProxy.toUpstreamPath([])` maps the empty segment + * array to `"/"`. + * + * SUB-BUG B — `ServiceSupervisor.start()` spawned the child with no pre-flight + * port/health probe, so an orphaned prior instance holding the port made the + * child die with a raw EADDRINUSE stack. Fix: `decidePreSpawn()` — adopt a + * healthy instance, surface a clear error for a held-but-unhealthy port. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { toUpstreamPath } from "../../src/lib/services/embedPath.ts"; +import { decidePreSpawn } from "../../src/lib/services/portProbe.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../.."); + +// ─── SUB-BUG A: optional catch-all route + empty-segment mapping ────────────── + +describe("#6205 A — embed panel root no longer 404s", () => { + it("the proxy route folder is an OPTIONAL catch-all ([[...path]])", () => { + const optional = path.join( + repoRoot, + "src/app/(dashboard)/dashboard/providers/services/[name]/embed/[[...path]]/route.ts" + ); + const required = path.join( + repoRoot, + "src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts" + ); + assert.ok(existsSync(optional), "optional catch-all [[...path]]/route.ts must exist"); + assert.ok( + !existsSync(required), + "required catch-all [...path]/route.ts must be gone (it cannot match /embed/)" + ); + }); + + it("maps the segment-less embed root ([]) to upstream '/'", () => { + // The embed frame links to `/dashboard/providers/services/9router/embed/`, + // i.e. zero segments after `embed/`. With the optional catch-all matching, + // Next hands the route an empty segment array — which must map to "/". + assert.equal(toUpstreamPath([]), "/"); + }); + + it("still maps nested segments to their upstream path", () => { + assert.equal(toUpstreamPath(["ui", "index.html"]), "/ui/index.html"); + assert.equal(toUpstreamPath(["api", "models"]), "/api/models"); + }); + + it("derives an empty segment array from the embed frame's constructed path", () => { + // Mirrors what Next's catch-all does: strip the prefix, split remaining. + const framePath = "/dashboard/providers/services/9router/embed/"; + const prefix = "/dashboard/providers/services/9router/embed/"; + const rest = framePath.slice(prefix.length); // "" + const segments = rest.split("/").filter(Boolean); // [] + assert.deepEqual(segments, []); + assert.equal(toUpstreamPath(segments), "/"); + }); +}); + +// ─── SUB-BUG B: pre-spawn port/health decision ─────────────────────────────── + +describe("#6205 B — pre-spawn port probe avoids raw EADDRINUSE", () => { + it("adopts a healthy existing instance (no spawn)", () => { + const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130); + assert.equal(decision.action, "adopt"); + }); + + it("returns a clear error object (not a throw) when the port is held but unhealthy", () => { + let decision; + assert.doesNotThrow(() => { + decision = decidePreSpawn({ healthy: false, portInUse: true }, 20130); + }, "decision must be returned, never thrown"); + assert.equal(decision.action, "error"); + assert.match(decision.message, /already in use/i); + assert.match(decision.message, /20130/, "error should name the port"); + // The clear message must not be a raw EADDRINUSE stack trace. + assert.ok(!decision.message.includes("at /"), "must not leak a stack trace"); + }); + + it("spawns when the port is free", () => { + const decision = decidePreSpawn({ healthy: false, portInUse: false }, 20130); + assert.equal(decision.action, "spawn"); + }); + + it("adopts a healthy instance even if the TCP probe missed it", () => { + // Health is authoritative: a 2xx means a real instance is serving. + const decision = decidePreSpawn({ healthy: true, portInUse: false }, 20130); + assert.equal(decision.action, "adopt"); + }); +}); diff --git a/tests/unit/services/ServiceSupervisor.test.ts b/tests/unit/services/ServiceSupervisor.test.ts index ec36debd15..db7e218b48 100644 --- a/tests/unit/services/ServiceSupervisor.test.ts +++ b/tests/unit/services/ServiceSupervisor.test.ts @@ -36,6 +36,10 @@ db.prepare( `INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose) VALUES ('test-lock', 'stopped', 29997, 0, 0, 0)` ).run(); +db.prepare( + `INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose) + VALUES ('test-adopt', 'stopped', 29996, 0, 0, 0)` +).run(); const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts"); @@ -204,3 +208,24 @@ test("does NOT auto-restart on crash", async () => { healthServer.close(); } }); + +// #6205: when probeBeforeSpawn is enabled and a healthy instance already serves +// the port, the supervisor ADOPTS it (marks running, no child spawned) instead +// of spawning a duplicate that would die with EADDRINUSE. +test("#6205: probeBeforeSpawn adopts a healthy existing instance (no spawn)", async () => { + const healthServer = startHealthServer(29996); + const cfg = { ...tickConfig("test-adopt", 29996), probeBeforeSpawn: true }; + const sup = new ServiceSupervisor(cfg); + + try { + const status = await sup.start(); + assert.equal(status.state, "running", "adopted instance is marked running"); + assert.equal(status.pid, null, "no child process is spawned when adopting"); + // No child means no captured stdout ticks. + await new Promise((r) => setTimeout(r, 300)); + assert.equal(sup.getRingBuffer().snapshot().length, 0, "no logs — nothing was spawned"); + } finally { + await sup.stop(); + healthServer.close(); + } +}); diff --git a/tests/unit/services/embed-proxy.test.ts b/tests/unit/services/embed-proxy.test.ts index b3675991a1..fdeedcb792 100644 --- a/tests/unit/services/embed-proxy.test.ts +++ b/tests/unit/services/embed-proxy.test.ts @@ -2,7 +2,7 @@ * T-07 — embed proxy route handler tests. * * Tests GET/POST/PUT/PATCH/DELETE handlers in - * /dashboard/providers/services/[name]/embed/[...path]/route.ts. + * /dashboard/providers/services/[name]/embed/[[...path]]/route.ts. * * Uses registerSupervisor to inject fake supervisors (ESM live bindings * can't be reassigned, so direct module patching is not possible). @@ -21,7 +21,7 @@ import { DELETE, HEAD, OPTIONS, -} from "../../../src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts"; +} from "../../../src/app/(dashboard)/dashboard/providers/services/[name]/embed/[[...path]]/route.ts"; const originalFetch = globalThis.fetch; From 201908df5ee6e873e3cbb35b0d1d8840015c3b68 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:57:56 -0300 Subject: [PATCH 04/61] fix(backend): system-first memory injection for strict providers (#6135) (#6225) --- CHANGELOG.md | 1 + src/lib/memory/injection.ts | 114 +++++++++++++------ tests/unit/memory-system-first-6135.test.ts | 115 ++++++++++++++++++++ 3 files changed, 199 insertions(+), 31 deletions(-) create mode 100644 tests/unit/memory-system-first-6135.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f9cc25dc..0641e622b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🔧 Bug Fixes - **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) +- **fix(backend):** memory injection now keeps the injected system message **first** for providers that require it (via a `PROVIDERS_SYSTEM_MUST_BE_FIRST` capability), instead of the cache-safe mid-array splice that made strict providers reject the request with a 400 ([#6135](https://github.com/diegosouzapw/OmniRoute/issues/6135)). Regression guard: `tests/unit/memory-system-first-6135.test.ts`. - **fix(services):** 9Router embed panel no longer 404s (optional catch-all route) and the supervisor probes the port before spawning to avoid raw EADDRINUSE ([#6205](https://github.com/diegosouzapw/OmniRoute/issues/6205)). Regression guards: `tests/unit/ninerouter-embed-port-6205.test.ts`, `tests/unit/services/ServiceSupervisor.test.ts`. (thanks @jonlwheat2-gif) ### ⚡ Performance & Infrastructure diff --git a/src/lib/memory/injection.ts b/src/lib/memory/injection.ts index b9a6199bb5..5eaa918ed8 100644 --- a/src/lib/memory/injection.ts +++ b/src/lib/memory/injection.ts @@ -56,6 +56,29 @@ export function providerSupportsSystemMessage(provider: string | null | undefine return !PROVIDERS_WITHOUT_SYSTEM_MESSAGE.has(normalized); } +/** + * Providers that accept a system-role message ONLY at index 0 (a `system` + * message at any later position is rejected with HTTP 400). For these, the + * cache-safe mid-array splice (which inserts memory just before the last user + * turn) is unsafe in multi-turn conversations, so memory must be merged into / + * prepended as the leading system message instead. See #6135. + * + * Populated with the Xiaomi MiMo endpoint (provider id `xiaomi-mimo`, registry + * alias `mimo`, serving mimo-v2.5) confirmed live to 400 on a non-first system + * message. Add other providers here only when they are documented as strict. + */ +const PROVIDERS_SYSTEM_MUST_BE_FIRST = new Set(["xiaomi-mimo", "mimo"]); + +/** + * Returns true when the given provider requires the system message to be first. + * Falls back to false for unknown/null providers (preserves current behavior). + */ +export function systemMessageMustBeFirst(provider: string | null | undefined): boolean { + if (!provider) return false; + const normalized = provider.toLowerCase().trim(); + return PROVIDERS_SYSTEM_MUST_BE_FIRST.has(normalized); +} + /** * Format memories into a single labeled context string. * Format: "Memory context: \n..." @@ -92,6 +115,46 @@ export interface InjectMemoryOptions { cacheSafe?: boolean; } +/** + * #6135: place the memory as a leading system message for providers that reject + * a non-first system role — merging into an existing index-0 system message when + * present, else prepending. Split out of injectMemory to keep it flat. + */ +function injectSystemFirst( + request: ChatRequest, + messages: ChatMessage[], + memoryText: string, + count: number +): ChatRequest { + log.info("memory.injection.injected", { count, strategy: "system-first", model: request.model }); + const first = messages[0]; + if (first && first.role === "system") { + const merged: ChatMessage = { ...first, content: `${memoryText}\n${first.content}` }; + return { ...request, messages: [merged, ...messages.slice(1)] }; + } + const memorySystemMessage: ChatMessage = { role: "system", content: memoryText }; + return { ...request, messages: [memorySystemMessage, ...messages] }; +} + +/** + * Place a memory message at the #3890 cache-safe anchor (just before the last + * user turn) when one exists, else prepend it. Shared by the system and user + * injection strategies to keep injectMemory flat. + */ +function placeMessage( + request: ChatRequest, + messages: ChatMessage[], + msg: ChatMessage, + cacheSafeIndex: number +): ChatRequest { + if (cacheSafeIndex >= 0) { + const next = [...messages]; + next.splice(cacheSafeIndex, 0, msg); + return { ...request, messages: next }; + } + return { ...request, messages: [msg, ...messages] }; +} + export function injectMemory( request: ChatRequest, memories: Memory[], @@ -116,38 +179,27 @@ export function injectMemory( // back to a leading message when caching is off or there is no user turn to anchor on. const cacheSafeIndex = options.cacheSafe ? messages.findLastIndex((m) => m.role === "user") : -1; - if (providerSupportsSystemMessage(provider)) { - // Strategy 1: inject as a system message. - // Prepending before any existing system messages keeps memory context - // accessible without overriding the caller's own system instructions. - const memorySystemMessage: ChatMessage = { role: "system", content: memoryText }; - log.info("memory.injection.injected", { - count: memories.length, - strategy: cacheSafeIndex >= 0 ? "system-cache-safe" : "system", - model: request.model, - }); - if (cacheSafeIndex >= 0) { - const next = [...messages]; - next.splice(cacheSafeIndex, 0, memorySystemMessage); - return { ...request, messages: next }; - } - return { ...request, messages: [memorySystemMessage, ...messages] }; - } else { - // Strategy 2 (fallback): inject as a user message. - // Used for providers like o1-mini that reject the system role. - const memoryUserMessage: ChatMessage = { role: "user", content: memoryText }; - log.info("memory.injection.injected", { - count: memories.length, - strategy: cacheSafeIndex >= 0 ? "user-cache-safe" : "user", - model: request.model, - }); - if (cacheSafeIndex >= 0) { - const next = [...messages]; - next.splice(cacheSafeIndex, 0, memoryUserMessage); - return { ...request, messages: next }; - } - return { ...request, messages: [memoryUserMessage, ...messages] }; + const supportsSystem = providerSupportsSystemMessage(provider); + + // #6135: strict providers reject a system message at a non-zero index. Never + // apply the cache-safe mid-array splice for these — keep the system message + // first (extracted to injectSystemFirst to keep this function flat). + if (supportsSystem && systemMessageMustBeFirst(provider)) { + return injectSystemFirst(request, messages, memoryText, memories.length); } + + // Strategy 1 (system): prepend before existing system messages, preserving the + // caller's own instructions. Strategy 2 (user, e.g. o1-mini): inject as a user + // message. Both honor the #3890 cache-safe anchor via placeMessage. + const role: ChatMessage["role"] = supportsSystem ? "system" : "user"; + const memoryMessage: ChatMessage = { role, content: memoryText }; + const base = supportsSystem ? "system" : "user"; + log.info("memory.injection.injected", { + count: memories.length, + strategy: cacheSafeIndex >= 0 ? `${base}-cache-safe` : base, + model: request.model, + }); + return placeMessage(request, messages, memoryMessage, cacheSafeIndex); } /** diff --git a/tests/unit/memory-system-first-6135.test.ts b/tests/unit/memory-system-first-6135.test.ts new file mode 100644 index 0000000000..371ccc8d8a --- /dev/null +++ b/tests/unit/memory-system-first-6135.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for #6135: cache-safe memory injection inserts a system message at a + * non-zero index, which strict providers (e.g. xiaomi-mimo / alias `mimo`, + * serving mimo-v2.5) reject with HTTP 400. + * + * For providers flagged system-message-must-be-first, the injected system + * message MUST remain at index 0 (merged into an existing leading system + * message, or prepended) instead of being spliced before the last user turn. + * Non-flagged providers keep the existing cache-safe placement unchanged. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + injectMemory, + systemMessageMustBeFirst, +} from "../../src/lib/memory/injection.ts"; +import type { ChatMessage, ChatRequest } from "../../src/lib/memory/injection.ts"; +import { MemoryType } from "../../src/lib/memory/types.ts"; +import type { Memory } from "../../src/lib/memory/types.ts"; + +function mem(content: string): Memory { + return { + id: `mem-${content}`, + content, + type: MemoryType.FACTUAL, + apiKeyId: "k", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + importance: 0.5, + } as unknown as Memory; +} + +// Multi-turn conversation with >= 2 user turns → findLastIndex(user) > 0. +function multiTurn(): ChatRequest { + return { + model: "mimo-v2.5", + messages: [ + { role: "system", content: "SYSTEM PROMPT" } as ChatMessage, + { role: "user", content: "turn 1 question" }, + { role: "assistant", content: "turn 1 answer" }, + { role: "user", content: "turn 2 question" }, + ], + }; +} + +describe("injectMemory system-must-be-first (#6135)", () => { + it("flags xiaomi-mimo (and alias mimo) as system-must-be-first", () => { + assert.equal(systemMessageMustBeFirst("xiaomi-mimo"), true); + assert.equal(systemMessageMustBeFirst("mimo"), true); + // default: unlisted providers keep current (non-first-constrained) behavior + assert.equal(systemMessageMustBeFirst("anthropic"), false); + assert.equal(systemMessageMustBeFirst(null), false); + }); + + it("keeps the injected system message at index 0 for a flagged provider even under cacheSafe", () => { + const out = injectMemory(multiTurn(), [mem("dark mode")], "xiaomi-mimo", { + cacheSafe: true, + }); + + // The system message must be first... + assert.equal( + out.messages.findIndex((m) => m.role === "system"), + 0 + ); + // ...and there must be NO system message at any index > 0. + const strayIdx = out.messages.findIndex((m, i) => i > 0 && m.role === "system"); + assert.equal(strayIdx, -1); + // Memory context is present in the leading system message. + assert.ok(out.messages[0].content.includes("Memory context")); + assert.ok(out.messages[0].content.includes("dark mode")); + }); + + it("merges memory into an existing leading system message (single system, still first)", () => { + const out = injectMemory(multiTurn(), [mem("dark mode")], "mimo", { + cacheSafe: true, + }); + // Exactly one system message, at index 0, carrying both memory + original. + const systemCount = out.messages.filter((m) => m.role === "system").length; + assert.equal(systemCount, 1); + assert.equal(out.messages[0].role, "system"); + assert.ok(out.messages[0].content.includes("Memory context")); + assert.ok(out.messages[0].content.includes("SYSTEM PROMPT")); + // Last user turn preserved at the tail. + assert.equal(out.messages[out.messages.length - 1].content, "turn 2 question"); + }); + + it("prepends a leading system message when there is no existing one (flagged provider)", () => { + const req: ChatRequest = { + model: "mimo-v2.5", + messages: [ + { role: "user", content: "turn 1 question" }, + { role: "assistant", content: "turn 1 answer" }, + { role: "user", content: "turn 2 question" }, + ], + }; + const out = injectMemory(req, [mem("dark mode")], "xiaomi-mimo", { cacheSafe: true }); + assert.equal(out.messages[0].role, "system"); + assert.ok(out.messages[0].content.includes("Memory context")); + assert.equal( + out.messages.findIndex((m, i) => i > 0 && m.role === "system"), + -1 + ); + }); + + it("regression: a NON-flagged provider keeps the existing cache-safe placement", () => { + const req = multiTurn(); + const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true }); + // Existing behavior: memory inserted just before the last user message (index 3). + assert.equal(out.messages[3].role, "system"); + assert.ok(out.messages[3].content.includes("Memory context")); + assert.equal(out.messages[4].content, "turn 2 question"); + assert.equal(out.messages.length, 5); + }); +}); From 670d502bd929805d5f75af250450462e1b66f8db Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:58:31 -0300 Subject: [PATCH 05/61] fix(auth): clear error for stale-key decryption failures (#6148) (#6226) --- CHANGELOG.md | 1 + src/app/api/providers/[id]/models/route.ts | 8 ++ .../[id]/models/staleEncryptionGuard.ts | 30 ++++++ src/lib/db/encryption.ts | 34 ++++++- .../unit/decrypt-stale-key-hint-6148.test.ts | 91 +++++++++++++++++++ 5 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 src/app/api/providers/[id]/models/staleEncryptionGuard.ts create mode 100644 tests/unit/decrypt-stale-key-hint-6148.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0641e622b6..31ad6f7ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🔧 Bug Fixes - **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) +- **fix(auth):** a stale/changed `STORAGE_ENCRYPTION_KEY` now surfaces as a clear **424 `storage_encryption_stale`** ("re-enter the API key") instead of a misleading "Auth failed: 401" — the connection's ciphertext failed to decrypt and was coerced to an empty Bearer, hiding the real cause ([#6148](https://github.com/diegosouzapw/OmniRoute/issues/6148)). Regression guard: `tests/unit/decrypt-stale-key-hint-6148.test.ts`. (thanks @chirag127) - **fix(backend):** memory injection now keeps the injected system message **first** for providers that require it (via a `PROVIDERS_SYSTEM_MUST_BE_FIRST` capability), instead of the cache-safe mid-array splice that made strict providers reject the request with a 400 ([#6135](https://github.com/diegosouzapw/OmniRoute/issues/6135)). Regression guard: `tests/unit/memory-system-first-6135.test.ts`. - **fix(services):** 9Router embed panel no longer 404s (optional catch-all route) and the supervisor probes the port before spawning to avoid raw EADDRINUSE ([#6205](https://github.com/diegosouzapw/OmniRoute/issues/6205)). Regression guards: `tests/unit/ninerouter-embed-port-6205.test.ts`, `tests/unit/services/ServiceSupervisor.test.ts`. (thanks @jonlwheat2-gif) diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 6d1448d803..d16dcc26a7 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -92,6 +92,7 @@ import { normalizeSapModelsResponse, } from "./discovery/normalizers"; import { isNamedOpenAIStyleProvider } from "./discovery/providerSets"; +import { buildStaleEncryptionKeyResponse } from "./staleEncryptionGuard"; import { type ProviderModelsConfigEntry, PROVIDER_MODELS_CONFIG, @@ -193,6 +194,13 @@ export async function GET( return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } + // #6148 — short-circuit when a stored credential is encrypted but no longer + // decrypts (STORAGE_ENCRYPTION_KEY changed/unset). Otherwise the null key is + // coerced to "", an empty-Bearer probe is sent, and the operator sees a + // misleading "Auth failed: 401" instead of the real cause. + const staleEncryptionResponse = buildStaleEncryptionKeyResponse(connection); + if (staleEncryptionResponse) return staleEncryptionResponse; + const provider = typeof connection.provider === "string" && connection.provider.trim().length > 0 ? connection.provider diff --git a/src/app/api/providers/[id]/models/staleEncryptionGuard.ts b/src/app/api/providers/[id]/models/staleEncryptionGuard.ts new file mode 100644 index 0000000000..ea05fd023f --- /dev/null +++ b/src/app/api/providers/[id]/models/staleEncryptionGuard.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; + +/** + * #6148 — Stale STORAGE_ENCRYPTION_KEY guard for model-discovery. + * + * `decryptConnectionFields` (src/lib/db/encryption.ts) flags a connection with + * `credentialDecryptFailed: true` when a stored credential is still encrypted + * (`enc:v1:…`) but no longer decrypts — the signature of a changed or unset + * STORAGE_ENCRYPTION_KEY. Without this guard the null credential is coerced to + * an empty string, an empty-Bearer request is sent upstream, and the operator + * sees a misleading "Auth failed: 401" that hides the real cause. + * + * Returns a 424 (Failed Dependency) response with a clear, sanitized message + * when the connection carries that flag; otherwise null (proceed normally). + */ +const STALE_ENCRYPTION_MESSAGE = + "Stored API key cannot be decrypted (STORAGE_ENCRYPTION_KEY changed or unset). Re-enter the API key."; + +export function buildStaleEncryptionKeyResponse( + connection: { credentialDecryptFailed?: unknown } | null | undefined +): NextResponse | null { + if (!connection || connection.credentialDecryptFailed !== true) return null; + + // buildErrorBody sanitizes the message (Rule #12); override the type so the + // client can key off the specific stale-encryption cause. + const body = buildErrorBody(424, STALE_ENCRYPTION_MESSAGE); + body.error.type = "storage_encryption_stale"; + return NextResponse.json(body, { status: 424 }); +} diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index 1559c14bb5..af81a40dde 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -102,6 +102,16 @@ export function isEncryptionEnabled(): boolean { return !!process.env.STORAGE_ENCRYPTION_KEY; } +/** + * True when `value` is a stored ciphertext (carries the `enc:v1:` prefix). + * Lets callers tell "credential present but undecryptable" (stale/changed + * STORAGE_ENCRYPTION_KEY) apart from "credential genuinely empty" — decrypt() + * collapses both to null otherwise. See #6148. + */ +export function looksEncrypted(value: unknown): boolean { + return typeof value === "string" && value.startsWith(PREFIX); +} + /** * Encrypt a plaintext string using the STATIC salt key. * If encryption is not configured, returns plaintext unchanged. @@ -232,12 +242,28 @@ export function decryptConnectionFields { + if (ORIGINAL_STORAGE_KEY === undefined) { + delete process.env.STORAGE_ENCRYPTION_KEY; + } else { + process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_KEY; + } +}); + +test("decryptConnectionFields flags a credential that no longer decrypts (#6148)", async () => { + // 1. Encrypt an apiKey under key A. + process.env.STORAGE_ENCRYPTION_KEY = "stale-key-6148-A"; + const encA = await importFresh("src/lib/db/encryption.ts"); + const ciphertext = encA.encrypt("sk-real-secret-key"); + assert.match(ciphertext, /^enc:v1:/, "expected a real enc:v1 ciphertext"); + + // 2. Read it back under a DIFFERENT key B (simulating a changed key). + process.env.STORAGE_ENCRYPTION_KEY = "stale-key-6148-B"; + const encB = await importFresh("src/lib/db/encryption.ts"); + + const decrypted = encB.decryptConnectionFields({ + provider: "openai", + apiKey: ciphertext, + }); + + // The credential fails to decrypt (null) but the guard flag distinguishes this + // from a genuinely empty credential. + assert.equal(decrypted.apiKey, null, "stale key must decrypt to null"); + assert.equal( + decrypted.credentialDecryptFailed, + true, + "undecryptable ciphertext must set credentialDecryptFailed" + ); + assert.equal(encB.looksEncrypted(ciphertext), true); +}); + +test("a genuinely empty credential is NOT flagged as decrypt failure (#6148)", async () => { + process.env.STORAGE_ENCRYPTION_KEY = "stale-key-6148-empty"; + const enc = await importFresh("src/lib/db/encryption.ts"); + + const decrypted = enc.decryptConnectionFields({ provider: "openai", apiKey: null }); + assert.notEqual(decrypted.credentialDecryptFailed, true, "empty credential must not flag"); +}); + +test("models route guard returns HTTP 424 storage_encryption_stale (#6148)", async () => { + const guard = await importFresh( + "src/app/api/providers/[id]/models/staleEncryptionGuard.ts" + ); + + // Connection flagged by decryptConnectionFields (stale key). + const staleResponse = guard.buildStaleEncryptionKeyResponse({ + provider: "openai", + apiKey: null, + credentialDecryptFailed: true, + }); + + assert.ok(staleResponse, "guard must return a response for a stale connection"); + assert.equal(staleResponse.status, 424, "must be HTTP 424, not an upstream 401"); + + const body = await staleResponse.json(); + assert.equal(body.error.type, "storage_encryption_stale"); + assert.match(body.error.message, /decrypt/i); + // Rule #12 — no stack trace leakage in the error body. + assert.equal(body.error.message.includes("at /"), false); + + // A healthy connection must NOT be short-circuited. + const okResponse = guard.buildStaleEncryptionKeyResponse({ + provider: "openai", + apiKey: "sk-real-secret-key", + }); + assert.equal(okResponse, null, "healthy connection must proceed (null)"); +}); From cbc16af2863095440363232f7c4eef382ed9580b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:59:08 -0300 Subject: [PATCH 06/61] fix(backend): record reasoning source for zero-metered reasoning models (#6187) (#6229) --- CHANGELOG.md | 1 + .../116_call_logs_reasoning_source.sql | 13 ++ src/lib/usage/callLogs.ts | 42 ++++- src/lib/usage/tokenAccounting.ts | 42 +++++ .../unit/reasoning-token-source-6187.test.ts | 149 ++++++++++++++++++ 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/lib/db/migrations/116_call_logs_reasoning_source.sql create mode 100644 tests/unit/reasoning-token-source-6187.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ad6f7ad7..2c4ca2b8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🔧 Bug Fixes - **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) +- **fix(backend):** call logs now record a **reasoning source/char-count** (migration 116, `reasoning_source`/`reasoning_chars`) for models that emit `reasoning_content`/`` but report zero reasoning tokens in usage, so `tokens_reasoning` no longer silently under-represents reasoning — cost math is unchanged (the priced `tokens_reasoning` stays usage-derived) ([#6187](https://github.com/diegosouzapw/OmniRoute/issues/6187)). Regression guard: `tests/unit/reasoning-token-source-6187.test.ts`. (thanks @andrea-kingautomation) - **fix(auth):** a stale/changed `STORAGE_ENCRYPTION_KEY` now surfaces as a clear **424 `storage_encryption_stale`** ("re-enter the API key") instead of a misleading "Auth failed: 401" — the connection's ciphertext failed to decrypt and was coerced to an empty Bearer, hiding the real cause ([#6148](https://github.com/diegosouzapw/OmniRoute/issues/6148)). Regression guard: `tests/unit/decrypt-stale-key-hint-6148.test.ts`. (thanks @chirag127) - **fix(backend):** memory injection now keeps the injected system message **first** for providers that require it (via a `PROVIDERS_SYSTEM_MUST_BE_FIRST` capability), instead of the cache-safe mid-array splice that made strict providers reject the request with a 400 ([#6135](https://github.com/diegosouzapw/OmniRoute/issues/6135)). Regression guard: `tests/unit/memory-system-first-6135.test.ts`. - **fix(services):** 9Router embed panel no longer 404s (optional catch-all route) and the supervisor probes the port before spawning to avoid raw EADDRINUSE ([#6205](https://github.com/diegosouzapw/OmniRoute/issues/6205)). Regression guards: `tests/unit/ninerouter-embed-port-6205.test.ts`, `tests/unit/services/ServiceSupervisor.test.ts`. (thanks @jonlwheat2-gif) diff --git a/src/lib/db/migrations/116_call_logs_reasoning_source.sql b/src/lib/db/migrations/116_call_logs_reasoning_source.sql new file mode 100644 index 0000000000..8cd507985c --- /dev/null +++ b/src/lib/db/migrations/116_call_logs_reasoning_source.sql @@ -0,0 +1,13 @@ +-- #6187: reasoning-token accounting is blind to reasoning_content / models. +-- Some providers (e.g. stepfun step-3.7-flash) emit reasoning content in the +-- assistant message but report reasoning_tokens=0 in usage, so the usage-derived +-- tokens_reasoning column under-represents reasoning. +-- +-- These two columns record reasoning PRESENCE/SOURCE and the raw CHARACTER count +-- of observed reasoning content. They are deliberately SEPARATE from the priced +-- tokens_reasoning column: reasoning_chars is a character count, NOT a token +-- count, and must never enter cost math. +-- reasoning_source: NULL | 'usage' | 'content' | 'think' +-- reasoning_chars : NULL when unknown, else raw char count of observed reasoning +ALTER TABLE call_logs ADD COLUMN reasoning_source TEXT DEFAULT NULL; +ALTER TABLE call_logs ADD COLUMN reasoning_chars INTEGER DEFAULT NULL; diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 3c0cc5deb9..961cd0f8c2 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -18,6 +18,7 @@ import { getPromptCacheReadTokensOrNull, getPromptCacheCreationTokensOrNull, getReasoningTokensOrNull, + getObservedReasoning, } from "./tokenAccounting"; import { isNoLog } from "../compliance/noLog"; import { protectPayloadForLog, parseStoredPayload } from "../logPayloads"; @@ -240,6 +241,37 @@ function buildArtifact( }; } +// #6187: extract the assistant message from a chat-completion-shaped response +// body so we can inspect its reasoning_content / content. +function extractAssistantMessage(responseBody: unknown): unknown { + if (!responseBody || typeof responseBody !== "object") return responseBody; + const choices = (responseBody as JsonRecord).choices; + if (Array.isArray(choices) && choices.length > 0) { + const first = choices[0] as JsonRecord; + return first?.message ?? first?.delta ?? first; + } + return responseBody; +} + +// #6187: decide the reasoning SOURCE and (char-only) count recorded alongside +// the usage-derived tokens_reasoning. Usage is authoritative when it reports +// non-zero reasoning tokens; otherwise we fall back to observed reasoning +// content so "reasoned but metered 0" stays distinguishable. reasoning_chars is +// a CHARACTER count, never a token count — it must not touch cost math. +function resolveReasoningObservation( + usageReasoning: number | null, + responseBody: unknown +): { source: string | null; chars: number | null } { + if (usageReasoning != null && usageReasoning > 0) { + return { source: "usage", chars: null }; + } + const observed = getObservedReasoning(extractAssistantMessage(responseBody)); + if (observed.chars > 0) { + return { source: observed.source, chars: observed.chars }; + } + return { source: null, chars: null }; +} + function hasTable(tableName: string): boolean { const db = getDbInstance(); return Boolean( @@ -534,6 +566,10 @@ export async function saveCallLog(entry: any) { const nodePrefix = await resolveProviderPrefix(rawProvider); resolvedRequestedModel = applyNodePrefix(rawRequestedModel, rawProvider, nodePrefix); } + // #6187: usage-derived reasoning tokens stay UNCHANGED (cost math reads this), + // while reasoning source/char-count are recorded separately for observability. + const tokensReasoning = getReasoningTokensOrNull(entry.tokens); + const reasoningObservation = resolveReasoningObservation(tokensReasoning, entry.responseBody); const logEntry = { id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(), timestamp: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(), @@ -550,7 +586,9 @@ export async function saveCallLog(entry: any) { tokensOut: toNumber(getLoggedOutputTokens(entry.tokens)), tokensCacheRead: getPromptCacheReadTokensOrNull(entry.tokens), tokensCacheCreation: getPromptCacheCreationTokensOrNull(entry.tokens), - tokensReasoning: getReasoningTokensOrNull(entry.tokens), + tokensReasoning, + reasoningSource: reasoningObservation.source, + reasoningChars: reasoningObservation.chars, tokensCompressed: entry.tokensCompressed != null ? toNumber(entry.tokensCompressed) : null, cacheSource: entry.cacheSource === "semantic" ? "semantic" : "upstream", requestType: entry.requestType || null, @@ -606,6 +644,7 @@ export async function saveCallLog(entry: any) { id, timestamp, method, path, status, model, requested_model, provider, account, connection_id, duration, tokens_in, tokens_out, tokens_cache_read, tokens_cache_creation, tokens_reasoning, tokens_compressed, + reasoning_source, reasoning_chars, cache_source, request_type, source_format, target_format, api_key_id, api_key_name, combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, @@ -616,6 +655,7 @@ export async function saveCallLog(entry: any) { @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @account, @connectionId, @duration, @tokensIn, @tokensOut, @tokensCacheRead, @tokensCacheCreation, @tokensReasoning, @tokensCompressed, + @reasoningSource, @reasoningChars, @cacheSource, @requestType, @sourceFormat, @targetFormat, @apiKeyId, @apiKeyName, @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, diff --git a/src/lib/usage/tokenAccounting.ts b/src/lib/usage/tokenAccounting.ts index 92dd83f21c..4131107902 100644 --- a/src/lib/usage/tokenAccounting.ts +++ b/src/lib/usage/tokenAccounting.ts @@ -93,6 +93,48 @@ export function getReasoningTokens(tokens: unknown): number { ); } +// Non-greedy, single-capture, no nested variable-length quantifiers → ReDoS-safe. +const THINK_BLOCK_RE = /([\s\S]*?)<\/think>/gi; + +/** + * Inspect an assistant message for reasoning/thinking content that the usage + * object may not have metered (#6187 — e.g. stepfun step-3.7-flash emits + * `reasoning_content` but reports `reasoning_tokens=0`). + * + * Returns the reasoning SOURCE and the raw CHARACTER count of the observed + * reasoning text. + * + * IMPORTANT: `chars` is a CHARACTER count, NOT a token count. It must NEVER be + * fed into cost math (`costCalculator` prices `tokens.reasoning`). It exists + * only so call logs can distinguish "reasoned but metered 0" from + * "did not reason at all" without corrupting billing. + */ +export function getObservedReasoning(message: unknown): { + source: "content" | "think" | null; + chars: number; +} { + const record = asRecord(message); + + // Explicit reasoning field: `reasoning_content` is the raw provider field; + // `reasoning` is what sseTextTransform maps it to. + const explicit = record.reasoning_content ?? record.reasoning; + if (typeof explicit === "string" && explicit.trim().length > 0) { + return { source: "content", chars: explicit.length }; + } + + // Inline ... blocks embedded in message content. + const content = record.content; + if (typeof content === "string" && content.length > 0) { + let chars = 0; + for (const match of content.matchAll(THINK_BLOCK_RE)) { + chars += (match[1] ?? "").length; + } + if (chars > 0) return { source: "think", chars }; + } + + return { source: null, chars: 0 }; +} + // ─── Nullable variants ────────────────────────────────────────────────── // Return `null` when the provider simply doesn't report the field, // vs `0` when the provider explicitly reported zero. diff --git a/tests/unit/reasoning-token-source-6187.test.ts b/tests/unit/reasoning-token-source-6187.test.ts new file mode 100644 index 0000000000..f95095a199 --- /dev/null +++ b/tests/unit/reasoning-token-source-6187.test.ts @@ -0,0 +1,149 @@ +/** + * Regression test for #6187 — reasoning-token accounting is blind to + * `reasoning_content` / `` models. + * + * Some providers (e.g. stepfun step-3.7-flash) emit reasoning content in the + * assistant message but report `reasoning_tokens=0` in usage. The usage-derived + * `tokens_reasoning` column then under-represents reasoning. The conservative + * fix records the reasoning SOURCE and raw CHARACTER count in two new, + * non-cost-touching columns (`reasoning_source`, `reasoning_chars`) while + * leaving `tokens_reasoning` (what cost math uses) untouched. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { getDbInstance, resetDbInstance } from "../../src/lib/db/core.ts"; +import { saveCallLog } from "../../src/lib/usage/callLogs.ts"; +import { getObservedReasoning } from "../../src/lib/usage/tokenAccounting.ts"; +import { computeCostFromPricing } from "../../src/lib/usage/costCalculator.ts"; + +test.after(() => { + try { + const db = getDbInstance(); + db.prepare("DELETE FROM call_logs WHERE id LIKE 'test-6187-%'").run(); + } catch { + // best-effort cleanup + } + try { + resetDbInstance(); + } catch { + // best-effort handle release (per DB-handle hang rule) + } +}); + +// ── getObservedReasoning helper ──────────────────────────────────────────── + +test("getObservedReasoning: reasoning_content field → source=content", () => { + const observed = getObservedReasoning({ reasoning_content: "Let me think about this." }); + assert.equal(observed.source, "content"); + assert.equal(observed.chars, "Let me think about this.".length); +}); + +test("getObservedReasoning: reasoning field (sse-mapped) → source=content", () => { + const observed = getObservedReasoning({ reasoning: "step one, step two" }); + assert.equal(observed.source, "content"); + assert.ok(observed.chars > 0); +}); + +test("getObservedReasoning: inline block → source=think", () => { + const observed = getObservedReasoning({ + content: "hidden chain of thoughtfinal answer", + }); + assert.equal(observed.source, "think"); + assert.equal(observed.chars, "hidden chain of thought".length); +}); + +test("getObservedReasoning: no reasoning → source=null, chars=0", () => { + const observed = getObservedReasoning({ content: "just a plain answer" }); + assert.equal(observed.source, null); + assert.equal(observed.chars, 0); +}); + +// ── Persistence: reasoning_content present but usage reports 0 ────────────── + +test("saveCallLog records reasoning_source=content when usage under-reports reasoning", async () => { + const db = getDbInstance(); + const testId = `test-6187-content-${Date.now()}`; + const reasoning = "The model reasoned internally but reported zero reasoning tokens."; + + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "step-3.7-flash", + provider: "stepfun", + duration: 100, + // usage EXPLICITLY reports reasoning_tokens=0 (the bug trigger) + tokens: { prompt_tokens: 10, completion_tokens: 20, reasoning_tokens: 0 }, + responseBody: { + choices: [{ message: { role: "assistant", content: "answer", reasoning_content: reasoning } }], + }, + }); + + const row = db + .prepare( + "SELECT tokens_reasoning, reasoning_source, reasoning_chars FROM call_logs WHERE id = ?" + ) + .get(testId) as { + tokens_reasoning: number | null; + reasoning_source: string | null; + reasoning_chars: number | null; + }; + + assert.ok(row, "row should exist"); + // Reasoning presence is now recorded from the message content... + assert.equal(row.reasoning_source, "content", "source should be content"); + assert.equal(row.reasoning_chars, reasoning.length, "char count should match reasoning content"); + // ...while the usage-derived, cost-relevant column stays exactly 0. + assert.equal(row.tokens_reasoning, 0, "tokens_reasoning stays usage-derived (0)"); + + // Cost math is untouched: reasoning_chars never enters cost; tokens.reasoning is 0. + const cost = computeCostFromPricing( + { input: 5, output: 10, reasoning: 100 }, + { prompt_tokens: 10, completion_tokens: 20, reasoning: 0 } + ); + const costNoReasoning = computeCostFromPricing( + { input: 5, output: 10, reasoning: 100 }, + { prompt_tokens: 10, completion_tokens: 20 } + ); + assert.equal(cost, costNoReasoning, "reasoning contributes 0 to cost when metered 0"); +}); + +// ── Regression: normal usage-reported reasoning keeps source=usage ────────── + +test("saveCallLog keeps reasoning_source=usage when usage reports reasoning tokens", async () => { + const db = getDbInstance(); + const testId = `test-6187-usage-${Date.now()}`; + + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "o3-mini", + provider: "openai", + duration: 100, + tokens: { + prompt_tokens: 5, + completion_tokens: 100, + completion_tokens_details: { reasoning_tokens: 57 }, + }, + responseBody: { + choices: [{ message: { role: "assistant", content: "answer" } }], + }, + }); + + const row = db + .prepare("SELECT tokens_reasoning, reasoning_source, reasoning_chars FROM call_logs WHERE id = ?") + .get(testId) as { + tokens_reasoning: number | null; + reasoning_source: string | null; + reasoning_chars: number | null; + }; + + assert.ok(row, "row should exist"); + assert.equal(row.reasoning_source, "usage", "usage-reported reasoning keeps source=usage"); + assert.equal(row.tokens_reasoning, 57, "tokens_reasoning stays usage-derived (57)"); + assert.equal(row.reasoning_chars, null, "no char count needed when usage is authoritative"); +}); From adde9e4bae9b5c9373bc2b9125c2dd68fefffe6d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:01:28 -0300 Subject: [PATCH 07/61] fix(providers): refresh stale NVIDIA NIM model registry (#6108) (#6223) --- CHANGELOG.md | 1 + .../config/providers/registry/nvidia/index.ts | 13 ++++++----- tests/unit/nvidia-nim-registry-6108.test.ts | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 tests/unit/nvidia-nim-registry-6108.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c4ca2b8f5..a2dd2608ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 🔧 Bug Fixes - **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) +- **fix(providers):** refresh the stale NVIDIA NIM model registry — drop EOL `z-ai/glm-5.1`, add `z-ai/glm-5.2` and `nvidia/nemotron-3-ultra-550b-a55b` ([#6108](https://github.com/diegosouzapw/OmniRoute/issues/6108)). Regression guard: `tests/unit/nvidia-nim-registry-6108.test.ts`. (thanks @andrea-kingautomation) - **fix(backend):** call logs now record a **reasoning source/char-count** (migration 116, `reasoning_source`/`reasoning_chars`) for models that emit `reasoning_content`/`` but report zero reasoning tokens in usage, so `tokens_reasoning` no longer silently under-represents reasoning — cost math is unchanged (the priced `tokens_reasoning` stays usage-derived) ([#6187](https://github.com/diegosouzapw/OmniRoute/issues/6187)). Regression guard: `tests/unit/reasoning-token-source-6187.test.ts`. (thanks @andrea-kingautomation) - **fix(auth):** a stale/changed `STORAGE_ENCRYPTION_KEY` now surfaces as a clear **424 `storage_encryption_stale`** ("re-enter the API key") instead of a misleading "Auth failed: 401" — the connection's ciphertext failed to decrypt and was coerced to an empty Bearer, hiding the real cause ([#6148](https://github.com/diegosouzapw/OmniRoute/issues/6148)). Regression guard: `tests/unit/decrypt-stale-key-hint-6148.test.ts`. (thanks @chirag127) - **fix(backend):** memory injection now keeps the injected system message **first** for providers that require it (via a `PROVIDERS_SYSTEM_MUST_BE_FIRST` capability), instead of the cache-safe mid-array splice that made strict providers reject the request with a 400 ([#6135](https://github.com/diegosouzapw/OmniRoute/issues/6135)). Regression guard: `tests/unit/memory-system-first-6135.test.ts`. diff --git a/open-sse/config/providers/registry/nvidia/index.ts b/open-sse/config/providers/registry/nvidia/index.ts index 152c3279bf..c42f8da703 100644 --- a/open-sse/config/providers/registry/nvidia/index.ts +++ b/open-sse/config/providers/registry/nvidia/index.ts @@ -9,10 +9,12 @@ export const nvidiaProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", models: [ - { id: "z-ai/glm-5.1", name: "GLM 5.1" }, - // #3329: minimaxai/minimax-m3 removed — NVIDIA NIM does not host it yet - // (every request 404s), while minimax-m2.7 on the same provider works. - // Re-add only once NVIDIA actually serves it. + // #6108: z-ai/glm-5.1 EOL'd 2026-07-02 (direct probe returns 410) — dropped. + { id: "z-ai/glm-5.2", name: "GLM 5.2" }, + // #3329/#6108: minimaxai/minimax-m3 stays excluded from the nvidia tier — it + // still 404s here for most callers; the single 200 probe in #6108 was not + // reproducible enough to override the #3329 guard. Re-add only once NVIDIA + // reliably serves it (and flip nvidia-minimax-m3-removed-3329.test.ts then). { id: "minimaxai/minimax-m2.7", name: "MiniMax M2.7" }, { id: "google/gemma-4-31b-it", name: "Gemma 4 31B" }, { id: "mistralai/mistral-small-4-119b-2603", name: "Mistral Small 4 2603" }, @@ -25,11 +27,10 @@ export const nvidiaProvider: RegistryEntry = { { id: "deepseek-ai/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, { id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, // Sweep 2026-06-19: verified present in the live NVIDIA NIM /v1/models catalog. - // minimaxai/minimax-m3 is now listed too, but left out per #3329 until inference - // (not just listing) is confirmed — re-add when a real request stops 404ing. { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false }, { id: "openai/gpt-oss-20b", name: "GPT OSS 20B", toolCalling: false }, { id: "nvidia/nemotron-3-super-120b-a12b", name: "Nemotron 3 Super 120B A12B" }, + { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra 550B" }, ], }; diff --git a/tests/unit/nvidia-nim-registry-6108.test.ts b/tests/unit/nvidia-nim-registry-6108.test.ts new file mode 100644 index 0000000000..b9c12d9992 --- /dev/null +++ b/tests/unit/nvidia-nim-registry-6108.test.ts @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { nvidiaProvider } from "../../open-sse/config/providers/registry/nvidia/index.ts"; + +// Regression guard for #6108: the static NVIDIA NIM model registry had gone +// stale — z-ai/glm-5.1 was EOL'd (410) 2026-07-02, while glm-5.2 and +// nvidia/nemotron-3-ultra-550b-a55b were absent. minimaxai/minimax-m3 stays +// excluded per the #3329 guard (nvidia-minimax-m3-removed-3329.test.ts) — the +// single 200 probe in #6108 wasn't reproducible enough to override it. +const modelIds = new Set(nvidiaProvider.models.map((m) => m.id)); + +test("#6108: NVIDIA NIM registry contains the refreshed live models", () => { + assert.ok(modelIds.has("z-ai/glm-5.2"), "z-ai/glm-5.2 must be present"); + assert.ok( + modelIds.has("nvidia/nemotron-3-ultra-550b-a55b"), + "nvidia/nemotron-3-ultra-550b-a55b must be present" + ); +}); + +test("#6108: NVIDIA NIM registry no longer lists EOL z-ai/glm-5.1", () => { + assert.ok(!modelIds.has("z-ai/glm-5.1"), "EOL z-ai/glm-5.1 must be removed"); +}); From 1473261c4cefc287dfc0e484d8909da29181fa7f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:02:55 -0300 Subject: [PATCH 08/61] fix(backend): distinct max_input_tokens for GPT-family models (#6191) (#6230) --- CHANGELOG.md | 1 + .../config/providers/registry/codex/index.ts | 14 ++++ open-sse/config/providers/shared.ts | 7 ++ src/app/api/v1/models/catalog.ts | 8 ++- src/lib/modelCapabilities.ts | 6 +- tests/unit/gpt-max-input-tokens-6191.test.ts | 67 +++++++++++++++++++ .../unit/model-capabilities-registry.test.ts | 3 +- 7 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 tests/unit/gpt-max-input-tokens-6191.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a2dd2608ff..7a814d2629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) - **fix(providers):** refresh the stale NVIDIA NIM model registry — drop EOL `z-ai/glm-5.1`, add `z-ai/glm-5.2` and `nvidia/nemotron-3-ultra-550b-a55b` ([#6108](https://github.com/diegosouzapw/OmniRoute/issues/6108)). Regression guard: `tests/unit/nvidia-nim-registry-6108.test.ts`. (thanks @andrea-kingautomation) +- **fix(backend):** GPT-family (codex) models now report a distinct `max_input_tokens` (272000) below their 400K `context_length` via an optional `maxInputTokens` on `RegistryModel`, so coding agents auto-compact correctly instead of overflowing the real input cap ([#6191](https://github.com/diegosouzapw/OmniRoute/issues/6191)). Regression guard: `tests/unit/gpt-max-input-tokens-6191.test.ts`. (thanks @luweiCN) - **fix(backend):** call logs now record a **reasoning source/char-count** (migration 116, `reasoning_source`/`reasoning_chars`) for models that emit `reasoning_content`/`` but report zero reasoning tokens in usage, so `tokens_reasoning` no longer silently under-represents reasoning — cost math is unchanged (the priced `tokens_reasoning` stays usage-derived) ([#6187](https://github.com/diegosouzapw/OmniRoute/issues/6187)). Regression guard: `tests/unit/reasoning-token-source-6187.test.ts`. (thanks @andrea-kingautomation) - **fix(auth):** a stale/changed `STORAGE_ENCRYPTION_KEY` now surfaces as a clear **424 `storage_encryption_stale`** ("re-enter the API key") instead of a misleading "Auth failed: 401" — the connection's ciphertext failed to decrypt and was coerced to an empty Bearer, hiding the real cause ([#6148](https://github.com/diegosouzapw/OmniRoute/issues/6148)). Regression guard: `tests/unit/decrypt-stale-key-hint-6148.test.ts`. (thanks @chirag127) - **fix(backend):** memory injection now keeps the injected system message **first** for providers that require it (via a `PROVIDERS_SYSTEM_MUST_BE_FIRST` capability), instead of the cache-safe mid-array splice that made strict providers reject the request with a 400 ([#6135](https://github.com/diegosouzapw/OmniRoute/issues/6135)). Regression guard: `tests/unit/memory-system-first-6135.test.ts`. diff --git a/open-sse/config/providers/registry/codex/index.ts b/open-sse/config/providers/registry/codex/index.ts index 2418a2bfb6..d739e09829 100644 --- a/open-sse/config/providers/registry/codex/index.ts +++ b/open-sse/config/providers/registry/codex/index.ts @@ -28,11 +28,17 @@ export const codexProvider: RegistryEntry = { // 1.05M). Public refs : openai/codex#19208, #19319, #19464 ; // opencode#24171. max_output_tokens is stripped server-side // (litellm#21193, codex#4138) so 128K is informational only. + // The usable INPUT budget is smaller than the 400K window (part is + // reserved for output), so max_input_tokens must be distinct from + // context_length or coding agents never auto-compact (#6191). OpenAI's + // own live catalog reports ~272K for gpt-5.5 in Codex. { id: "gpt-5.5", name: "GPT 5.5", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, + // #6191: input cap per reporter; TODO confirm exact value + maxInputTokens: 272000, maxOutputTokens: 128000, }, { @@ -40,6 +46,8 @@ export const codexProvider: RegistryEntry = { name: "GPT 5.5 (xHigh)", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, + // #6191: input cap per reporter; TODO confirm exact value + maxInputTokens: 272000, maxOutputTokens: 128000, }, { @@ -47,6 +55,8 @@ export const codexProvider: RegistryEntry = { name: "GPT 5.5 (High)", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, + // #6191: input cap per reporter; TODO confirm exact value + maxInputTokens: 272000, maxOutputTokens: 128000, }, { @@ -54,6 +64,8 @@ export const codexProvider: RegistryEntry = { name: "GPT 5.5 (Medium)", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, + // #6191: input cap per reporter; TODO confirm exact value + maxInputTokens: 272000, maxOutputTokens: 128000, }, { @@ -61,6 +73,8 @@ export const codexProvider: RegistryEntry = { name: "GPT 5.5 (Low)", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, + // #6191: input cap per reporter; TODO confirm exact value + maxInputTokens: 272000, maxOutputTokens: 128000, }, { diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index ecc4fd088d..1a90d258e6 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -56,6 +56,13 @@ export interface RegistryModel { unsupportedParams?: readonly string[]; /** Maximum context window in tokens */ contextLength?: number; + /** + * Explicit maximum input-token budget, when it is smaller than the full + * context window (e.g. OAuth backends that reserve part of the window for + * output). When set, catalog/capability builders prefer this over deriving + * max_input_tokens from contextLength (#6191). + */ + maxInputTokens?: number; /** * Interleaved-reasoning signal, mirroring models.dev's `interleaved_field`. * Set to "reasoning_content" for models whose upstream runs DeepSeek thinking diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 664cc0087a..73733107af 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -292,9 +292,13 @@ export async function getUnifiedModelsResponse( registryContext ?? specContext ?? (getTokenLimit(providerId, modelId) || undefined); - const maxInputTokens = isPositiveFiniteNumber(synced?.limit_input) + const registryInputLimit = isPositiveFiniteNumber(registryModel?.maxInputTokens) + ? registryModel.maxInputTokens + : undefined; + const syncedInputLimit = isPositiveFiniteNumber(synced?.limit_input) ? synced.limit_input - : contextLength; + : undefined; + const maxInputTokens = registryInputLimit ?? syncedInputLimit ?? contextLength; const maxOutputTokens = isPositiveFiniteNumber(synced?.limit_output) ? synced.limit_output : isPositiveFiniteNumber(spec?.maxOutputTokens) diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 866d073643..cae1173042 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -406,7 +406,11 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo structuredOutput: synced?.structured_output ?? null, temperature: synced?.temperature ?? null, contextWindow, - maxInputTokens: authoritativeContextWindow ?? synced?.limit_input ?? contextWindow, + maxInputTokens: + (typeof registryModel?.maxInputTokens === "number" ? registryModel.maxInputTokens : null) ?? + authoritativeContextWindow ?? + synced?.limit_input ?? + contextWindow, maxOutputTokens: synced?.limit_output ?? (typeof registryModel?.maxOutputTokens === "number" ? registryModel.maxOutputTokens : null) ?? diff --git a/tests/unit/gpt-max-input-tokens-6191.test.ts b/tests/unit/gpt-max-input-tokens-6191.test.ts new file mode 100644 index 0000000000..f89c42edb1 --- /dev/null +++ b/tests/unit/gpt-max-input-tokens-6191.test.ts @@ -0,0 +1,67 @@ +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"; + +// #6191: GPT-family codex models advertised their full 400K context window as +// BOTH context_length and max_input_tokens, so coding agents never triggered +// auto-compaction. The real usable input budget is smaller (~272K). These tests +// pin the distinct-input-cap behavior and guard the contextLength fallback for +// models that do NOT declare an explicit maxInputTokens. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-gpt-input-cap-6191-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelCapabilities = await import("../../src/lib/modelCapabilities.ts"); + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("codex gpt-5.5 reports max_input_tokens smaller than its context window (#6191)", () => { + const caps = modelCapabilities.getResolvedModelCapabilities("codex/gpt-5.5"); + assert.equal(caps.contextWindow, 400000); + assert.equal(caps.maxInputTokens, 272000); + assert.ok( + (caps.maxInputTokens ?? 0) < (caps.contextWindow ?? 0), + "max_input_tokens must be strictly smaller than context_length so agents compact" + ); +}); + +test("all codex gpt-5.5 effort variants carry the distinct input cap (#6191)", () => { + for (const modelId of [ + "codex/gpt-5.5-xhigh", + "codex/gpt-5.5-high", + "codex/gpt-5.5-medium", + "codex/gpt-5.5-low", + ]) { + const caps = modelCapabilities.getResolvedModelCapabilities(modelId); + assert.equal(caps.contextWindow, 400000, modelId); + assert.equal(caps.maxInputTokens, 272000, modelId); + } +}); + +test("regression: a model without maxInputTokens still falls back to its context window", () => { + // codex gpt-5.4 declares no maxInputTokens, so max_input_tokens must equal + // the context window (the historical fallback) — no under-reporting. + const caps = modelCapabilities.getResolvedModelCapabilities("codex/gpt-5.4"); + assert.ok((caps.contextWindow ?? 0) > 0, "gpt-5.4 should have a context window"); + assert.equal( + caps.maxInputTokens, + caps.contextWindow, + "without an explicit input cap, max_input_tokens falls back to context_length" + ); +}); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index b6582a1ca6..c4be2f0e49 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -108,7 +108,8 @@ test("canonical model capability resolver lets exact synced metadata override gl const codexGpt55 = modelCapabilities.getResolvedModelCapabilities("codex/gpt-5.5"); assert.equal(codexGpt55.contextWindow, 400000); - assert.equal(codexGpt55.maxInputTokens, 400000); + // #6191: max_input_tokens is a distinct, smaller cap than the context window. + assert.equal(codexGpt55.maxInputTokens, 272000); assert.equal(codexGpt55.maxOutputTokens, 128000); assert.equal(codexGpt55.supportsThinking, true); assert.equal(codexGpt55.supportsVision, true); From cf6c2798b42ec08f80bb111b29bd21e8e69974d5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:29:16 -0300 Subject: [PATCH 09/61] fix(oauth): extract keychain-import-only guard to restore file-size freeze (base-red) (#6158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/app/api/oauth/[provider]/[action]/route.ts` grew to 959 lines, past its frozen cap of 924 (`check:file-size` → Fast Quality Gates red on release/v3.8.44). The growth came from #6054 (graceful 400 for keychain-import-only providers / zed): a doc block, two Sets (KEYCHAIN_IMPORT_ONLY_PROVIDERS, OAUTH_FLOW_ACTIONS) and a keychainImportOnlyResponse() helper, plus two duplicated guard blocks in GET/POST. That is a cohesive, self-contained leaf, so extract it to a new `keychainImportOnly.ts` exposing `keychainImportOnlyGuard(provider, action)` (returns the 400 NextResponse or null). The two route callsites collapse to a 2-line guard each. route.ts: 959 -> 918 (< 924, freeze restored). No behavior change. Tests (Rule #8/#18): - Existing tests/unit/oauth-keychain-import-only-6041.test.ts (route-level GET/POST zed 400) still pass unchanged — behavior preserved. - New tests/unit/oauth-keychain-import-only-guard.test.ts pins the extracted guard in isolation (zed+flow -> 400, normal provider -> null, zed+non-flow -> null). --- .../[provider]/[action]/keychainImportOnly.ts | 50 ++++++++++++++++++ .../api/oauth/[provider]/[action]/route.ts | 51 ++----------------- .../oauth-keychain-import-only-guard.test.ts | 38 ++++++++++++++ 3 files changed, 93 insertions(+), 46 deletions(-) create mode 100644 src/app/api/oauth/[provider]/[action]/keychainImportOnly.ts create mode 100644 tests/unit/oauth-keychain-import-only-guard.test.ts diff --git a/src/app/api/oauth/[provider]/[action]/keychainImportOnly.ts b/src/app/api/oauth/[provider]/[action]/keychainImportOnly.ts new file mode 100644 index 0000000000..0ee954f54a --- /dev/null +++ b/src/app/api/oauth/[provider]/[action]/keychainImportOnly.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; + +/** + * Providers that have NO browser OAuth flow at all — their credentials are read + * from the OS keychain via a dedicated Import button, not an OAuth + * authorize/exchange. They are listed in the OAuth provider *catalog* + * (so the dashboard shows them) but have no entry in the OAuth provider + * *handler* registry, so hitting the generic OAuth route for them threw an + * unhandled `Unknown provider: ` 500 (#6041). Return a clear, actionable + * response pointing at the Import flow instead. + * + * Extracted from the OAuth route handler into this leaf module so the route + * stays under its frozen file-size cap (#6155 base-red follow-up). + */ +export const KEYCHAIN_IMPORT_ONLY_PROVIDERS = new Set(["zed"]); + +/** GET/POST OAuth actions that don't apply to keychain-import-only providers. */ +export const OAUTH_FLOW_ACTIONS = new Set([ + "authorize", + "device-code", + "start-callback-server", + "poll-callback", + "exchange", + "poll", + "device-complete", +]); + +function keychainImportOnlyResponse(provider: string) { + return NextResponse.json( + { + error: + `${provider} has no browser OAuth flow — it imports LLM credentials from the ` + + `OS keychain. Use the "Import" button on the ${provider} provider card in the ` + + `dashboard to discover and import them automatically.`, + }, + { status: 400 } + ); +} + +/** + * If `provider` is keychain-import-only and `action` is an OAuth-flow action, + * return the graceful 400 response; otherwise return null so the caller falls + * through to normal OAuth handling. + */ +export function keychainImportOnlyGuard(provider: string, action: string): NextResponse | null { + if (KEYCHAIN_IMPORT_ONLY_PROVIDERS.has(provider) && OAUTH_FLOW_ACTIONS.has(action)) { + return keychainImportOnlyResponse(provider); + } + return null; +} diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 373f572b01..270372eb0e 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -35,6 +35,7 @@ import { import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { keychainImportOnlyGuard } from "./keychainImportOnly"; // Use globalThis to persist callback server state across Next.js HMR reloads if (!globalThis.__codexCallbackState) { @@ -68,40 +69,6 @@ const RETIRED_PKCE_PROVIDERS = new Set(["windsurf", "devin-cli"]); /** Providers that allow direct import of a raw API token (no OAuth exchange). */ const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]); -/** - * Providers that have NO browser OAuth flow at all — their credentials are read - * from the OS keychain via a dedicated Import button, not an OAuth - * authorize/exchange. They are listed in the OAuth provider *catalog* - * (so the dashboard shows them) but have no entry in the OAuth provider - * *handler* registry, so hitting the generic OAuth route for them threw an - * unhandled `Unknown provider: ` 500 (#6041). Return a clear, actionable - * response pointing at the Import flow instead. - */ -const KEYCHAIN_IMPORT_ONLY_PROVIDERS = new Set(["zed"]); - -/** GET/POST OAuth actions that don't apply to keychain-import-only providers. */ -const OAUTH_FLOW_ACTIONS = new Set([ - "authorize", - "device-code", - "start-callback-server", - "poll-callback", - "exchange", - "poll", - "device-complete", -]); - -function keychainImportOnlyResponse(provider: string) { - return NextResponse.json( - { - error: - `${provider} has no browser OAuth flow — it imports LLM credentials from the ` + - `OS keychain. Use the "Import" button on the ${provider} provider card in the ` + - `dashboard to discover and import them automatically.`, - }, - { status: 400 } - ); -} - /** * Constant-time string comparison to prevent timing-oracle attacks (CWE-208). * Handles null/undefined safely and different-length strings. @@ -172,12 +139,8 @@ export async function GET( } // Keychain-import-only providers (e.g. zed) have no OAuth flow — return a // clear 400 pointing at the Import button instead of a 500 (#6041). - if ( - KEYCHAIN_IMPORT_ONLY_PROVIDERS.has(earlyParams.provider) && - OAUTH_FLOW_ACTIONS.has(earlyParams.action) - ) { - return keychainImportOnlyResponse(earlyParams.provider); - } + const kio = keychainImportOnlyGuard(earlyParams.provider, earlyParams.action); + if (kio) return kio; } catch { /* fall through to normal handling */ } @@ -399,12 +362,8 @@ export async function POST( ); } // Keychain-import-only providers (e.g. zed) have no OAuth flow (#6041). - if ( - KEYCHAIN_IMPORT_ONLY_PROVIDERS.has(earlyParams.provider) && - OAUTH_FLOW_ACTIONS.has(earlyParams.action) - ) { - return keychainImportOnlyResponse(earlyParams.provider); - } + const kio = keychainImportOnlyGuard(earlyParams.provider, earlyParams.action); + if (kio) return kio; } catch { /* fall through to normal handling */ } diff --git a/tests/unit/oauth-keychain-import-only-guard.test.ts b/tests/unit/oauth-keychain-import-only-guard.test.ts new file mode 100644 index 0000000000..4160987917 --- /dev/null +++ b/tests/unit/oauth-keychain-import-only-guard.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + keychainImportOnlyGuard, + KEYCHAIN_IMPORT_ONLY_PROVIDERS, + OAUTH_FLOW_ACTIONS, +} from "../../src/app/api/oauth/[provider]/[action]/keychainImportOnly.ts"; + +// Unit coverage for the leaf extracted from the OAuth route (#6155 file-size +// base-red follow-up). The route-level behavior is guarded by +// oauth-keychain-import-only-6041.test.ts; this pins the guard in isolation. + +test("keychainImportOnlyGuard returns a 400 for a keychain-import-only provider on an OAuth-flow action", async () => { + const res = keychainImportOnlyGuard("zed", "authorize"); + assert.ok(res, "expected a response, not null"); + assert.equal(res!.status, 400); + const body = await res!.json(); + assert.match(body.error, /no browser OAuth flow/i); + assert.match(body.error, /Import/); +}); + +test("keychainImportOnlyGuard returns null for a normal OAuth provider", () => { + assert.equal(keychainImportOnlyGuard("openai", "authorize"), null); + assert.equal(keychainImportOnlyGuard("anthropic", "exchange"), null); +}); + +test("keychainImportOnlyGuard returns null for a keychain provider on a non-flow action", () => { + // e.g. a callback/status action that is not in OAUTH_FLOW_ACTIONS + assert.equal(keychainImportOnlyGuard("zed", "status"), null); +}); + +test("the sets stay in sync with the guard's expectations", () => { + assert.ok(KEYCHAIN_IMPORT_ONLY_PROVIDERS.has("zed")); + assert.ok(OAUTH_FLOW_ACTIONS.has("authorize")); + assert.ok(OAUTH_FLOW_ACTIONS.has("exchange")); + assert.ok(!OAUTH_FLOW_ACTIONS.has("status")); +}); From e44f1259927bb8e4dbce58dc411ddf3474297796 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:29:20 -0300 Subject: [PATCH 10/61] fix(dashboard): stop model-test error freezing the page (React #31 object toast) (#6161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking 'test' on a provider model (e.g. a ClinePass flash model) could freeze the entire dashboard. Root cause: POST /api/models/test returned an OBJECT in `error` on the Zod-validation and invalid-JSON paths (`validation.error.format()` / a details object). The client does `notify.error(data.error)`, and NotificationToast renders the message directly as a React child — an object throws React #31 ('Objects are not valid as a React child'), crashing the tree = frozen page instead of a toast. Fixed in three layers (defense in depth): 1. Server (root cause): /api/models/test now returns a STRING `error` on every path — flattens Zod issues to text, returns 'Invalid JSON body' for bad JSON. 2. Client: onTestModel funnels the response through extractApiErrorMessage() so any object-shaped error is coerced to a string before notify.error. 3. Toast: NotificationToast coerces title/message via toToastText() — a resilient catch-all so no future caller can freeze the page with a non-string. Tests (Rule #18, both node:test / blocking suite): - tests/unit/models-test-error-shape.test.ts — asserts STRING error on Zod-fail, missing-field, and invalid-JSON (fails on the pre-fix route: 3/3 red -> green). - tests/unit/notification-toast-coercion.test.ts — toToastText coercion matrix. --- .../[id]/hooks/useModelVisibilityHandlers.ts | 6 +- src/app/api/models/test/route.ts | 22 ++++--- src/shared/components/NotificationToast.tsx | 26 +++++++- tests/unit/models-test-error-shape.test.ts | 65 +++++++++++++++++++ .../unit/notification-toast-coercion.test.ts | 35 ++++++++++ 5 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 tests/unit/models-test-error-shape.test.ts create mode 100644 tests/unit/notification-toast-coercion.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts index e9283d1b77..3fd4b67529 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts @@ -31,6 +31,7 @@ import { type CompatByProtocolMap, } from "../providerPageHelpers"; import { useNotificationStore } from "@/store/notificationStore"; +import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; type NotifyStore = ReturnType; @@ -312,7 +313,10 @@ export function useModelVisibilityHandlers({ ); setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" })); } else { - notify.error(data.error || "Model test failed"); + // extractApiErrorMessage coerces any object-shaped `error` (e.g. a Zod + // format object) to a string so notify.error never hands the toast a + // non-string child (React #31 → frozen page). + notify.error(extractApiErrorMessage(data, "Model test failed")); setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); } } catch (err) { diff --git a/src/app/api/models/test/route.ts b/src/app/api/models/test/route.ts index 376b050610..8044d51d20 100644 --- a/src/app/api/models/test/route.ts +++ b/src/app/api/models/test/route.ts @@ -20,21 +20,23 @@ export async function POST(request: Request) { try { rawBody = await request.json(); } catch { - return NextResponse.json( - { - error: { - message: "Invalid request", - details: [{ field: "body", message: "Invalid JSON body" }], - }, - }, - { status: 400 } - ); + // Keep `error` a plain string — the dashboard renders it directly in a toast, + // and an object here throws React #31 ("Objects are not valid as a React + // child"), freezing the whole page instead of showing the message. + return NextResponse.json({ status: "error", error: "Invalid JSON body" }, { status: 400 }); } try { const validation = testModelSchema.safeParse(rawBody); if (!validation.success) { - return NextResponse.json({ error: validation.error.format() }, { status: 400 }); + // Flatten the Zod issues to a string (never return the object — see above). + const detail = validation.error.issues + .map((i) => `${i.path.join(".") || "body"}: ${i.message}`) + .join("; "); + return NextResponse.json( + { status: "error", error: `Invalid request: ${detail}` }, + { status: 400 } + ); } const { providerId, modelId, connectionId } = validation.data; diff --git a/src/shared/components/NotificationToast.tsx b/src/shared/components/NotificationToast.tsx index b7b6af861a..d6fec929a5 100644 --- a/src/shared/components/NotificationToast.tsx +++ b/src/shared/components/NotificationToast.tsx @@ -19,6 +19,28 @@ const ICONS = { info: "ℹ", }; +/** + * Coerce a toast title/message to a string. `message`/`title` are typed as + * `string`, but callers occasionally pass a raw API error body (an object) — + * rendering that object directly throws React #31 ("Objects are not valid as a + * React child") and freezes the whole page. This keeps the toast resilient no + * matter what a caller hands it. + */ +export function toToastText(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + if (typeof value === "object") { + const message = (value as { message?: unknown }).message; + if (typeof message === "string") return message; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + const BG_DARK = "rgba(30, 30, 30, 0.95)"; const COLORS = { @@ -101,7 +123,7 @@ function Toast({ notification, onDismiss }) { marginBottom: "2px", }} > - {notification.title} + {toToastText(notification.title)} )}
- {notification.message} + {toToastText(notification.message)}
{notification.dismissible && ( diff --git a/tests/unit/models-test-error-shape.test.ts b/tests/unit/models-test-error-shape.test.ts new file mode 100644 index 0000000000..54f98ed493 --- /dev/null +++ b/tests/unit/models-test-error-shape.test.ts @@ -0,0 +1,65 @@ +// Regression guard: POST /api/models/test must always return a STRING `error`, +// never an object. The Zod-validation and invalid-JSON paths used to return +// `{ error: }` (Zod .format() / a details object). The dashboard renders +// that value directly in a toast, so an object froze the whole page (React #31). +// The "test a model → screen froze" bug. +// +// DB handles released in test.after (CLAUDE.md learning: unreleased SQLite +// handles hang node:test). + +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-models-test-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const route = await import("../../src/app/api/models/test/route.ts"); + +test.before(async () => { + await settingsDb.updateSettings({ requireLogin: false }); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function post(body: unknown, rawText?: string) { + return route.POST( + new Request("http://localhost:20128/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: rawText !== undefined ? rawText : JSON.stringify(body), + }) + ); +} + +test("Zod validation failure returns a STRING error (not an object)", async () => { + // connectionId "" fails z.string().min(1).optional() -> validation error path + const res = await post({ providerId: "openai", modelId: "gpt-4o", connectionId: "" }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(typeof body.error, "string", "error must be a string, never an object"); + assert.equal(body.status, "error"); + assert.match(body.error, /Invalid request/i); +}); + +test("missing required field returns a STRING error", async () => { + const res = await post({ providerId: "openai" }); // no modelId + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(typeof body.error, "string"); +}); + +test("invalid JSON body returns a STRING error (not an object)", async () => { + const res = await post(undefined, "{ not json "); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(typeof body.error, "string"); + assert.match(body.error, /Invalid JSON/i); +}); diff --git a/tests/unit/notification-toast-coercion.test.ts b/tests/unit/notification-toast-coercion.test.ts new file mode 100644 index 0000000000..89285ca66b --- /dev/null +++ b/tests/unit/notification-toast-coercion.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { toToastText } from "@/shared/components/NotificationToast"; + +// Regression guard: a toast message/title that is NOT a string (e.g. a raw API +// error body — a Zod `.format()` object) must be coerced to a string. Rendering +// an object as a React child throws React #31 and freezes the whole page. This +// was the "test model → screen froze" bug on the provider page. + +test("returns strings unchanged", () => { + assert.equal(toToastText("hello"), "hello"); + assert.equal(toToastText(""), ""); +}); + +test("returns empty string for null/undefined (never crashes render)", () => { + assert.equal(toToastText(null), ""); + assert.equal(toToastText(undefined), ""); +}); + +test("prefers a nested string .message on an object error body", () => { + assert.equal(toToastText({ message: "Rate limited" }), "Rate limited"); +}); + +test("JSON-stringifies an arbitrary object instead of throwing (Zod .format() shape)", () => { + const zodish = { modelId: { _errors: ["Required"] }, _errors: [] }; + const out = toToastText(zodish); + assert.equal(typeof out, "string"); + assert.ok(out.includes("_errors")); +}); + +test("coerces numbers/booleans to string", () => { + assert.equal(toToastText(42), "42"); + assert.equal(toToastText(true), "true"); +}); From 8a7b62ec857e4cc2a600488d8e1c075526775b68 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:29:24 -0300 Subject: [PATCH 11/61] fix(dashboard): remove the always-on Auto-Routing (combo) banner from the home page (#6164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blue "Auto-Routing Active — OmniRoute is automatically routing requests using combo-based strategies" banner was rendered unconditionally on the home page (`/home`, the default dashboard landing) — it did NOT reflect whether auto-routing was actually active, and reappeared on every fresh browser / private window / cleared localStorage (dismissal is stored per-browser). It added noise to the landing page without conveying live state. Remove it: drop the usage + import from home/page.tsx and delete the now-unused component and its test. --- src/app/(dashboard)/home/page.tsx | 2 - .../components/AutoRoutingBanner.test.tsx | 116 ------------------ src/shared/components/AutoRoutingBanner.tsx | 80 ------------ 3 files changed, 198 deletions(-) delete mode 100644 src/shared/components/AutoRoutingBanner.test.tsx delete mode 100644 src/shared/components/AutoRoutingBanner.tsx diff --git a/src/app/(dashboard)/home/page.tsx b/src/app/(dashboard)/home/page.tsx index 0e27614634..b6c0796ad3 100644 --- a/src/app/(dashboard)/home/page.tsx +++ b/src/app/(dashboard)/home/page.tsx @@ -3,7 +3,6 @@ import { getMachineId } from "@/shared/utils/machine"; import { getSettings } from "@/lib/localDb"; import HomePageClient from "../dashboard/HomePageClient"; import BootstrapBanner from "../dashboard/BootstrapBanner"; -import AutoRoutingBanner from "@/shared/components/AutoRoutingBanner"; export const dynamic = "force-dynamic"; @@ -17,7 +16,6 @@ export default async function HomePage() { return ( <> {isBootstrapped && } - ); diff --git a/src/shared/components/AutoRoutingBanner.test.tsx b/src/shared/components/AutoRoutingBanner.test.tsx deleted file mode 100644 index e01cdd0b1d..0000000000 --- a/src/shared/components/AutoRoutingBanner.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -// @vitest-environment jsdom -import React from "react"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const cleanupCallbacks: Array<() => void> = []; - -function createTestStorage(): Storage { - const entries = new Map(); - return { - get length() { - return entries.size; - }, - clear: () => entries.clear(), - getItem: (key) => entries.get(key) ?? null, - key: (index) => Array.from(entries.keys())[index] ?? null, - removeItem: (key) => { - entries.delete(key); - }, - setItem: (key, value) => { - entries.set(key, value); - }, - }; -} - -function makeContainer(): HTMLElement { - const container = document.createElement("div"); - document.body.appendChild(container); - cleanupCallbacks.push(() => { - container.remove(); - }); - return container; -} - -describe("AutoRoutingBanner", () => { - beforeEach(() => { - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - vi.stubGlobal("localStorage", createTestStorage()); - localStorage.clear(); - }); - - afterEach(() => { - while (cleanupCallbacks.length > 0) { - cleanupCallbacks.pop()?.(); - } - document.body.innerHTML = ""; - localStorage.clear(); - vi.unstubAllGlobals(); - }); - - it("renders banner on first mount", async () => { - const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner"); - const container = makeContainer(); - const root = createRoot(container); - await act(async () => { - root.render(); - }); - expect(container.querySelector('[role="banner"]')).toBeTruthy(); - expect(container.textContent).toContain("Auto-Routing Active"); - }); - - it("includes link to Combos page", async () => { - const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner"); - const container = makeContainer(); - const root = createRoot(container); - await act(async () => { - root.render(); - }); - const link = container.querySelector('a[href="/dashboard/combos"]'); - expect(link).toBeTruthy(); - }); - - it("can be dismissed by clicking close button", async () => { - const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner"); - const container = makeContainer(); - const root = createRoot(container); - await act(async () => { - root.render(); - }); - expect(container.querySelector('[role="banner"]')).toBeTruthy(); - const closeButton = container.querySelector('button[aria-label="Dismiss auto-routing banner"]'); - expect(closeButton).toBeTruthy(); - await act(async () => { - closeButton?.click(); - }); - expect(container.querySelector('[role="banner"]')).toBeFalsy(); - }); - - it("persists dismissal to localStorage", async () => { - const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner"); - const container = makeContainer(); - const root = createRoot(container); - await act(async () => { - root.render(); - }); - const closeButton = container.querySelector('button[aria-label="Dismiss auto-routing banner"]'); - await act(async () => { - closeButton?.click(); - }); - expect(localStorage.getItem("auto-routing-banner-dismissed")).toBe("true"); - }); - - it("remains hidden after dismissal on remount", async () => { - localStorage.setItem("auto-routing-banner-dismissed", "true"); - const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner"); - const container = makeContainer(); - const root = createRoot(container); - await act(async () => { - root.render(); - }); - expect(container.querySelector('[role="banner"]')).toBeFalsy(); - }); -}); diff --git a/src/shared/components/AutoRoutingBanner.tsx b/src/shared/components/AutoRoutingBanner.tsx deleted file mode 100644 index 3c232cdc8d..0000000000 --- a/src/shared/components/AutoRoutingBanner.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useEffect, useState } from "react"; - -const AUTO_ROUTING_DISMISSED_KEY = "auto-routing-banner-dismissed"; - -export default function AutoRoutingBanner() { - const [isDismissed, setIsDismissed] = useState(false); - - useEffect(() => { - try { - const dismissed = localStorage.getItem(AUTO_ROUTING_DISMISSED_KEY); - if (dismissed === "true") { - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsDismissed(true); - } - } catch { - // localStorage unavailable (SSR or private mode) — do nothing - } - }, []); - - const handleDismiss = () => { - try { - localStorage.setItem(AUTO_ROUTING_DISMISSED_KEY, "true"); - } catch { - // ignore localStorage errors (private mode, quotas) - } - - setIsDismissed(true); - }; - - if (isDismissed) return null; - - return ( -
-
-
- - - Auto-Routing Active - -
-
- OmniRoute is automatically routing requests using combo-based strategies. - - View or change your routing configuration on the{" "} - - Combos page - - . - -
- -
-
- ); -} From f2ad9b23bdeebbb92020e5f3a5b4459c3da4c1f5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:29:28 -0300 Subject: [PATCH 12/61] fix(cline): force upstream streaming for Cline/ClinePass (streaming-only API) (#6165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cline): force upstream streaming for Cline/ClinePass (streaming-only API) Cline's API (api.cline.bot) only implements streaming (streamText). A non-streaming request returns HTTP 500 "generateText is not implemented" (Claude models) or HTTP 502 "empty response" (others). Live-verified on the VPS: stream:true → works (STREAM_OK), stream:false → fails. This is why testing a Cline model in the dashboard (the test button sends stream:false) failed. Fix (reuses the existing isClaudeCodeCompatible mechanism, no new handler): - Flag `cline` and `clinepass` registry entries with `forceStream: true`. - In chatCore, OR `providerRequiresStreaming` into `upstreamStream` (line 1591) so the upstream request always streams for these providers, while the client's original `stream` intent still drives the response format. The existing non-streaming branch (parseNonStreamingResponseBody) already accumulates the upstream SSE and converts it back to JSON for stream:false clients — the same path Claude-Code-compatible providers already use. Tests (Rule #18): tests/unit/cline-force-stream.test.ts pins the registry flags + resolveStreamFlag forcing behavior. Live VPS before/after recorded on the PR. * fix(sse): cline forceStream must stream upstream only, keep client JSON The #2081 wiring fed providerRequiresStreaming into resolveStreamFlag, forcing the client-facing stream flag to true for forceStream providers. That skips the if(!stream) branch that drains a forced upstream SSE and converts it back to JSON, so a stream:false caller (model-test button, plain JSON API) got STREAM_EARLY_EOF instead of a JSON body. Keep providerRequiresStreaming only on upstreamStream (force upstream to stream); leave the client-facing stream as the client sent it, so readNonStreamingResponseBody accumulates the SSE into JSON. The promised handleForcedSSEToJson (#2081 comment) was never implemented — this uses the existing non-streaming SSE-buffering path (same as isClaudeCodeCompatible). Live-verified on VPS: cline stream:true worked, stream:false failed. --- .../config/providers/registry/cline/index.ts | 5 +++ .../providers/registry/clinepass/index.ts | 5 +++ open-sse/handlers/chatCore.ts | 22 +++++++--- tests/unit/cline-force-stream.test.ts | 43 +++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 tests/unit/cline-force-stream.test.ts diff --git a/open-sse/config/providers/registry/cline/index.ts b/open-sse/config/providers/registry/cline/index.ts index d1467fdd9f..4913465d19 100644 --- a/open-sse/config/providers/registry/cline/index.ts +++ b/open-sse/config/providers/registry/cline/index.ts @@ -5,6 +5,11 @@ export const clineProvider: RegistryEntry = { alias: "cl", format: "openai", executor: "openai", + // Cline's API only implements streaming (streamText). A non-streaming request + // returns "generateText is not implemented" / an empty body, so force upstream + // streaming and let chatCore convert the SSE back to JSON for stream:false + // clients (e.g. the model-test button, non-streaming API callers). + forceStream: true, baseUrl: "https://api.cline.bot/api/v1/chat/completions", authType: "oauth", authHeader: "Authorization", diff --git a/open-sse/config/providers/registry/clinepass/index.ts b/open-sse/config/providers/registry/clinepass/index.ts index 3ea7dbcb3a..e6d3697a03 100644 --- a/open-sse/config/providers/registry/clinepass/index.ts +++ b/open-sse/config/providers/registry/clinepass/index.ts @@ -9,6 +9,11 @@ export const clinepassProvider: RegistryEntry = { alias: "clinepass", format: "openai", executor: "default", + // ClinePass shares Cline's streaming-only API — a non-streaming request returns + // "generateText is not implemented" / an empty body. Force upstream streaming; + // chatCore accumulates the SSE and converts it back to JSON for stream:false + // clients. (Same as the sibling `cline` provider.) + forceStream: true, baseUrl: "https://api.cline.bot/api/v1/chat/completions", authType: "apikey", authHeader: "bearer", diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e6f7cb7391..54657d6737 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -873,9 +873,16 @@ export async function handleChatCore({ // sourceFormat="claude" applies the Anthropic Messages spec default (stream=false // when body omits stream), preventing STREAM_EARLY_EOF on /v1/messages when // clients send Accept: */* without an explicit stream flag. - // providerRequiresStreaming: providers with forceStream:true reject stream:false - // upstream (HTTP 400); keep streaming so OmniRoute can convert the stream to JSON - // for the client via handleForcedSSEToJson. (#2081) + // providerRequiresStreaming: providers with forceStream:true (cline/clinepass) + // only implement upstream streaming — a non-streaming request returns + // "generateText is not implemented" / an empty body. This flag forces the + // UPSTREAM request to stream (see `upstreamStream` below), but it MUST NOT + // force the client-facing `stream` flag: a stream:false client (e.g. the + // model-test button, plain JSON API callers) still expects a JSON response. + // The client-side `if (!stream)` branch drains the forced upstream SSE and + // converts it back to JSON via readNonStreamingResponseBody. Passing this + // flag into resolveStreamFlag would force `stream=true` and skip that + // conversion, yielding STREAM_EARLY_EOF for JSON callers. (#2081, #6126) const providerRequiresStreaming = REGISTRY[provider]?.forceStream === true; const stream = nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath) @@ -883,7 +890,6 @@ export async function handleChatCore({ : resolveStreamFlag(body?.stream, acceptHeader, sourceFormat, { userAgent: streamUserAgent, streamDefaultMode: apiKeyInfo?.streamDefaultMode, - providerRequiresStreaming, }); // `settings` is already consolidated once near the top of handleChatCore @@ -1588,7 +1594,13 @@ export async function handleChatCore({ headers: clientRawRequest?.headers, userAgent, }); - const upstreamStream = stream || isClaudeCodeCompatible; + // `forceStream` providers (e.g. Cline / ClinePass) only implement upstream + // streaming — a non-streaming request returns "generateText is not implemented" + // / an empty body. Force the upstream request to stream even when the client + // wants JSON; the non-streaming branch below accumulates the SSE and converts + // it back to JSON (same mechanism already used for Claude-Code-compatible + // providers via isClaudeCodeCompatible). + const upstreamStream = stream || isClaudeCodeCompatible || providerRequiresStreaming; let ccSessionId: string | null = null; const stripTypes = getStripTypesForProviderModel(provider || "", model || ""); diff --git a/tests/unit/cline-force-stream.test.ts b/tests/unit/cline-force-stream.test.ts new file mode 100644 index 0000000000..2c8ea5caaf --- /dev/null +++ b/tests/unit/cline-force-stream.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { REGISTRY } from "@omniroute/open-sse/config/providers/index.ts"; +import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat.ts"; + +// Cline / ClinePass only implement upstream streaming — a non-streaming request +// returns "generateText is not implemented" / an empty body. They carry +// `forceStream: true` so chatCore forces the UPSTREAM request to stream +// (`upstreamStream = stream || isClaudeCodeCompatible || providerRequiresStreaming`) +// even when the client wants JSON. The client-facing `stream` flag stays as the +// client sent it, so the `if (!stream)` branch drains the forced upstream SSE and +// converts it back to JSON via readNonStreamingResponseBody. Regression guard for +// the "cline model test → generateText is not implemented / STREAM_EARLY_EOF" bug +// (live-verified on the VPS: stream:true works, stream:false failed). (#6126) + +test("cline provider is flagged forceStream (streaming-only upstream)", () => { + assert.equal(REGISTRY.cline?.forceStream, true); +}); + +test("clinepass provider is flagged forceStream (streaming-only upstream)", () => { + assert.equal(REGISTRY.clinepass?.forceStream, true); +}); + +test("upstreamStream is forced true for a forceStream provider even when the client sent stream:false", () => { + // Mirror the chatCore wiring: providerRequiresStreaming derives from the + // registry flag, and upstreamStream ORs it in so the upstream always streams. + const providerRequiresStreaming = REGISTRY.cline?.forceStream === true; + const isClaudeCodeCompatible = false; + const clientStream = false; // client asked for JSON + const upstreamStream = clientStream || isClaudeCodeCompatible || providerRequiresStreaming; + assert.equal(upstreamStream, true); +}); + +test("client-facing stream stays false for a stream:false JSON caller (so SSE→JSON conversion runs)", () => { + // chatCore MUST NOT pass providerRequiresStreaming into resolveStreamFlag: + // a stream:false client keeps stream=false so the `if (!stream)` branch drains + // the forced upstream SSE and returns JSON. Forcing stream=true here would skip + // that conversion and yield STREAM_EARLY_EOF for JSON callers. + assert.equal(resolveStreamFlag(false, "application/json", "openai"), false); + // A stream:true client still streams end-to-end. + assert.equal(resolveStreamFlag(true, "application/json", "openai"), true); +}); From b3a2cfe0eac65ac502d34f9af286a27a4efc21b5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:29:32 -0300 Subject: [PATCH 13/61] fix(providers): correct Kiro model catalog to real upstream ids (#6170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): correct Kiro model catalog to real upstream ids Kiro's API (generateAssistantResponse) returns 400 "Invalid model. Please select a different model" for any id it does not recognize. The registry exposed fabricated ids (copied from OmniRoute's own Anthropic catalog) that Kiro never serves, so every call to them 400'd. Live-verified on the VPS: Removed (400 Invalid model): - auto-kiro (no "auto" model id — was sent verbatim upstream) - claude-fable-5 (Kiro offers no Fable) - claude-opus-4.8/4.7/4.6 (Kiro offers no Opus) Corrected: - claude-sonnet-4.6 -> claude-sonnet-4.5 (Kiro's Sonnet is 4.5; 4.5 -> 200) Kept: - claude-sonnet-5 (real Kiro model, plan-gated per account) - claude-haiku-4.5, deepseek-3.2, glm-5, minimax-m2.5/m2.1, qwen3-coder-next (all proven 200 on the VPS) Aligns the free-model catalog and drops the orphaned auto-kiro price key. Regression guard: tests/unit/kiro-catalog-real-models.test.ts (3/3). Kiro cluster #6112/#6113/#6099. * test(providers): align stale Kiro-catalog tests to the corrected upstream ids The fabricated Kiro ids removed in the parent commit (claude-fable-5, claude-opus-4.8/4.7/4.6, claude-sonnet-4.6) were still asserted as present by three pre-existing tests, which encoded the bug: - catalog-updates-v3x: now asserts Kiro does NOT expose Fable 5 / Opus (kept the legit cc exposure) and guards the real claude-sonnet-4.5 pricing. - model-family-fallback-notation: the dot-notation example moves from kiro/ to anthropic/ (which genuinely serves Opus/Fable in dot notation) — coverage kept. - provider-models-route: the Kiro local-catalog assertion now expects the real Sonnet 5 / Sonnet 4.5 set and negatively guards the fabricated ids. Co-authored-by: diegosouzapw --- open-sse/config/freeModelCatalog.data.ts | 6 +- .../config/providers/registry/kiro/index.ts | 36 +++--------- open-sse/translator/request/openai-to-kiro.ts | 6 +- .../constants/pricing/oauth-subscriptions.ts | 13 ++--- tests/unit/catalog-updates-v3x.test.ts | 50 +++++++--------- tests/unit/kiro-catalog-real-models.test.ts | 58 +++++++++++++++++++ .../model-family-fallback-notation.test.ts | 20 ++++--- tests/unit/provider-models-config.test.ts | 12 ++-- tests/unit/provider-models-route.test.ts | 6 +- 9 files changed, 118 insertions(+), 89 deletions(-) create mode 100644 tests/unit/kiro-catalog-real-models.test.ts diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 7074fcb3dd..81cfb498a7 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -245,11 +245,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "kilo-gateway", modelId: "nvidia/nemotron-3-ultra-550b-a55b:free", displayName: "NVIDIA Nemotron 3 Ultra (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, { provider: "kilo-gateway", modelId: "nvidia/nemotron-3-super-120b-a12b:free", displayName: "NVIDIA Nemotron 3 Super (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, { provider: "kilo-gateway", modelId: "nex-agi/nex-n2-pro:free", displayName: "Nex-N2-Pro (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "kilo-gateway-free", tos: "caution" }, - { provider: "kiro", modelId: "auto-kiro", displayName: "Auto (Kiro picks best model)", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, - { provider: "kiro", modelId: "claude-opus-4.8", displayName: "Claude Opus 4.8", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, - { provider: "kiro", modelId: "claude-opus-4.7", displayName: "Claude Opus 4.7", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, - { provider: "kiro", modelId: "claude-opus-4.6", displayName: "Claude Opus 4.6", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, - { provider: "kiro", modelId: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, + { provider: "kiro", modelId: "claude-sonnet-4.5", displayName: "Claude Sonnet 4.5", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "claude-haiku-4.5", displayName: "Claude Haiku 4.5", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "deepseek-3.2", displayName: "DeepSeek V3.2", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, { provider: "kiro", modelId: "minimax-m2.5", displayName: "MiniMax M2.5", monthlyTokens: 25000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "kiro", tos: "avoid" }, diff --git a/open-sse/config/providers/registry/kiro/index.ts b/open-sse/config/providers/registry/kiro/index.ts index 6c4d41cf6e..71262a4f09 100644 --- a/open-sse/config/providers/registry/kiro/index.ts +++ b/open-sse/config/providers/registry/kiro/index.ts @@ -15,32 +15,14 @@ export const kiroProvider: RegistryEntry = { tokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev", }, + // Model IDs must match Kiro's real upstream catalog exactly — an unknown id + // makes Kiro return `400 "Invalid model. Please select a different model"`. + // Fabricated ids (auto-kiro, claude-opus-4.x, claude-fable-5, claude-sonnet-4.6) + // were removed after live VPS validation: Kiro offers no Opus/Fable, its Sonnet + // is 4.5 (not 4.6), and there is no "auto" model id (it was sent verbatim and + // 400'd). claude-sonnet-5 is a real Kiro model but plan-gated per account — + // kept so entitled accounts can use it. See kiro cluster #6112/#6113/#6099. models: [ - { id: "auto-kiro", name: "Auto (Kiro picks best model)" }, - { - id: "claude-fable-5", - name: "Claude Fable 5", - contextLength: 1000000, - maxOutputTokens: 128000, - }, - { - id: "claude-opus-4.8", - name: "Claude Opus 4.8", - contextLength: 1000000, - maxOutputTokens: 128000, - }, - { - id: "claude-opus-4.7", - name: "Claude Opus 4.7", - contextLength: 1000000, - maxOutputTokens: 128000, - }, - { - id: "claude-opus-4.6", - name: "Claude Opus 4.6", - contextLength: 1000000, - maxOutputTokens: 128000, - }, { id: "claude-sonnet-5", name: "Claude Sonnet 5", @@ -48,8 +30,8 @@ export const kiroProvider: RegistryEntry = { maxOutputTokens: 128000, }, { - id: "claude-sonnet-4.6", - name: "Claude Sonnet 4.6", + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", contextLength: 200000, maxOutputTokens: 64000, }, diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index fc6d39abfd..02cbd4aa1e 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -57,9 +57,9 @@ function convertMessages(messages, tools, model) { let toolsAttached = false; // Only Claude models support images in Kiro. Kiro also routes non-Claude - // models (deepseek, minimax, glm, qwen3-coder-next, auto-kiro) that do not - // accept image attachments — gate image extraction behind a Claude check so - // we never attach images those models would reject. + // models (deepseek, minimax, glm, qwen3-coder-next) that do not accept image + // attachments — gate image extraction behind a Claude check so we never + // attach images those models would reject. const supportsImages = typeof model === "string" && model.toLowerCase().includes("claude"); const flushPending = () => { diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index b5aa770351..04792b5ea1 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -584,7 +584,10 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 8.0, cache_creation: 2.0, }, - // Kiro "Auto" model — routes to best available + // Kiro "Auto" pricing — retained as a fallback price for any legacy "auto" + // reference. The "auto-kiro" registry model was removed (Kiro's API has no + // "auto" model id — it 400'd "Invalid model"), so its dedicated price key + // was dropped with it. See kiro cluster #6112/#6113/#6099. auto: { input: 3.0, output: 15.0, @@ -592,13 +595,5 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 15.0, cache_creation: 3.0, }, - // Registry exposes the Auto model as id "auto-kiro" — keep both keys priced. - "auto-kiro": { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, }, }; diff --git a/tests/unit/catalog-updates-v3x.test.ts b/tests/unit/catalog-updates-v3x.test.ts index a246171cea..0f3bbc80ee 100644 --- a/tests/unit/catalog-updates-v3x.test.ts +++ b/tests/unit/catalog-updates-v3x.test.ts @@ -47,23 +47,19 @@ test("NVIDIA catalog includes the verified 2026 additions and GPT OSS 20B alias }); }); -test("Fable 5 catalog exposes claude-fable-5 in cc and kiro providers with matching pricing", () => { +test("Fable 5 catalog exposes claude-fable-5 in cc — but NOT via Kiro (fabricated)", () => { + // claude-fable-5 is a real Claude flagship served by the Claude Code (cc) channel, + // but Kiro's upstream never served it — it had been copied verbatim into the Kiro + // registry from OmniRoute's own Anthropic catalog and returned upstream 400 + // "Invalid model". #6170 removed the fabricated Kiro copy. const ccIds = new Set(getModelsByProviderId("cc").map((m) => m.id)); assert.ok(ccIds.has("claude-fable-5"), "cc must expose claude-fable-5"); - const kiroModels = getModelsByProviderId("kiro"); - const kiroIds = new Set(kiroModels.map((m) => m.id)); - assert.ok(kiroIds.has("claude-fable-5"), "kiro must expose claude-fable-5"); - - const fable = kiroModels.find((m) => m.id === "claude-fable-5"); - assert.equal(fable?.contextLength, 1000000); - assert.equal(fable?.maxOutputTokens, 128000); - const ccPricing = (DEFAULT_PRICING as Record>).cc; assert.ok(ccPricing["claude-fable-5"], "cc pricing must include claude-fable-5"); - const kiroPricing = (DEFAULT_PRICING as Record>).kiro; - assert.ok(kiroPricing["claude-fable-5"], "kiro pricing must include claude-fable-5"); + const kiroIds = new Set(getModelsByProviderId("kiro").map((m) => m.id)); + assert.equal(kiroIds.has("claude-fable-5"), false, "kiro must NOT expose claude-fable-5 (fabricated)"); }); test("Sonnet 5 catalog exposes claude-sonnet-5 across cc/kiro/anthropic/blackbox with Sonnet-tier pricing", () => { @@ -89,20 +85,15 @@ test("Sonnet 5 catalog exposes claude-sonnet-5 across cc/kiro/anthropic/blackbox assert.equal(kiroSonnet5Price.output, 15.0); }); -test("Kiro catalog exposes Claude Opus 4.8 alongside 4.7 with matching pricing", () => { - const models = getModelsByProviderId("kiro"); - const ids = new Set(models.map((model) => model.id)); +test("Kiro catalog does NOT expose Claude Opus (fabricated — Kiro upstream has no Opus)", () => { + // Kiro's real upstream never served any Opus model; the Opus 4.8/4.7/4.6 ids had been + // copied into the Kiro registry from OmniRoute's Anthropic catalog and returned upstream + // 400 "Invalid model. Please select a different model". #6170 removed them. + const ids = new Set(getModelsByProviderId("kiro").map((model) => model.id)); - assert.ok(ids.has("claude-opus-4.8"), "kiro must expose claude-opus-4.8"); - assert.ok(ids.has("claude-opus-4.7"), "kiro must still expose claude-opus-4.7"); - - const opus48 = models.find((model) => model.id === "claude-opus-4.8"); - assert.equal(opus48?.contextLength, 1000000); - assert.equal(opus48?.maxOutputTokens, 128000); - - // Pricing for the Kiro channel must cover the new model so usage cost is non-zero. - const kiroPricing = (DEFAULT_PRICING as Record>).kiro; - assert.ok(kiroPricing["claude-opus-4.8"], "kiro pricing must include claude-opus-4.8"); + assert.equal(ids.has("claude-opus-4.8"), false, "kiro must NOT expose claude-opus-4.8 (fabricated)"); + assert.equal(ids.has("claude-opus-4.7"), false, "kiro must NOT expose claude-opus-4.7 (fabricated)"); + assert.equal(ids.has("claude-opus-4.6"), false, "kiro must NOT expose claude-opus-4.6 (fabricated)"); }); test("Every Kiro registry model resolves a non-zero pricing row (no $0.00 usage)", async () => { @@ -124,14 +115,15 @@ test("Every Kiro registry model resolves a non-zero pricing row (no $0.00 usage) ); } - // Regression guard for the reported issue: Sonnet 4.6 must be priced like Sonnet 4.5. - const sonnet46 = getPricingForModel("kiro", "claude-sonnet-4.6") as { + // Regression guard: Kiro's real Sonnet is 4.5 (the "4.6" id was fabricated — #6170) and + // must carry Sonnet-tier pricing ($3/$15). + const sonnet45 = getPricingForModel("kiro", "claude-sonnet-4.5") as { input: number; output: number; } | null; - assert.ok(sonnet46, "kiro pricing must include claude-sonnet-4.6"); - assert.equal(sonnet46?.input, 3.0); - assert.equal(sonnet46?.output, 15.0); + assert.ok(sonnet45, "kiro pricing must include claude-sonnet-4.5"); + assert.equal(sonnet45?.input, 3.0); + assert.equal(sonnet45?.output, 15.0); }); test("Every OpenAI registry model resolves a non-zero pricing row (alias: openai)", async () => { diff --git a/tests/unit/kiro-catalog-real-models.test.ts b/tests/unit/kiro-catalog-real-models.test.ts new file mode 100644 index 0000000000..e896ca5e44 --- /dev/null +++ b/tests/unit/kiro-catalog-real-models.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { REGISTRY } from "@omniroute/open-sse/config/providers/index.ts"; +import { FREE_MODEL_BUDGETS } from "@omniroute/open-sse/config/freeModelCatalog.data.ts"; + +// Kiro's upstream (`generateAssistantResponse`) returns 400 "Invalid model. +// Please select a different model" for any model id it does not recognize. The +// registry must therefore expose ONLY ids Kiro actually serves. These ids were +// fabricated (copied from OmniRoute's own Anthropic catalog) and live-verified +// to 400 on the VPS — they must never reappear. Regression guard for the kiro +// cluster (#6112/#6113/#6099). +const FABRICATED_KIRO_IDS = [ + "auto-kiro", // no "auto" model id on Kiro — was sent verbatim and 400'd + "claude-fable-5", // Kiro offers no Fable + "claude-opus-4.8", // Kiro offers no Opus + "claude-opus-4.7", + "claude-opus-4.6", + "claude-sonnet-4.6", // Kiro's Sonnet is 4.5, not 4.6 +]; + +// Ids proven to return 200 on the VPS (or a real, plan-gated Kiro model). +const REAL_KIRO_IDS = [ + "claude-sonnet-5", // real model, plan-gated per account (kept) + "claude-sonnet-4.5", // proven 200 (replaces the fabricated 4.6) + "claude-haiku-4.5", // proven 200 + "deepseek-3.2", // proven 200 + "glm-5", // proven 200 + "minimax-m2.5", // proven 200 + "minimax-m2.1", // proven 200 + "qwen3-coder-next", // proven 200 +]; + +test("kiro registry exposes no fabricated model ids", () => { + const ids = new Set((REGISTRY.kiro?.models || []).map((m) => m.id)); + for (const bad of FABRICATED_KIRO_IDS) { + assert.ok(!ids.has(bad), `kiro registry must not expose fabricated id "${bad}"`); + } +}); + +test("kiro registry exposes exactly the real Kiro model ids", () => { + const ids = (REGISTRY.kiro?.models || []).map((m) => m.id).sort(); + assert.deepEqual(ids, [...REAL_KIRO_IDS].sort()); +}); + +test("kiro free-model catalog carries no fabricated ids", () => { + const kiroCatalogIds = new Set( + FREE_MODEL_BUDGETS.filter((e) => e.provider === "kiro").map((e) => e.modelId) + ); + for (const bad of FABRICATED_KIRO_IDS) { + assert.ok(!kiroCatalogIds.has(bad), `free catalog must not list fabricated kiro id "${bad}"`); + } + // Every kiro free-catalog entry must exist in the registry (no orphans). + const registryIds = new Set((REGISTRY.kiro?.models || []).map((m) => m.id)); + for (const id of kiroCatalogIds) { + assert.ok(registryIds.has(id), `free catalog kiro id "${id}" is not in the registry`); + } +}); diff --git a/tests/unit/model-family-fallback-notation.test.ts b/tests/unit/model-family-fallback-notation.test.ts index bda527d98d..b52e58e8d1 100644 --- a/tests/unit/model-family-fallback-notation.test.ts +++ b/tests/unit/model-family-fallback-notation.test.ts @@ -4,8 +4,10 @@ import assert from "node:assert/strict"; // Covers getNextFamilyFallback's dot-vs-hyphen notation resolution (the hunk // added alongside Claude Fable 5 in #3524 that affects ALL families): the // lookup normalizes dots→hyphens, and each candidate is resolved to the -// notation the provider's registry actually exposes (kiro uses dot notation -// `claude-opus-4.8`, cc uses hyphen `claude-opus-4-8`). +// notation the provider's registry actually exposes (anthropic uses dot notation +// `claude-opus-4.8`, cc uses hyphen `claude-opus-4-8`). Kiro is NOT used as the +// dot-notation example any more — its upstream never served Opus/Fable and #6170 +// removed the fabricated ids; `anthropic` genuinely serves them in dot notation. const { getNextFamilyFallback } = await import("../../open-sse/services/modelFamilyFallback.ts"); test("Fable 5 falls back to the next-best Opus tier first (not Sonnet) — cc→claude", () => { @@ -14,16 +16,16 @@ test("Fable 5 falls back to the next-best Opus tier first (not Sonnet) — cc→ assert.equal(next, "claude/claude-opus-4-8"); }); -test("Fable 5 fallback resolves to kiro's dot-notation model id", () => { - // kiro registry exposes `claude-opus-4.8` (dot), not `claude-opus-4-8`. - const next = getNextFamilyFallback("kiro/claude-fable-5", new Set(["kiro/claude-fable-5"])); - assert.equal(next, "kiro/claude-opus-4.8"); +test("Fable 5 fallback resolves to anthropic's dot-notation model id", () => { + // anthropic registry exposes `claude-opus-4.8` (dot), not `claude-opus-4-8`. + const next = getNextFamilyFallback("anthropic/claude-fable-5", new Set(["anthropic/claude-fable-5"])); + assert.equal(next, "anthropic/claude-opus-4.8"); }); test("dot-notation current model is normalized for the family lookup", () => { - // kiro/claude-opus-4.8 must find the claude-opus-4-8 family entry. - const next = getNextFamilyFallback("kiro/claude-opus-4.8", new Set(["kiro/claude-opus-4.8"])); - assert.equal(next, "kiro/claude-opus-4.7"); + // anthropic/claude-opus-4.8 must find the claude-opus-4-8 family entry. + const next = getNextFamilyFallback("anthropic/claude-opus-4.8", new Set(["anthropic/claude-opus-4.8"])); + assert.equal(next, "anthropic/claude-opus-4.7"); }); test("skips already-tried candidates and advances down the Fable chain", () => { diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index be6acd59ab..747085b828 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -105,11 +105,15 @@ test("Kiro registry exposes the current CLI model lineup with context windows", const kiroModels = getProviderModels("kr"); const byId = new Map(kiroModels.map((model) => [model.id, model])); - assert.ok(byId.has("claude-opus-4.7")); - assert.equal(byId.get("claude-opus-4.7")?.contextLength, 1000000); - assert.ok(byId.has("claude-sonnet-4.6")); + // Kiro's real upstream Claude lineup (#6170): Sonnet 5 / Sonnet 4.5 / Haiku 4.5. + // The Opus 4.x and Sonnet 4.6 ids were fabricated (copied from the Anthropic + // catalog) and returned upstream 400 "Invalid model" — removed. + assert.ok(byId.has("claude-sonnet-5")); + assert.equal(byId.get("claude-sonnet-5")?.contextLength, 1000000); + assert.ok(byId.has("claude-sonnet-4.5")); assert.ok(byId.has("claude-haiku-4.5")); - assert.equal(byId.has("claude-opus-4-7"), false); + assert.equal(byId.has("claude-opus-4.7"), false); + assert.equal(byId.has("claude-sonnet-4.6"), false); assert.equal(byId.has("claude-sonnet-4-6"), false); assert.equal(byId.has("claude-haiku-4-5"), false); }); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index f83c81e5a3..ed8e5d5e8d 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -734,9 +734,9 @@ test("provider models route returns the expanded local catalog for Kiro", async assert.equal(response.status, 200); assert.equal(body.provider, "kiro"); assert.equal(body.source, "local_catalog"); - assert.ok(body.models.some((model) => model.id === "claude-haiku-4.5")); - assert.ok(body.models.some((model) => model.id === "claude-opus-4.7")); - assert.ok(body.models.some((model) => model.id === "claude-sonnet-4.6")); + const kiroIds = new Set(body.models.map((model) => model.id)); // #6170: real upstream lineup + assert.ok(kiroIds.has("claude-sonnet-5") && kiroIds.has("claude-sonnet-4.5") && kiroIds.has("claude-haiku-4.5")); + assert.equal(kiroIds.has("claude-opus-4.7") || kiroIds.has("claude-sonnet-4.6"), false); // fabricated ids removed }); test("provider models route returns the local catalog for new built-in chat-openai-compat providers", async () => { From 286fdf8794ce96290753a2dec4cbc9d09a5cb1a9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:29:37 -0300 Subject: [PATCH 14/61] fix(sse): surface ChatGPT-web image silent-drop as an accurate error (#6208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ChatGPT Web generates an image as an image_asset_pointer but the pointer fails to resolve to a downloadable URL (unknown asset scheme, download 403/ expired, oversize), resolveImagePointers returned [] — indistinguishable from 'no image produced' — so the image-generation handler reported the misleading 502 'completed without returning image markdown'. The image genuinely existed upstream; OmniRoute dropped it silently. Fix: the executor flags x_image_resolution_failed when a pointer existed but none resolved (and logs the unresolved asset scheme for follow-up), and the handler surfaces a truthful 'generated but not retrievable' 502 instead of 'no image markdown'. Adds executorFactory DI for unit testing. TDD: tests/unit/chatgpt-web-image-silentdrop.test.ts (red -> green), plus the existing chatgpt-web / image-generation-handler suites stay green. Reported via community triage (mesh escalated backlog). --- open-sse/executors/chatgpt-web.ts | 33 +++++++ .../imageGeneration/providers/chatgptWeb.ts | 16 +++- .../unit/chatgpt-web-image-silentdrop.test.ts | 96 +++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 tests/unit/chatgpt-web-image-silentdrop.test.ts diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 27085cf3c6..17125d4fcb 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -1579,6 +1579,21 @@ type ImageResolver = ( parentMessageId?: string | null ) => Promise; +/** + * True when ChatGPT emitted an image asset pointer (the image WAS generated + * upstream) but none of the pointers could be resolved to a downloadable URL + * — so the assistant text carries no image markdown. Lets callers surface an + * accurate "generated but not retrievable" error instead of the misleading + * "no image was produced". Escalated mesh report: image visible in the ChatGPT + * chat but returned to OmniRoute as a bare "completed without image markdown". + */ +export function detectImageResolutionFailure( + pointerCount: number, + resolvedCount: number +): boolean { + return pointerCount > 0 && resolvedCount === 0; +} + /** Build the final markdown block for a list of resolved image URLs. */ function imageMarkdown(urls: string[]): string { if (urls.length === 0) return ""; @@ -2017,6 +2032,23 @@ async function buildNonStreamingResponse( log, parentCandidateMessageId ); + // The image genuinely exists upstream but no pointer resolved to a URL + // (unknown asset scheme, download 403/expired, oversize). Flag it so the + // image-generation handler can report an accurate "generated but not + // retrievable" error instead of the misleading "no image markdown" 502. + const imageResolutionFailed = detectImageResolutionFailure( + imagePointers?.length ?? 0, + urls.length + ); + if (imageResolutionFailed && log?.warn) { + const schemes = (imagePointers ?? []) + .map((p) => p.pointer.split("://")[0] || p.pointer.slice(0, 24)) + .join(", "); + log.warn( + "CGPT-WEB", + `Image generated upstream but no asset pointer resolved (schemes: ${schemes}) — surfacing as unretrievable` + ); + } fullAnswer += imageMarkdown(urls); const promptTokens = Math.ceil(currentMsg.length / 4); const completionTokens = Math.ceil(fullAnswer.length / 4); @@ -2028,6 +2060,7 @@ async function buildNonStreamingResponse( created, model, system_fingerprint: null, + ...(imageResolutionFailed ? { x_image_resolution_failed: true } : {}), choices: [ { index: 0, diff --git a/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts b/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts index 7faa531f5c..c6b9688b7a 100644 --- a/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts +++ b/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts @@ -43,6 +43,9 @@ export async function handleChatGptWebImageGeneration({ log, signal, clientHeaders, + // Injectable so unit tests can drive the handler without a live ChatGPT + // session; production uses the real executor. + executorFactory = () => new ChatGptWebExecutor(), }) { const startTime = Date.now(); const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; @@ -98,7 +101,7 @@ export async function handleChatGptWebImageGeneration({ }; for (let i = 0; i < requestedCount; i++) { - const executor = new ChatGptWebExecutor(); + const executor = executorFactory(); const result = await executor.execute({ model, body: { @@ -124,21 +127,30 @@ export async function handleChatGptWebImageGeneration({ } let content = ""; + let imageResolutionFailed = false; try { const json = JSON.parse(responseText); content = String(json?.choices?.[0]?.message?.content || ""); + imageResolutionFailed = json?.x_image_resolution_failed === true; } catch { content = responseText; } const urls = extractMarkdownImageUrls(content); if (urls.length === 0) { + // Distinguish "image was generated upstream but OmniRoute could not + // retrieve it" (executor flagged the unresolved asset pointer) from + // "no image was produced at all" — the former is our bug/limitation, + // not a failed prompt, so the message must not read as "no image made". + const error = imageResolutionFailed + ? `ChatGPT Web generated an image but OmniRoute could not retrieve it (the image asset could not be downloaded — the URL may have expired or ChatGPT changed its image delivery format). Please retry; if it persists, report it. Assistant text: ${content.slice(0, 200)}` + : `ChatGPT Web completed without returning image markdown: ${content.slice(0, 300)}`; return saveImageErrorResult({ provider, model, status: 502, startTime, - error: `ChatGPT Web completed without returning image markdown: ${content.slice(0, 300)}`, + error, requestBody, }); } diff --git a/tests/unit/chatgpt-web-image-silentdrop.test.ts b/tests/unit/chatgpt-web-image-silentdrop.test.ts new file mode 100644 index 0000000000..1b3c3c7309 --- /dev/null +++ b/tests/unit/chatgpt-web-image-silentdrop.test.ts @@ -0,0 +1,96 @@ +// Regression guard for the escalated mesh-bot report: a user generated an +// image via the ChatGPT Web provider; the image WAS produced upstream but +// OmniRoute returned `502 "ChatGPT Web completed without returning image +// markdown"` — i.e. the silent-drop path where an image_asset_pointer existed +// but resolution failed, and the handler reported it as "no image made". +// +// The fix distinguishes "image generated but not retrievable" (executor sets +// x_image_resolution_failed) from "no image at all", so the 502 is accurate +// and actionable instead of misleading. +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-cgptweb-silentdrop-")); + +const { detectImageResolutionFailure } = await import("../../open-sse/executors/chatgpt-web.ts"); +const { handleChatGptWebImageGeneration } = await import( + "../../open-sse/handlers/imageGeneration/providers/chatgptWeb.ts" +); + +function fakeExecutor(jsonBody: object, status = 200) { + return { + execute: async () => ({ + response: new Response(JSON.stringify(jsonBody), { + status, + headers: { "Content-Type": "application/json" }, + }), + }), + }; +} + +const baseArgs = { + model: "gpt-4o", + provider: "chatgpt-web", + body: { prompt: "a kitten" }, + credentials: { apiKey: "sess-cookie" }, + log: null, + signal: null, + clientHeaders: {}, +}; + +test("detectImageResolutionFailure: true only when a pointer existed but none resolved", () => { + assert.equal(detectImageResolutionFailure(1, 0), true); + assert.equal(detectImageResolutionFailure(2, 0), true); + assert.equal(detectImageResolutionFailure(0, 0), false); // no image at all + assert.equal(detectImageResolutionFailure(1, 1), false); // resolved fine +}); + +test("handler surfaces a specific 502 when the image was generated but not retrievable", async () => { + const res = await handleChatGptWebImageGeneration({ + ...baseArgs, + executorFactory: () => + fakeExecutor({ + choices: [{ message: { role: "assistant", content: "Here's your image:" } }], + x_image_resolution_failed: true, + }), + }); + assert.equal(res.success, false); + assert.equal(res.status, 502); + // must NOT be the misleading "completed without returning image markdown" + assert.ok( + !/completed without returning image markdown/i.test(res.error), + `expected specific retrieval error, got: ${res.error}` + ); + // must clearly say the image was generated but could not be retrieved + assert.match(res.error, /could not (be )?retriev|generated an image but/i); +}); + +test("handler keeps the generic 502 when no image was generated at all", async () => { + const res = await handleChatGptWebImageGeneration({ + ...baseArgs, + executorFactory: () => + fakeExecutor({ + choices: [{ message: { role: "assistant", content: "I can't create that." } }], + }), + }); + assert.equal(res.success, false); + assert.equal(res.status, 502); + assert.match(res.error, /completed without returning image markdown/i); +}); + +test("handler returns success when the executor produced image markdown", async () => { + const url = "/v1/chatgpt-web/image/abcdef0123456789"; + const res = await handleChatGptWebImageGeneration({ + ...baseArgs, + executorFactory: () => + fakeExecutor({ + choices: [{ message: { role: "assistant", content: `Here you go:\n\n![image](${url})` } }], + }), + }); + assert.equal(res.success, true); + assert.equal(res.data.data.length, 1); + assert.equal(res.data.data[0].url, url); +}); From 6816bcdaf31b3f2d9718f509db00144124713b91 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:29:40 -0300 Subject: [PATCH 15/61] fix(dashboard): providers page data-timeout guard + live-ws standalone wiring (#6211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): providers page data-timeout guard + live-ws standalone wiring Captura de trabalho em progresso: timeout de dados na página de providers, ajuste em ProviderLimits e instrumentation-node, com testes novos (providers-page-data-timeout, live-ws-standalone-wiring). * chore(quality): rebaseline ProviderLimits/index.tsx file-size (+6, #6211 data-timeout guard) Cohesive fix growth from PR #6211's data-timeout guard on the quota page's two first-paint fetches (1121->1127). The fast-path PR->release skips check:file-size, so the bump lands with the PR. Justification recorded in file-size-baseline.json. --- config/quality/file-size-baseline.json | 5 +- .../(dashboard)/dashboard/providers/page.tsx | 31 +++---- .../dashboard/providers/providerPageUtils.ts | 65 ++++++++++++++ .../usage/components/ProviderLimits/index.tsx | 16 +++- src/instrumentation-node.ts | 18 ++++ tests/unit/live-ws-standalone-wiring.test.ts | 33 +++++++ .../unit/providers-page-data-timeout.test.ts | 86 +++++++++++++++++++ 7 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 tests/unit/live-ws-standalone-wiring.test.ts create mode 100644 tests/unit/providers-page-data-timeout.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 090083aa63..0b2beb0367 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -221,7 +221,7 @@ "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924, "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, - "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1121, + "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1127, "src/app/api/oauth/[provider]/[action]/route.ts": 960, "src/app/api/providers/[id]/models/route.ts": 2593, "src/app/api/providers/[id]/test/route.ts": 940, @@ -371,5 +371,6 @@ "_rebaseline_2026_06_22_phase4c_adaptive_context_budget": "Compression Phase 4 (C) adaptive context-budget wiring own growth: open-sse/services/compression/strategySelector.ts 818->848 (+30 at the existing selectCompressionPlan dispatch chokepoint). selectCompressionPlan gains an 8th optional `adaptiveOptions` param (modelContextLimit/requestMaxTokens/onAdaptive sink) and, after resolveBasePlan and before the caching-aware pass, runs the PURE resolveAdaptivePlan when config.contextBudget.mode is floor|replace-autotrigger; the new adaptiveEnabled(config) helper also gates the legacy shouldAutoTrigger branch inside resolveBasePlan off when adaptive owns automatic-by-size escalation (D-C4). The escalation ladder, target computation, and the resolver itself live in open-sse/services/compression/adaptiveCompression/{computeTarget,ladder,resolveAdaptivePlan,types}.ts (all 1122 (+19 = SanitizeOpenAIResponseOptions interface + stripReasoning option, #4678); tokenRefresh.ts 2070->2090 (+20 = codex 401 defense-in-depth unrecoverable-refresh guard, #4686); token-refresh-service.test.ts 1322->1353 (+31 = 401-unfamiliar-payload regression case, #4686); translator-openai-responses-req.test.ts 1047->1050 (+3 = reasoning_effort non-Copilot assertion update, #4688). All are the merged PRs own surgical additions at existing chokepoints.", "_rebaseline_2026_06_25_rc17b_leva2": "rc17 leva2 PR batch own growth (cohesive, not extractable): providerLimits.ts 950->955 (#4786 generalized accesstoken fallback); default.ts NEW frozen entry at 828 (#4729 anthropic-compatible Bearer + #4766 json_schema fallback + #4787 cline workos headers — three provider-specific header branches); openai-to-kiro.ts 807->814 (#4763 Claude-capability image gate); openai-responses.ts 923->937 (#4764 computeFinishReason guard); executor-default-base.test.ts 1339->1440 (#4766 json_schema fallback tests); translator-openai-to-kiro.test.ts 918->980 (#4763 non-Claude image gate tests).", - "_rebaseline_2026_06_27_v3838_filesize_drift": "Mid-cycle drift on release/v3.8.38 — feature/fix growth from already-merged PRs that the fast-path (PR->release skips check:file-size) let accumulate without a bump. src/shared/constants/sidebarVisibility.ts 1100->1198 (+98 = #3812 colored menu-icon support, per-item accent map across the sidebar entries; #5142 then dropped one orphan settings entry, net still above the frozen). src/sse/handlers/chat.ts 1560->1575 (+15 = #5064 self-inflicted-timeout cooldown skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS LIVE_WS_HOST honour / early empty-message reject). Localized feature/fix code next to existing branches, each covered by its own PR tests; not extractable without hiding the chokepoint. Structural shrink of chat.ts tracked in #3501." + "_rebaseline_2026_06_27_v3838_filesize_drift": "Mid-cycle drift on release/v3.8.38 — feature/fix growth from already-merged PRs that the fast-path (PR->release skips check:file-size) let accumulate without a bump. src/shared/constants/sidebarVisibility.ts 1100->1198 (+98 = #3812 colored menu-icon support, per-item accent map across the sidebar entries; #5142 then dropped one orphan settings entry, net still above the frozen). src/sse/handlers/chat.ts 1560->1575 (+15 = #5064 self-inflicted-timeout cooldown skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS LIVE_WS_HOST honour / early empty-message reject). Localized feature/fix code next to existing branches, each covered by its own PR tests; not extractable without hiding the chokepoint. Structural shrink of chat.ts tracked in #3501.", + "_rebaseline_2026_07_05_6211_providerlimits_fetch_timeout": "PR #6211 own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1121->1127 (+6 = data-timeout guard for the quota page's two first-paint fetches — a PROVIDER_LIMITS_FETCH_TIMEOUT_MS const + fetchWithTimeout at the /api/providers/client and /api/usage/provider-limits call sites — so a never-settling connection can no longer wedge initialLoading on the skeleton, same infinite-skeleton class the PR also fixes on the providers page). Cohesive fix code at the existing fetch chokepoints (the 5-line rationale comment explains why a timeout/abort is needed where a try/catch only rescues a rejection); not extractable. Covered by tests/unit/providers-page-data-timeout.test.ts. Fast-path PR->release skips check:file-size, so this bump lands with the PR. Structural shrink of this file tracked in #3501." } diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 112e46fa47..2393ac6046 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -25,6 +25,7 @@ import { shouldFilterProviderEntriesForDisplayMode, shouldShowFirstProviderHint, upsertProviderNodeById, + loadProviderPageData, } from "./providerPageUtils"; import type { ProviderEntry } from "./providerPageUtils"; import { @@ -235,26 +236,16 @@ export default function ProvidersPage() { useEffect(() => { const fetchData = async () => { try { - const [connectionsRes, nodesRes, expirationsRes, settingsRes] = await Promise.all([ - fetch("/api/providers"), - fetch("/api/provider-nodes"), - fetch("/api/providers/expiration"), - fetch("/api/settings", { cache: "no-store" }), - ]); - const connectionsData = await connectionsRes.json(); - const nodesData = await nodesRes.json(); - const expirationsData = await expirationsRes.json(); - const settingsData = settingsRes.ok ? await settingsRes.json() : null; - if (connectionsRes.ok) setConnections(connectionsData.connections || []); - if (nodesRes.ok) { - setProviderNodes(nodesData.nodes || []); - setCcCompatibleProviderEnabled(nodesData.ccCompatibleProviderEnabled === true); - } - if (expirationsRes.ok && expirationsData) setExpirations(expirationsData); - if (settingsData && Array.isArray(settingsData.blockedProviders)) { - setBlockedProviders(settingsData.blockedProviders); - } - setCodexGlobalServiceMode(getCodexGlobalServiceMode(settingsData)); + // Each request is time-bounded (see loadProviderPageData); a single + // stalled connection can no longer wedge `loading` on `true` and freeze + // the page on its skeleton forever. + const data = await loadProviderPageData(); + setConnections(data.connections); + setProviderNodes(data.providerNodes); + setCcCompatibleProviderEnabled(data.ccCompatibleProviderEnabled); + if (data.expirations) setExpirations(data.expirations); + if (data.blockedProviders) setBlockedProviders(data.blockedProviders); + setCodexGlobalServiceMode(getCodexGlobalServiceMode(data.settings)); } catch (error) { console.log("Error fetching data:", error); } finally { diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index f1ea2cc66f..b7b9f4d354 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -14,6 +14,7 @@ import { import { getModelsByProviderId } from "@/shared/constants/models"; import { providerHasServiceKind } from "@/lib/providers/serviceKindIndex"; import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; +import { fetchWithTimeout } from "@/shared/utils/fetchTimeout"; import type { ProviderDisplayMode } from "./providerPageStorage"; export interface ProviderStatsSnapshot { @@ -331,3 +332,67 @@ export function upsertProviderNodeById(prev: T next[idx] = node; return next; } + +/** Parsed payload the providers dashboard renders its first paint from. */ +export interface ProviderPageData { + connections: any[]; + providerNodes: any[]; + ccCompatibleProviderEnabled: boolean; + expirations: any | null; + blockedProviders: string[] | null; + settings: any | null; +} + +// Bound each first-paint request so a single stalled connection cannot freeze +// the page on its skeleton. 20s is generous for a loopback dashboard API while +// still guaranteeing the skeleton clears in bounded time. +const PROVIDER_PAGE_FETCH_TIMEOUT_MS = 20_000; + +/** + * Load the four data sources the providers dashboard renders from, each bounded + * by an AbortSignal timeout and independently degrading to a default. + * + * Why this exists (infinite-skeleton bug): the page used to gate its `loading` + * flag on `await Promise.all([fetch(...) x4])` with **no** timeout. A bare + * `fetch()` that never *settles* — e.g. the browser's 6-connection HTTP/1.1 pool + * starved by the dashboard's RSC `` prefetch storm, or any stalled + * connection — leaves `Promise.all` pending forever, so `setLoading(false)` + * (which lives in the effect's `finally`) never runs and the Suspense skeleton + * shows indefinitely. A `try/catch` cannot rescue a promise that never settles; + * only a timeout/abort can. Here every request is time-bounded and failures + * degrade to a default, so the loader always resolves within the timeout and the + * page paints from whatever data arrived (matching the fast `/api/providers`). + */ +export async function loadProviderPageData( + fetchImpl: typeof fetch = (globalThis.fetch as typeof fetch), + timeoutMs: number = PROVIDER_PAGE_FETCH_TIMEOUT_MS +): Promise { + const safeJson = async (url: string, init?: RequestInit): Promise => { + try { + const res = await fetchWithTimeout(url, { ...init, timeoutMs, fetchFn: fetchImpl }); + if (!res.ok) return null; + return await res.json(); + } catch { + // Timeout/abort/network error → degrade to the default; never hang. + return null; + } + }; + + const [connectionsData, nodesData, expirationsData, settingsData] = await Promise.all([ + safeJson("/api/providers"), + safeJson("/api/provider-nodes"), + safeJson("/api/providers/expiration"), + safeJson("/api/settings", { cache: "no-store" }), + ]); + + return { + connections: Array.isArray(connectionsData?.connections) ? connectionsData.connections : [], + providerNodes: Array.isArray(nodesData?.nodes) ? nodesData.nodes : [], + ccCompatibleProviderEnabled: nodesData?.ccCompatibleProviderEnabled === true, + expirations: expirationsData ?? null, + blockedProviders: Array.isArray(settingsData?.blockedProviders) + ? settingsData.blockedProviders + : null, + settings: settingsData ?? null, + }; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index f977b2fedc..4acdb798bd 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -25,6 +25,14 @@ import { useVisibleQuotaData } from "./useVisibleQuotaData"; import { formatAutoRefreshCountdown } from "./formatters"; import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; import { compareTr } from "@/shared/utils/turkishText"; +import { fetchWithTimeout } from "@/shared/utils/fetchTimeout"; + +// Bound the two first-paint requests so a stalled connection cannot wedge +// `initialLoading` on `true` and freeze the quota page on its skeleton forever +// (same infinite-skeleton class as the providers page). A `try/catch` degrades a +// *rejection* to a default, but only a timeout/abort can rescue a `fetch()` that +// never settles (browser connection-pool starvation under the RSC prefetch storm). +const PROVIDER_LIMITS_FETCH_TIMEOUT_MS = 20_000; const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter"; const LS_STATUS_FILTER = "omniroute:limits:statusFilter"; @@ -311,7 +319,9 @@ export default function ProviderLimits({ const fetchConnections = useCallback(async () => { try { - const response = await fetch("/api/providers/client"); + const response = await fetchWithTimeout("/api/providers/client", { + timeoutMs: PROVIDER_LIMITS_FETCH_TIMEOUT_MS, + }); if (!response.ok) throw new Error("Failed"); const data = await response.json(); const list = data.connections || []; @@ -382,7 +392,9 @@ export default function ProviderLimits({ const fetchCachedProviderLimits = useCallback(async () => { try { - const response = await fetch("/api/usage/provider-limits"); + const response = await fetchWithTimeout("/api/usage/provider-limits", { + timeoutMs: PROVIDER_LIMITS_FETCH_TIMEOUT_MS, + }); if (!response.ok) throw new Error("Failed"); const data = await response.json(); return data.caches || {}; diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 6c005b1c51..ad132b51f8 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -379,5 +379,23 @@ export async function registerNodejs(): Promise { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] memory decay sweep failed to start (non-fatal):", msg); } + + // Real-time dashboard WebSocket daemon (port 20129): powers Combo Studio Live, + // the Home live-pulse, and Live Compression. liveServer.ts auto-starts the + // daemon on import (gated by OMNIROUTE_ENABLE_LIVE_WS, default ON) — but NOTHING + // imported it in the packaged standalone/PM2 runtime. Only the unused + // `server-init.ts` and a dev-only helper script (`scripts/start-ws-server.mjs`) + // ever pulled it into a module graph, so in the published `omniroute` bin the + // daemon never bound its port and every live dashboard reported "Live disabled — + // WebSocket disconnected". Importing it here (the instrumentation hook that DOES + // run in standalone) fires that flag-gated auto-start. Side-effect import + the + // module's own `.catch` keep it non-fatal. + try { + await import("@/server/ws/liveServer"); + console.log("[STARTUP] Live dashboard WebSocket daemon bootstrap invoked"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Live dashboard WebSocket daemon failed to start (non-fatal):", msg); + } } } diff --git a/tests/unit/live-ws-standalone-wiring.test.ts b/tests/unit/live-ws-standalone-wiring.test.ts new file mode 100644 index 0000000000..6c628d44eb --- /dev/null +++ b/tests/unit/live-ws-standalone-wiring.test.ts @@ -0,0 +1,33 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +// Regression guard for: "live-dashboard WebSocket daemon (port 20129) never +// starts in the packaged standalone bin." liveServer.ts auto-starts the daemon +// on import, but nothing imported it in the standalone/PM2 runtime — so the port +// never bound and every live dashboard showed "Live disabled — WebSocket +// disconnected." The fix wires the import into the Next instrumentation hook +// (instrumentation-node.ts), which is the module that actually runs in the +// packaged bin. This test asserts that wiring stays in place. +const __dirname = dirname(fileURLToPath(import.meta.url)); +const instrumentationPath = resolve(__dirname, "../../src/instrumentation-node.ts"); +const source = readFileSync(instrumentationPath, "utf8"); + +describe("standalone runtime wires the live-dashboard WebSocket daemon", () => { + test("instrumentation-node.ts imports the live-WS server so its auto-start fires", () => { + assert.match( + source, + /import\(\s*["']@\/server\/ws\/liveServer["']\s*\)/, + "instrumentation-node.ts must import @/server/ws/liveServer so the port-20129 daemon starts in the standalone bin" + ); + }); + + test("the live-WS bootstrap is gated with the background-services block and is non-fatal", () => { + // It must live inside the isBackgroundServicesDisabled() gate and be wrapped + // so a bind failure never crashes startup. + assert.match(source, /liveServer/); + assert.match(source, /Live dashboard WebSocket daemon failed to start \(non-fatal\)/); + }); +}); diff --git a/tests/unit/providers-page-data-timeout.test.ts b/tests/unit/providers-page-data-timeout.test.ts new file mode 100644 index 0000000000..200fd95f75 --- /dev/null +++ b/tests/unit/providers-page-data-timeout.test.ts @@ -0,0 +1,86 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +// Regression guard for the "providers/quota dashboard stuck on its skeleton +// forever" bug. The page gates `loading` on awaiting every first-paint request; +// a bare `fetch()` that never *settles* (browser connection-pool starvation +// under the RSC prefetch storm, or a stalled connection) left `loading` true +// forever. loadProviderPageData bounds each request with an AbortSignal timeout +// so the loader ALWAYS resolves (degrading to defaults) and the page paints. +const { loadProviderPageData } = await import( + "@/app/(dashboard)/dashboard/providers/providerPageUtils" +); + +// A fetch mock that honors AbortSignal the way the real fetch does: it never +// resolves on its own, but rejects with an AbortError once the signal fires. +function hangingFetch(): typeof fetch { + return ((_url: string | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal) { + if (signal.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + signal.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true } + ); + } + })) as unknown as typeof fetch; +} + +function jsonFetch(map: Record): typeof fetch { + return ((url: string | URL) => { + const body = map[String(url)] ?? {}; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + }) as unknown as typeof fetch; +} + +describe("loadProviderPageData — never freezes the dashboard skeleton", () => { + test("a never-settling fetch is aborted by the timeout and the loader still resolves", async () => { + const start = Date.now(); + const data = await loadProviderPageData(hangingFetch(), 50); + const elapsed = Date.now() - start; + + // The core guarantee: bounded, not infinite. + assert.ok(elapsed < 3000, `loader should resolve promptly, took ${elapsed}ms`); + assert.deepEqual(data.connections, []); + assert.deepEqual(data.providerNodes, []); + assert.equal(data.ccCompatibleProviderEnabled, false); + assert.equal(data.expirations, null); + assert.equal(data.blockedProviders, null); + assert.equal(data.settings, null); + }); + + test("returns parsed data when every endpoint resolves", async () => { + const data = await loadProviderPageData( + jsonFetch({ + "/api/providers": { connections: [{ id: "c1" }] }, + "/api/provider-nodes": { nodes: [{ id: "n1" }], ccCompatibleProviderEnabled: true }, + "/api/providers/expiration": { openai: "2030-01-01" }, + "/api/settings": { blockedProviders: ["openai"] }, + }), + 1000 + ); + + assert.deepEqual(data.connections, [{ id: "c1" }]); + assert.deepEqual(data.providerNodes, [{ id: "n1" }]); + assert.equal(data.ccCompatibleProviderEnabled, true); + assert.deepEqual(data.expirations, { openai: "2030-01-01" }); + assert.deepEqual(data.blockedProviders, ["openai"]); + }); + + test("a rejecting fetch degrades to defaults instead of throwing", async () => { + const rejectFetch = (() => Promise.reject(new Error("network down"))) as unknown as typeof fetch; + const data = await loadProviderPageData(rejectFetch, 1000); + assert.deepEqual(data.connections, []); + assert.equal(data.settings, null); + }); +}); From 6a12ba07b16ab683ac83a186aa3eed82c8e54a5f Mon Sep 17 00:00:00 2001 From: Danny S <36470572+kanztu@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:29:59 +0800 Subject: [PATCH 16/61] fix(translator): strip reasoning param for nvidia z-ai/glm-5.2 (#6181) * fix(translator): strip reasoning param for nvidia z-ai/glm-5.2 NVIDIA NIM OpenAI-compatible wrapper rejects the reasoning body field and returns HTTP 400 "Unsupported parameter(s): `reasoning`". Add a StripRule scoped to provider=nvidia + model /z-ai\/glm-5\.2/i. Mirrors PR #6102 drop pattern (minimax-m2.7 thinking). * docs(translator): tighten nvidia glm-5.2 strip-rule comment * fix(translator): anchor glm-5.2 strip rule with word boundary --- open-sse/translator/paramSupport.ts | 3 ++ ...executors-strip-unsupported-params.test.ts | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/open-sse/translator/paramSupport.ts b/open-sse/translator/paramSupport.ts index 08e0e807a4..a0a3a70bab 100644 --- a/open-sse/translator/paramSupport.ts +++ b/open-sse/translator/paramSupport.ts @@ -29,6 +29,9 @@ const STRIP_RULES: StripRule[] = [ /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"], }, + // NVIDIA NIM z-ai/glm-5.2: OpenAI-compatible wrapper rejects the `reasoning` + // body field → HTTP 400 "Unsupported parameter(s): `reasoning`". #6102 drop pattern. + { provider: "nvidia", match: /z-ai\/glm-5\.2\b/i, drop: ["reasoning"] }, // NVIDIA NIM minimaxai/minimax-m2.7: NVIDIA's OpenAI-compatible wrapper // (format:"openai") does not accept the Claude-style `thinking` body field // and returns 400 "Unsupported parameter(s): thinking". Upstream #2268. diff --git a/tests/unit/executors-strip-unsupported-params.test.ts b/tests/unit/executors-strip-unsupported-params.test.ts index d1896408c4..e9169d731f 100644 --- a/tests/unit/executors-strip-unsupported-params.test.ts +++ b/tests/unit/executors-strip-unsupported-params.test.ts @@ -5,6 +5,7 @@ // 1. claude-opus-4 series: temperature deprecated → Anthropic 400. // 2. github + gpt-5.4: temperature unsupported. // 3. github + Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. +// 4. nvidia + z-ai/glm-5.2: reasoning rejected → NVIDIA 400. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -112,6 +113,47 @@ test("stripUnsupportedParams: missing model is a no-op", () => { assert.equal(body.temperature, 0.7); }); +test("stripUnsupportedParams: drops reasoning for nvidia z-ai/glm-5.2", () => { + const body: Record = { + model: "z-ai/glm-5.2", + reasoning: { effort: "high" }, + temperature: 0.7, + }; + stripUnsupportedParams("nvidia", "z-ai/glm-5.2", body); + assert.equal(body.reasoning, undefined, "reasoning must be stripped"); + assert.equal(body.temperature, 0.7, "other params must survive"); + assert.equal(body.model, "z-ai/glm-5.2", "model must not be touched"); +}); + +test("stripUnsupportedParams: nvidia z-ai/glm-5.1 keeps reasoning (rule is 5.2-only)", () => { + const body: Record = { + model: "z-ai/glm-5.1", + reasoning: { effort: "medium" }, + max_tokens: 100, + }; + stripUnsupportedParams("nvidia", "z-ai/glm-5.1", body); + assert.ok(body.reasoning !== undefined, "reasoning must survive for glm-5.1"); + assert.equal(body.max_tokens, 100, "other params must survive"); +}); + +test("stripUnsupportedParams: nvidia glm-5 rule is provider-scoped (no-op for other providers)", () => { + const body: Record = { + model: "z-ai/glm-5.2", + reasoning: { effort: "high" }, + }; + stripUnsupportedParams("openai", "z-ai/glm-5.2", body); + assert.ok(body.reasoning !== undefined, "reasoning must survive for non-nvidia provider"); +}); + +test("stripUnsupportedParams: nvidia non-glm-5 model keeps reasoning", () => { + const body: Record = { + model: "deepseek-ai/deepseek-v4-pro", + reasoning: { effort: "high" }, + }; + stripUnsupportedParams("nvidia", "deepseek-ai/deepseek-v4-pro", body); + assert.ok(body.reasoning !== undefined, "reasoning must survive for non-glm-5 nvidia model"); +}); + test("STRIP_RULES is non-empty and every rule has a drop list", () => { assert.ok(__STRIP_RULES_FOR_TEST.length > 0); for (const rule of __STRIP_RULES_FOR_TEST) { From 0ed6780798d85980187c9423f4285a7f75dec739 Mon Sep 17 00:00:00 2001 From: Luis Alejandro Vega Date: Sun, 5 Jul 2026 02:30:04 -0300 Subject: [PATCH 17/61] fix: add nvidia to PROVIDER_TOOL_LIMITS (1536) to prevent tool truncation (#6177) NVIDIA NIM API (nvidia/* models) silently truncates the tool list to 128 (the default MAX_TOOLS_LIMIT) because nvidia is not in PROVIDER_TOOL_LIMITS. Tools beyond index 127 are dropped, causing agents to lose access to critical tools like task, read, or high-index MCP tools. Verified that NVIDIA NIM API supports up to 1536 tools by direct testing. End-to-end confirmed: 198 tools sent, model successfully called tools at indices 193, 195, and 197 (previously dropped by truncation to 128). Follows the same pattern as #5563 (grok-cli: 200), integrated in v3.8.43. --- open-sse/services/toolLimitDetector.ts | 1 + tests/unit/tool-limit-detector.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/open-sse/services/toolLimitDetector.ts b/open-sse/services/toolLimitDetector.ts index 48969fcb12..5685f26e98 100644 --- a/open-sse/services/toolLimitDetector.ts +++ b/open-sse/services/toolLimitDetector.ts @@ -6,6 +6,7 @@ const DEFAULT_LIMIT = MAX_TOOLS_LIMIT; const PROVIDER_TOOL_LIMITS: Record = { "grok-cli": 200, + "nvidia": 1536, }; const _detectedLimitsSweep = setInterval(() => { diff --git a/tests/unit/tool-limit-detector.test.ts b/tests/unit/tool-limit-detector.test.ts index d1ce4a88fd..82e89323bd 100644 --- a/tests/unit/tool-limit-detector.test.ts +++ b/tests/unit/tool-limit-detector.test.ts @@ -69,6 +69,15 @@ describe("toolLimitDetector", () => { assert.strictEqual(getEffectiveToolLimit("grok-cli"), 200); }); + it("should return proactive limit for nvidia (1536) without any detection", () => { + assert.strictEqual(getEffectiveToolLimit("nvidia"), 1536); + }); + + it("should not override nvidia proactive limit with reactive detection", () => { + setDetectedToolLimit("nvidia", 100); + assert.strictEqual(getEffectiveToolLimit("nvidia"), 1536); + }); + it("should still return default (128) for unknown providers", () => { assert.strictEqual(getEffectiveToolLimit("some-new-provider"), 128); }); From 5e3a95be4f99d7659af6f2c88224e270e52dc1f4 Mon Sep 17 00:00:00 2001 From: Milan Soni <123074437+Iammilansoni@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:00:10 +0530 Subject: [PATCH 18/61] feat(provider): add Claude 5 Sonnet to Claude Web provider (#6200) (#6209) * feat(provider): add Claude 5 Sonnet to Claude Web provider (#6200) * test(providers): guard claude-web claude-sonnet-5 registry entry (#6209) Adds the missing regression test the PR-test-policy gate requires: asserts the claude-web registry exposes claude-sonnet-5 (Claude 5 Sonnet web) alongside the existing 4.6 Sonnet / 4.5 Haiku entries. Fails on the release base (no entry). Co-authored-by: diegosouzapw --------- Co-authored-by: Diego Rodrigues de Sa e Souza --- .../config/providers/registry/claude/web/index.ts | 1 + .../unit/claude-web-sonnet5-registry-6209.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 tests/unit/claude-web-sonnet5-registry-6209.test.ts diff --git a/open-sse/config/providers/registry/claude/web/index.ts b/open-sse/config/providers/registry/claude/web/index.ts index ae5612822e..952b4f39fe 100644 --- a/open-sse/config/providers/registry/claude/web/index.ts +++ b/open-sse/config/providers/registry/claude/web/index.ts @@ -9,6 +9,7 @@ export const claude_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", models: [ + { id: "claude-sonnet-5", name: "Claude 5 Sonnet (web)" }, { id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet (web)" }, { id: "claude-haiku-4-5", name: "Claude 4.5 Haiku (web)" }, ], diff --git a/tests/unit/claude-web-sonnet5-registry-6209.test.ts b/tests/unit/claude-web-sonnet5-registry-6209.test.ts new file mode 100644 index 0000000000..d3d3ba0058 --- /dev/null +++ b/tests/unit/claude-web-sonnet5-registry-6209.test.ts @@ -0,0 +1,15 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts"; + +// #6209 (#6200): the Claude Web provider (claude.ai scrape) now offers Claude 5 Sonnet. +// Regression guard: the claude-web registry must expose `claude-sonnet-5` alongside the +// pre-existing 4.6 Sonnet / 4.5 Haiku web entries. Fails without the registry line. +test("claude-web registry exposes claude-sonnet-5 (Claude 5 Sonnet web)", () => { + const models = getModelsByProviderId("claude-web"); + const ids = new Set(models.map((m) => m.id)); + assert.ok(ids.has("claude-sonnet-5"), "claude-web must expose claude-sonnet-5"); + // the prior web lineup must survive + assert.ok(ids.has("claude-sonnet-4-6"), "claude-web must keep claude-sonnet-4-6"); + assert.ok(ids.has("claude-haiku-4-5"), "claude-web must keep claude-haiku-4-5"); +}); From b074c6d75ebb79e6030ae54a42d016bc6c47fdf9 Mon Sep 17 00:00:00 2001 From: "R. Beltran" Date: Sun, 5 Jul 2026 07:30:16 +0200 Subject: [PATCH 19/61] fix(cli): detect POSIX auto-set HOSTNAME via os.hostname() to fix bind address (#6194) (#6195) POSIX shells (bash/zsh) always set HOSTNAME to the machine name. The .env loader uses first-wins semantics, so HOSTNAME=0.0.0.0 in .env is silently ignored. This causes the server to bind to the LAN hostname instead of 0.0.0.0, breaking localhost access and all internal self-requests (ModelSync, HealthCheck, cloud sync). The fix compares process.env.HOSTNAME against os.hostname(): when they match, it's the POSIX auto-set signature and HOSTNAME is ignored. OMNIROUTE_SERVER_HOST takes precedence as the dedicated escape hatch. Backward compatibility is preserved: users who set HOSTNAME to a value that doesn't match the machine name (e.g. Windows CMD/PowerShell users with HOSTNAME in .env) will still have their value honoured. Closes #6194 --- .env.example | 5 ++ bin/cli/commands/serve.mjs | 13 ++++- docs/reference/ENVIRONMENT.md | 3 +- tests/unit/cli-serve-hostname.test.ts | 71 +++++++++++++++++++++------ 4 files changed, 75 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index fdfe5d0b0f..73b87e9d93 100644 --- a/.env.example +++ b/.env.example @@ -187,8 +187,13 @@ OMNIROUTE_USE_TURBOPACK=1 # Hostname/bind address for the Next.js server. # Used by: scripts/dev/run-next.mjs (HOST), Playwright runner (HOSTNAME). # Default: 0.0.0.0 (HOST) / 127.0.0.1 (HOSTNAME inside tests). +# NOTE: Do NOT use `HOSTNAME` — it is a POSIX shell variable automatically set to +# the machine name by bash/zsh. The .env loader cannot override it (first-wins +# semantics). Use OMNIROUTE_SERVER_HOST instead for `omniroute serve`. +# See: https://github.com/diegosouzapw/OmniRoute/issues/6194 #HOST=0.0.0.0 #HOSTNAME=127.0.0.1 +#OMNIROUTE_SERVER_HOST=0.0.0.0 # Environment mode — affects Next.js behavior, logging verbosity, and caching. # Values: production | development | Default: production diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 9ba6579c3f..ab9d793ca3 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { platform, totalmem } from "node:os"; +import { platform, totalmem, hostname as osHostname } from "node:os"; import { t } from "../i18n.mjs"; import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs"; import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs"; @@ -170,7 +170,16 @@ export async function runServe(opts = {}) { PORT: String(dashboardPort), DASHBOARD_PORT: String(dashboardPort), API_PORT: String(apiPort), - HOSTNAME: process.env.HOSTNAME || "0.0.0.0", + // #6194: POSIX shells (bash/zsh) auto-set HOSTNAME to the machine name — the + // .env loader (first-wins) can never override it. Ignore HOSTNAME when it + // matches the OS-reported hostname (the auto-set signature). OMNIROUTE_SERVER_HOST + // takes precedence; legacy HOSTNAME values that don't match os.hostname() are + // still honoured for backward compatibility (e.g. Windows CMD/PowerShell users + // who set HOSTNAME in .env where it is NOT auto-set). + HOSTNAME: + process.env.OMNIROUTE_SERVER_HOST || + (process.env.HOSTNAME !== osHostname() ? process.env.HOSTNAME : undefined) || + "0.0.0.0", NODE_ENV: "production", // #5238: preserve a user-set NODE_OPTIONS (incl. their own // `--max-old-space-size=…`) instead of clobbering it with the calibrated diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index efe32cff43..e288136d00 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -139,7 +139,8 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | | `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. | | `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | -| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. | +| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. **Do not use for `omniroute serve`** — use `OMNIROUTE_SERVER_HOST` instead (POSIX shells auto-set `HOSTNAME` to the machine name; `.env` cannot override it). | +| `OMNIROUTE_SERVER_HOST` | `0.0.0.0` | `bin/cli/commands/serve.mjs` | Bind address for `omniroute serve`. Avoids collision with the POSIX shell `HOSTNAME` variable (always set to the machine name by bash/zsh). Falls back to `0.0.0.0` when unset. (#6194) | ### Port Modes diff --git a/tests/unit/cli-serve-hostname.test.ts b/tests/unit/cli-serve-hostname.test.ts index 64573aa2c1..80eeb6ff9c 100644 --- a/tests/unit/cli-serve-hostname.test.ts +++ b/tests/unit/cli-serve-hostname.test.ts @@ -3,27 +3,70 @@ import assert from "node:assert/strict"; /** * Replicate the HOSTNAME resolution from bin/cli/commands/serve.mjs to verify - * that the spawned server honours a HOSTNAME provided via env/.env instead of - * always hardcoding "0.0.0.0" (#5134). Mirrors the in-file replication pattern - * used by cli-serve-port.test.ts (serve.mjs spawns processes, so the logic is - * tested in isolation rather than imported). + * the #6194 fix: POSIX shells auto-set HOSTNAME to the machine name, which + * collides with the bind address. The fix uses os.hostname() to detect the + * auto-set signature and ignores it, while preserving backward compatibility + * for explicit HOSTNAME values (e.g. Windows CMD/PowerShell users). + * + * Resolution order: + * 1. OMNIROUTE_SERVER_HOST (new dedicated var — always wins) + * 2. HOSTNAME if it does NOT match os.hostname() (legacy backward compat) + * 3. "0.0.0.0" (default) */ -function resolveHostname(envHostname: string | undefined): string { - return envHostname || "0.0.0.0"; +function resolveHostname( + envServerHost: string | undefined, + envHostname: string | undefined, + machineHostname: string +): string { + return envServerHost || (envHostname !== machineHostname ? envHostname : undefined) || "0.0.0.0"; } -test("serve hostname: honours HOSTNAME env var when set", () => { - assert.equal(resolveHostname("127.0.0.1"), "127.0.0.1"); +// --- OMNIROUTE_SERVER_HOST takes precedence --- + +test("serve hostname: OMNIROUTE_SERVER_HOST takes precedence over everything", () => { + assert.equal(resolveHostname("127.0.0.1", "myhostname", "myhostname"), "127.0.0.1"); }); -test("serve hostname: honours a specific bind interface", () => { - assert.equal(resolveHostname("192.168.0.15"), "192.168.0.15"); +test("serve hostname: OMNIROUTE_SERVER_HOST overrides an explicit HOSTNAME", () => { + assert.equal(resolveHostname("192.168.1.100", "10.0.0.1", "myhostname"), "192.168.1.100"); }); -test("serve hostname: falls back to 0.0.0.0 when HOSTNAME is unset", () => { - assert.equal(resolveHostname(undefined), "0.0.0.0"); +// --- POSIX shell auto-set detection (the #6194 bug) --- + +test("serve hostname: POSIX auto-set HOSTNAME (matches os.hostname()) is ignored (#6194)", () => { + // bash/zsh sets HOSTNAME=. When it matches os.hostname(), + // it's the auto-set signature — must be ignored. + assert.equal(resolveHostname(undefined, "myhostname", "myhostname"), "0.0.0.0"); }); -test("serve hostname: falls back to 0.0.0.0 when HOSTNAME is an empty string", () => { - assert.equal(resolveHostname(""), "0.0.0.0"); +// --- Backward compatibility for explicit HOSTNAME values --- + +test("serve hostname: explicit HOSTNAME (not matching os.hostname()) is preserved", () => { + // Windows CMD/PowerShell user who set HOSTNAME=192.168.1.50 in .env + // HOSTNAME != os.hostname() → treat as intentional user config + assert.equal(resolveHostname(undefined, "192.168.1.50", "myhostname"), "192.168.1.50"); +}); + +test("serve hostname: localhost as explicit HOSTNAME is preserved", () => { + assert.equal(resolveHostname(undefined, "localhost", "myhostname"), "localhost"); +}); + +// --- Default fallback --- + +test("serve hostname: falls back to 0.0.0.0 when both are unset", () => { + assert.equal(resolveHostname(undefined, undefined, "myhostname"), "0.0.0.0"); +}); + +test("serve hostname: falls back to 0.0.0.0 when both are empty strings", () => { + assert.equal(resolveHostname("", "", "myhostname"), "0.0.0.0"); +}); + +test("serve hostname: OMNIROUTE_SERVER_HOST empty string falls through to HOSTNAME check", () => { + // Empty string is falsy → falls through; HOSTNAME is auto-set → ignored → 0.0.0.0 + assert.equal(resolveHostname("", "myhostname", "myhostname"), "0.0.0.0"); +}); + +test("serve hostname: OMNIROUTE_SERVER_HOST empty string with explicit HOSTNAME", () => { + // Empty string is falsy → falls through; HOSTNAME != os.hostname() → used + assert.equal(resolveHostname("", "10.0.0.5", "myhostname"), "10.0.0.5"); }); From dc7eeba717b812c9e209e76fb3ee35dbb11676e5 Mon Sep 17 00:00:00 2001 From: VXNCXNX <93332837+VXNCXNX@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:30:21 +0200 Subject: [PATCH 20/61] feat(sse): surface Kiro adaptive-thinking reasoning as reasoning_content (#6213) Kiro/CodeWhisperer streams Claude's reasoning as native `reasoningContentEvent` frames when adaptive thinking is enabled, but the Kiro executor had no handler for them, so `reasoning_effort` requests returned no reasoning. Wire it end to end: - translator (openai-to-kiro): enable Kiro thinking when the request carries `reasoning_effort`, Anthropic `output_config.effort`, or a `thinking` block (`{type:"enabled",budget_tokens}` mapped to a level; `{type:"adaptive"}` defaults to `high`, matching Anthropic's documented default). Prepends the Kiro ``/`` prompt directive and sets top-level `additionalModelRequestFields` ({output_config.effort, thinking:{type:"adaptive"}, max_tokens}). Gated on `supportsReasoning`; drops non-default temperature/top_p (rejected by adaptive-only Claude models). - executor transformRequest: forward `additionalModelRequestFields` to AWS (previously dropped by the strict top-level allowlist). - executor stream loop: parse `reasoningContentEvent` (and reasoningText variants) into the OpenAI reasoning_content channel. Verified against the live CodeWhisperer stream: reasoningContentEvent frames are returned, and larger effort/budget measurably deepens reasoning up to the model cap. Unit tests cover the effort sources, forwarding, temp/top_p stripping, and native reasoning-frame parsing. --- open-sse/executors/kiro.ts | 56 +++++++ open-sse/translator/request/openai-to-kiro.ts | 129 ++++++++++++++++ tests/unit/executor-kiro.test.ts | 58 +++++++ tests/unit/translator-openai-to-kiro.test.ts | 141 ++++++++++++++++++ 4 files changed, 384 insertions(+) diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 26c3135257..7def296e39 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -214,6 +214,12 @@ export class KiroExecutor extends BaseExecutor { if (b.conversationState !== undefined) kiroPayload.conversationState = b.conversationState; if (b.profileArn !== undefined) kiroPayload.profileArn = b.profileArn; if (b.inferenceConfig !== undefined) kiroPayload.inferenceConfig = b.inferenceConfig; + // Thinking control: `additionalModelRequestFields` ({output_config.effort, + // thinking:{type:"adaptive"}, max_tokens}) is a recognized top-level field on + // GenerateAssistantResponse — it steers adaptive reasoning. Built by the + // openai-to-kiro translator only when the request asked for thinking. + if (b.additionalModelRequestFields !== undefined) + kiroPayload.additionalModelRequestFields = b.additionalModelRequestFields; // Fallback: if somehow conversationState isn't there, return the rest without model // (for backward compatibility if something else bypasses the translator) @@ -382,6 +388,56 @@ export class KiroExecutor extends BaseExecutor { if (!state.totalContentLength) state.totalContentLength = 0; if (!state.contextUsagePercentage) state.contextUsagePercentage = 0; + // Native reasoning frames. Verified against the live CodeWhisperer + // stream (2026-07): with adaptive thinking enabled (via + // additionalModelRequestFields), Kiro streams reasoning as a dedicated + // `reasoningContentEvent` frame carrying `{ text, signature }` — NOT + // inline `` tags and NOT `assistantResponseEvent`. Some + // models/variants instead use a `reasoningText` object or a flat + // `{ text }` (cf. javargasm/pi-kiro `src/event-parser.ts`). OmniRoute + // had no handler for this event, so the reasoning was silently dropped; + // route it to the OpenAI `reasoning_content` channel. + { + const rp = event.payload as Record | undefined; + const rt = rp?.reasoningText; + if (eventType === "reasoningContentEvent" || rt !== undefined) { + let nativeReasoning = ""; + if (rt && typeof rt === "object") { + const rto = rt as { text?: unknown; Text?: unknown }; + nativeReasoning = + typeof rto.text === "string" + ? rto.text + : typeof rto.Text === "string" + ? rto.Text + : ""; + } else if (typeof rt === "string") { + nativeReasoning = rt; + } else if (typeof rp?.text === "string") { + nativeReasoning = rp.text as string; + } + if (nativeReasoning) { + state.hasReasoningContent = true; + const reasoningDelta: JsonRecord = + (state.reasoningChunkCount ?? 0) === 0 && chunkIndex === 0 + ? { role: "assistant", reasoning_content: nativeReasoning } + : { reasoning_content: nativeReasoning }; + const chunk: JsonRecord = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: reasoningDelta, finish_reason: null }], + }; + chunkIndex++; + state.reasoningChunkCount = (state.reasoningChunkCount ?? 0) + 1; + controller.enqueue(TEXT_ENCODER.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + // Consume the reasoning frame (incl. signature-only) so it never + // falls through to the content handlers below. + continue; + } + } + // Handle assistantResponseEvent if (eventType === "assistantResponseEvent") { const content = diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 02cbd4aa1e..8508fe54d0 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -5,6 +5,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { v4 as uuidv4, v5 as uuidv5 } from "uuid"; +import { capMaxOutputTokens, capThinkingBudget, supportsReasoning } from "@/lib/modelCapabilities"; import { parseToolInput, normalizeKiroToolSchema, @@ -575,6 +576,80 @@ function convertMessages(messages, tools, model) { return { history: alternatingHistory, currentMessage, toolsAttached }; } +/** Kiro's accepted reasoning-effort levels (`output_config.effort`). */ +const KIRO_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; + +/** + * Resolve the Kiro effort level for a request, or "" when no reasoning was asked + * for. Effort sources, in priority order: + * 1. OpenAI-style `reasoning_effort` + * 2. Anthropic adaptive-thinking `output_config.effort` (the canonical field) + * 3. Anthropic `thinking` block — `{type:"enabled", budget_tokens}` mapped to a + * level via {@link effortFromBudget}; `{type:"adaptive"}` (no explicit + * effort) defaults to `high`, matching Anthropic's documented default + * (omitting `effort` ≡ `high`). + * OpenAI's `minimal` collapses to `low` (Kiro has no `minimal`). + */ +function resolveKiroEffort(body: Record): string { + let effort = typeof body.reasoning_effort === "string" ? body.reasoning_effort.toLowerCase() : ""; + + if (!effort) { + const outputConfig = body.output_config as Record | undefined; + if ( + outputConfig && + typeof outputConfig === "object" && + typeof outputConfig.effort === "string" + ) { + effort = outputConfig.effort.toLowerCase(); + } + } + + if (!effort) { + const thinking = body.thinking as Record | undefined; + if (thinking && typeof thinking === "object") { + if (thinking.type === "enabled") { + effort = effortFromBudget(Number(thinking.budget_tokens) || 0); + } else if (thinking.type === "adaptive") { + effort = "high"; + } + } + } + + if (effort === "minimal") effort = "low"; + return KIRO_EFFORT_LEVELS.includes(effort) ? effort : ""; +} + +/** Map an Anthropic `thinking.budget_tokens` to a coarse Kiro effort level. */ +function effortFromBudget(budget: number): string { + if (budget >= 32000) return "high"; + if (budget >= 16000) return "medium"; + if (budget > 0) return "low"; + return ""; +} + +/** + * Soft `` budget for the Kiro prompt directive, per effort + * level. Anthropic publishes no effort→token mapping (effort is "a behavioral + * signal, not a strict token budget"), so this is a heuristic tuned against the + * live CodeWhisperer stream, where a larger budget measurably deepens reasoning + * up to the model cap. It is a hint the model may honor, not a hard cap (the hard + * enable signal is ``); the caller clamps it to the model's cap. + */ +function thinkingLengthForEffort(effort: string): number { + switch (effort) { + case "max": + return 120000; + case "xhigh": + return 64000; + case "high": + return 32000; + case "medium": + return 16000; + default: + return 8000; // low + } +} + /** * Build Kiro payload from OpenAI format */ @@ -683,6 +758,11 @@ export function buildKiroPayload(model, body, stream, credentials) { temperature?: number; topP?: number; }; + additionalModelRequestFields?: { + thinking?: { type: string; display?: string }; + output_config?: { effort: string }; + max_tokens?: number; + }; } = { conversationState: { chatTriggerType: "MANUAL", @@ -754,6 +834,55 @@ export function buildKiroPayload(model, body, stream, credentials) { if (topP !== undefined) payload.inferenceConfig.topP = topP; } + // Thinking mode for Claude models on Kiro (ported from javargasm/pi-kiro). + // Two coordinated signals steer reasoning on the CodeWhisperer surface: + // 1. a `enabledN` + // directive prepended to the current user message — makes Claude emit its + // reasoning INLINE as ``, which the Kiro executor + // splits back into the OpenAI `reasoning_content` channel (kiroThinking.ts); + // 2. top-level `additionalModelRequestFields` (output_config.effort + + // thinking:{type:"adaptive"} + a clamped max_tokens), forwarded to AWS by + // the Kiro executor's transformRequest allowlist — this is the graded + // effort lever. Gated on models that advertise thinking support. + const kiroEffort = supportsReasoning(normalizedModel) ? resolveKiroEffort(body) : ""; + if (kiroEffort) { + // `` / `` are Kiro/CodeWhisperer prompt + // conventions (NOT Anthropic API params); the length is a soft hint (the hard + // enable signal is ``), clamped to the model's thinking cap. + const thinkingLength = capThinkingBudget(normalizedModel, thinkingLengthForEffort(kiroEffort)); + const directive = + `enabled` + + `${thinkingLength}`; + payload.conversationState.currentMessage.userInputMessage.content = `${directive}\n\n${payload.conversationState.currentMessage.userInputMessage.content}`; + + const fields: { + output_config: { effort: string }; + thinking: { type: string; display: string }; + max_tokens?: number; + } = { + output_config: { effort: kiroEffort }, + thinking: { type: "adaptive", display: "summarized" }, + }; + // Forward max_tokens only when the client set one, clamped to the model's + // output window (floor 1024) — matches pi-kiro and avoids an over-budget reject. + if (maxTokens > 0) { + const capped = capMaxOutputTokens(normalizedModel, maxTokens) ?? maxTokens; + fields.max_tokens = Math.max(Math.floor(capped), 1024); + } + payload.additionalModelRequestFields = fields; + + // Adaptive-only Claude models (Opus 4.7/4.8, Sonnet 5, Fable 5) reject a + // non-default temperature / top_p with a 400 while thinking is active, so + // strip both. Drop inferenceConfig entirely if nothing else remains. + if (payload.inferenceConfig) { + delete payload.inferenceConfig.temperature; + delete payload.inferenceConfig.topP; + if (Object.keys(payload.inferenceConfig).length === 0) { + delete payload.inferenceConfig; + } + } + } + return payload; } diff --git a/tests/unit/executor-kiro.test.ts b/tests/unit/executor-kiro.test.ts index c746c6a5f3..ff14a94580 100644 --- a/tests/unit/executor-kiro.test.ts +++ b/tests/unit/executor-kiro.test.ts @@ -168,6 +168,31 @@ test("KiroExecutor.transformRequest removes the top-level model field", () => { ); }); +test("KiroExecutor.transformRequest forwards additionalModelRequestFields (thinking) to AWS", () => { + const executor = new KiroExecutor(); + const body = { + model: "kiro-model", + conversationState: { + currentMessage: { userInputMessage: { modelId: "kiro-model" } }, + }, + additionalModelRequestFields: { + output_config: { effort: "high" }, + thinking: { type: "adaptive", display: "summarized" }, + max_tokens: 32000, + }, + }; + + const result = executor.transformRequest("kiro-model", body, true, {}) as any; + // The thinking control must survive the strict allowlist — otherwise graded + // reasoning never reaches CodeWhisperer (the field the openai-to-kiro + // translator builds would be silently dropped). + assert.deepEqual(result.additionalModelRequestFields, { + output_config: { effort: "high" }, + thinking: { type: "adaptive", display: "summarized" }, + max_tokens: 32000, + }); +}); + test("KiroExecutor.transformEventStreamToSSE converts text, tool calls, usage and DONE", async () => { const executor = new KiroExecutor(); const invalidPreludeFrame = buildEventFrame("assistantResponseEvent", { content: "skip me" }); @@ -202,6 +227,39 @@ test("KiroExecutor.transformEventStreamToSSE converts text, tool calls, usage an assert.match(text, /\[DONE\]/); }); +test("KiroExecutor.transformEventStreamToSSE surfaces native reasoning frames as reasoning_content", async () => { + const executor = new KiroExecutor(); + // Verified live wire format: Kiro streams adaptive-thinking reasoning as a + // dedicated `reasoningContentEvent` frame carrying `{ text, signature }`. Also + // cover the `reasoningText` object variant and a signature-only frame. + const response = buildEventStreamResponse([ + buildEventFrame("reasoningContentEvent", { text: "Let me think... " }), + buildEventFrame("reasoningContentEvent", { text: "step two. " }), + buildEventFrame("reasoningContentEvent", { signature: "sig-only-frame" }), + buildEventFrame("assistantResponseEvent", { reasoningText: { text: "variant." } }), + buildEventFrame("assistantResponseEvent", { content: "The answer is 42." }), + buildEventFrame("metricsEvent", { inputTokens: 3, outputTokens: 5 }), + ]); + + const transformed = executor.transformEventStreamToSSE(response, "kiro-model"); + const chunks = parseSSEJsonChunks(await transformed.text()); + const reasoning = chunks + .map((c) => c.choices?.[0]?.delta?.reasoning_content) + .filter(Boolean) + .join(""); + const content = chunks + .map((c) => c.choices?.[0]?.delta?.content) + .filter(Boolean) + .join(""); + + assert.equal( + reasoning, + "Let me think... step two. variant.", + "reasoningContentEvent frames + reasoningText variant must all surface" + ); + assert.match(content, /The answer is 42\./, "normal content must still flow"); +}); + test("KiroExecutor.transformEventStreamToSSE parses fragmented frames and waits for post-stop usage", async () => { const executor = new KiroExecutor(); const bytes = concatArrays( diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 9d74a3e20e..3421b2563b 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -1090,3 +1090,144 @@ test("buildKiroPayload leaves already-two-dash Claude ids unchanged (#2270)", () "two-dash form (patch + date) must remain unchanged" ); }); + +test("buildKiroPayload enables thinking mode for Claude models via reasoning_effort", () => { + const body = { + messages: [{ role: "user", content: "Solve a hard problem" }], + reasoning_effort: "high", + max_tokens: 64000, + }; + + const result = buildKiroPayload("claude-opus-4.8", body, false, null); + + assert.ok(result.additionalModelRequestFields, "additionalModelRequestFields must be set"); + assert.deepEqual(result.additionalModelRequestFields.thinking, { + type: "adaptive", + display: "summarized", + }); + assert.equal(result.additionalModelRequestFields.output_config.effort, "high"); + assert.equal(result.additionalModelRequestFields.max_tokens, 64000); + assert.match( + result.conversationState.currentMessage.userInputMessage.content, + /enabled<\/thinking_mode>/, + "thinking_mode directive must be injected into user content" + ); + assert.match( + result.conversationState.currentMessage.userInputMessage.content, + /\d+<\/max_thinking_length>/, + "max_thinking_length directive must be injected into user content" + ); +}); + +test("buildKiroPayload drops temperature when thinking is enabled", () => { + const body = { + messages: [{ role: "user", content: "Solve a hard problem" }], + reasoning_effort: "high", + temperature: 0.5, + }; + + const result = buildKiroPayload("claude-opus-4.8", body, false, null); + + assert.ok(result.additionalModelRequestFields, "thinking must be enabled"); + assert.equal( + result.inferenceConfig?.temperature, + undefined, + "temperature must be dropped when adaptive thinking is active" + ); +}); + +test("buildKiroPayload ignores thinking request for unsupported effort levels", () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + reasoning_effort: "invalid", + }; + + const result = buildKiroPayload("claude-opus-4.8", body, false, null); + + assert.equal( + result.additionalModelRequestFields, + undefined, + "invalid effort must not enable thinking" + ); +}); + +test("buildKiroPayload maps body.thinking budget_tokens to effort level", () => { + const body = { + messages: [{ role: "user", content: "Deep reasoning" }], + thinking: { type: "enabled", budget_tokens: 50000 }, + }; + + const result = buildKiroPayload("claude-opus-4.7", body, false, null); + + assert.ok(result.additionalModelRequestFields, "thinking must be enabled from budget_tokens"); + assert.equal(result.additionalModelRequestFields.output_config.effort, "high"); +}); + +test("buildKiroPayload leaves thinking off when no reasoning is requested", () => { + const body = { messages: [{ role: "user", content: "Hi" }] }; + + const result = buildKiroPayload("claude-opus-4.8", body, false, null); + + assert.equal(result.additionalModelRequestFields, undefined, "no thinking fields by default"); + assert.doesNotMatch( + result.conversationState.currentMessage.userInputMessage.content, + //, + "no directive injected by default" + ); +}); + +test("buildKiroPayload maps reasoning_effort to the same Kiro effort level (no +1 shift)", () => { + const result = buildKiroPayload( + "claude-sonnet-5", + { messages: [{ role: "user", content: "hard" }], reasoning_effort: "medium" }, + false, + null + ); + + assert.equal(result.additionalModelRequestFields.output_config.effort, "medium"); +}); + +test("buildKiroPayload reads effort from Anthropic output_config.effort", () => { + const result = buildKiroPayload( + "claude-opus-4.8", + { messages: [{ role: "user", content: "hard" }], output_config: { effort: "xhigh" } }, + false, + null + ); + + assert.ok(result.additionalModelRequestFields, "output_config.effort must enable thinking"); + assert.equal(result.additionalModelRequestFields.output_config.effort, "xhigh"); +}); + +test("buildKiroPayload defaults adaptive thinking (no effort) to high", () => { + const result = buildKiroPayload( + "claude-opus-4.8", + { messages: [{ role: "user", content: "hard" }], thinking: { type: "adaptive" } }, + false, + null + ); + + assert.equal( + result.additionalModelRequestFields.output_config.effort, + "high", + "adaptive with no explicit effort defaults to Anthropic's documented default (high)" + ); +}); + +test("buildKiroPayload drops both temperature and top_p when thinking is enabled", () => { + const result = buildKiroPayload( + "claude-opus-4.8", + { + messages: [{ role: "user", content: "hard" }], + reasoning_effort: "high", + temperature: 0.5, + top_p: 0.9, + }, + false, + null + ); + + assert.ok(result.additionalModelRequestFields, "thinking must be enabled"); + assert.equal(result.inferenceConfig?.temperature, undefined, "temperature must be dropped"); + assert.equal(result.inferenceConfig?.topP, undefined, "top_p must be dropped"); +}); From b1e27258c0e9d1bb1b5fa2ef609a0a9753b77af4 Mon Sep 17 00:00:00 2001 From: Denis Kotsyuba Date: Sun, 5 Jul 2026 07:30:30 +0200 Subject: [PATCH 21/61] fix(chatcore): exempt opencode client from the default 128-tool truncation (#6193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chatcore): exempt opencode client from the default 128-tool truncation The default MAX_TOOLS_LIMIT (128) cap made truncateToolList blind-slice tools.slice(0, 128), dropping opencode's built-in task tool and part of its MCP tools when the inbound list exceeded 128 — so models routed through OmniRoute could not launch subagents or reach all their tools. Detect the opencode client (any x-opencode-* header, or 'opencode' in the user-agent) and bypass ONLY the speculative 128 default. A known provider ceiling (proactive PROVIDER_TOOL_LIMITS or a detected limit) always wins and still truncates, even for opencode, so upstreams with real hard limits (e.g. grok-cli 200) keep their 400-avoidance guard. Non-opencode clients are unchanged. - requestFormat.ts: add isOpencodeClient(headers, userAgent) + expose it on resolveChatCoreRequestFormat. - toolLimitDetector.ts: add getKnownToolLimit(); getEffectiveToolLimit becomes getKnownToolLimit(provider) ?? DEFAULT_LIMIT (byte-identical for existing callers). - upstreamBody.ts: truncateToolList takes bypassDefaultToolLimit and encodes the precedence; fix cosmetic debug-log count. - chatCore.ts: thread the flag into prepareUpstreamBody. - tests: extend tool-limit-detector unit tests. * refactor(tools): accept nullable provider in tool-limit resolvers Address PR review: widen getKnownToolLimit / getEffectiveToolLimit to (provider: string | null | undefined) to match the call sites in truncateToolList, and add unit assertions covering null/undefined providers (getKnownToolLimit -> null, getEffectiveToolLimit -> 128). --------- Co-authored-by: DKotsyuba <16292493+DKotsyuba@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 218 +------------------- open-sse/handlers/chatCore.ts | 2 + open-sse/handlers/chatCore/requestFormat.ts | 29 +++ open-sse/handlers/chatCore/upstreamBody.ts | 39 +++- open-sse/services/toolLimitDetector.ts | 8 +- tests/unit/tool-limit-detector.test.ts | 35 ++++ 6 files changed, 108 insertions(+), 223 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a814d2629..e7e6201aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,221 +2,9 @@ ## [Unreleased] ---- +### 🐛 Bug Fixes -## [3.8.45] — TBD - -### 🔧 Bug Fixes - -- **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) -- **fix(providers):** refresh the stale NVIDIA NIM model registry — drop EOL `z-ai/glm-5.1`, add `z-ai/glm-5.2` and `nvidia/nemotron-3-ultra-550b-a55b` ([#6108](https://github.com/diegosouzapw/OmniRoute/issues/6108)). Regression guard: `tests/unit/nvidia-nim-registry-6108.test.ts`. (thanks @andrea-kingautomation) -- **fix(backend):** GPT-family (codex) models now report a distinct `max_input_tokens` (272000) below their 400K `context_length` via an optional `maxInputTokens` on `RegistryModel`, so coding agents auto-compact correctly instead of overflowing the real input cap ([#6191](https://github.com/diegosouzapw/OmniRoute/issues/6191)). Regression guard: `tests/unit/gpt-max-input-tokens-6191.test.ts`. (thanks @luweiCN) -- **fix(backend):** call logs now record a **reasoning source/char-count** (migration 116, `reasoning_source`/`reasoning_chars`) for models that emit `reasoning_content`/`` but report zero reasoning tokens in usage, so `tokens_reasoning` no longer silently under-represents reasoning — cost math is unchanged (the priced `tokens_reasoning` stays usage-derived) ([#6187](https://github.com/diegosouzapw/OmniRoute/issues/6187)). Regression guard: `tests/unit/reasoning-token-source-6187.test.ts`. (thanks @andrea-kingautomation) -- **fix(auth):** a stale/changed `STORAGE_ENCRYPTION_KEY` now surfaces as a clear **424 `storage_encryption_stale`** ("re-enter the API key") instead of a misleading "Auth failed: 401" — the connection's ciphertext failed to decrypt and was coerced to an empty Bearer, hiding the real cause ([#6148](https://github.com/diegosouzapw/OmniRoute/issues/6148)). Regression guard: `tests/unit/decrypt-stale-key-hint-6148.test.ts`. (thanks @chirag127) -- **fix(backend):** memory injection now keeps the injected system message **first** for providers that require it (via a `PROVIDERS_SYSTEM_MUST_BE_FIRST` capability), instead of the cache-safe mid-array splice that made strict providers reject the request with a 400 ([#6135](https://github.com/diegosouzapw/OmniRoute/issues/6135)). Regression guard: `tests/unit/memory-system-first-6135.test.ts`. -- **fix(services):** 9Router embed panel no longer 404s (optional catch-all route) and the supervisor probes the port before spawning to avoid raw EADDRINUSE ([#6205](https://github.com/diegosouzapw/OmniRoute/issues/6205)). Regression guards: `tests/unit/ninerouter-embed-port-6205.test.ts`, `tests/unit/services/ServiceSupervisor.test.ts`. (thanks @jonlwheat2-gif) - -### ⚡ Performance & Infrastructure - -- **perf(test):** test-suite loader quick wins ([#6214](https://github.com/diegosouzapw/OmniRoute/pull/6214)) — the 19 test scripts switch `--import tsx` → `--import tsx/esm` (the repo is pure ESM; the unused CJS hook cost ~1.3s per test process × 2,462 processes — CI fast-path unit shards dropped 14.8→7.5 min, −49%), tsx bumped to ^4.23.0 (tsx#809 startup-regression fix), **37 orphan `.test.mjs` files (224 cases) recovered** into the canonical glob (they matched no runner and never ran in any CI job; `check:test-discovery` now scans `.mjs` too), and ci.yml/quality.yml unit jobs now call the canonical npm script `test:unit:ci:shard` (single source of truth — closes two silent drifts: missing `setupPolyfill` import in CI and `memory/`+`usage/` dirs absent from the fast-path glob). `tests/unit/dashboard/**` keeps the full tsx hook in its own invocation (`@lobehub/icons` es/ build internally `require()`s ESM-syntax files). -- **ci:** heavy-pipeline dedup ([#6215](https://github.com/diegosouzapw/OmniRoute/pull/6215)) — the release-PR pipeline ran the unit suite 4× per sync (95 jobs, 208 machine-min; the v3.8.44 cycle fired 123 such runs, 88 cancelled). Now: Node 24/26 compat matrices move to a daily `nightly-compat.yml` (−28%/run; resolves the active release branch, opens a tracking issue on failure), coverage is collected inside the unit shards themselves via c8/`NODE_V8_COVERAGE` (−18%/run; the Coverage Shard ×8 matrix is gone — nodejs/node's own CI pattern), the ~40-job per-language i18n matrix becomes 1 job (the account has 20 concurrent-job slots total), and heavy jobs skip **draft** PRs — paired with `/generate-release` now opening the living release PR as draft (flipped ready at the new Phase 0a.0a), killing the per-merge churn for the whole cycle. Validated by a full `workflow_dispatch` of the new pipeline: 35 jobs, 0 failures, 23 min, merged coverage 80.16% (> ratchet baseline). -- **feat(quality):** no-new-warnings per PR ([#6218](https://github.com/diegosouzapw/OmniRoute/pull/6218)) — native ESLint bulk suppressions (≥9.24) freeze the pre-existing debt (476 files / 4,273 violations in `config/quality/eslint-suppressions.json`); `npm run lint`, lint-staged (pre-commit) and a new fork-aware `lint-guard` job in quality.yml all run suppressions-aware, so a NEW warning goes red in the PR that introduces it instead of accruing invisibly (+41/+88 per cycle) and being blind-rebaselined at release. 3 warn rules promoted to error in `src/**` (`react-hooks/exhaustive-deps`, `@next/next/no-img-element`, `import/no-anonymous-default-export`); `collect-metrics` measures under the frozen baseline (ratchet metric = net-NEW debt; baseline tightened 4,279→0 in-PR per require-tighten); fork PRs run report-only (contributors are never blocked — the maintainer campaigns fix via co-authorship). Baseline stock shrinks via `--prune-suppressions` at release reconciliation. - -### 🔧 Bug Fixes - -- fix(mcp): forward the MCP request `extra` context through static tool loops so stdio callers keep their scope/identity ([#6178](https://github.com/diegosouzapw/OmniRoute/issues/6178)) - ---- - -## [3.8.44] — TBD - -### ✨ New Features - -- **feat(resilience):** throttle upstream quota fetches on the per-request preflight path ([#6009](https://github.com/diegosouzapw/OmniRoute/issues/6009)) — a new global min-interval gate (`open-sse/services/quotaFetchThrottle.ts`) spaces the actual network calls made by the Codex quota fetcher so that many accounts on one IP no longer fetch quota in the same second (which, per `router-for-me/CLIProxyAPI#2385`, can get a Codex OAuth token revoked). Complements the existing bulk-sync spacing (`PROVIDER_LIMITS_SYNC_SPACING_MS`) which already serialized the periodic provider-limits sync — this covers the concurrent combo/preflight path it didn't. Cache hits are never delayed; fail-open (only ever awaits a timer). Configurable via `OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS` (default 250ms, clamped 0..5000; `0` disables). Regression guard: `tests/unit/quota-fetch-throttle-6009.test.ts` (5). (thanks @powellnorma) -- **feat(autoCombo):** add **per-request Auto-Combo controls** via two headers ([#6024](https://github.com/diegosouzapw/OmniRoute/issues/6024) / [#6025](https://github.com/diegosouzapw/OmniRoute/issues/6025) / [#6023](https://github.com/diegosouzapw/OmniRoute/issues/6023)) — `X-OmniRoute-Mode` steers an `auto` combo's scoring for a single request (friendly presets `fast`/`balanced`/`quality`/`cheap`/`reliable`/`offline` **or** a raw mode-pack name; `balanced` forces the default weights), and `X-OmniRoute-Budget` sets a hard per-request USD cost ceiling. Both override the combo's stored config only for the request that carries them; unknown/garbage values are ignored so the saved config is preserved. The resolvers are pure (`open-sse/services/autoCombo/requestControls.ts`) and feed the engine's existing `config.modePack` / `config.budgetCap` inputs — no engine changes. Regression guard: `tests/unit/auto-combo-request-controls-6024.test.ts` (5). (thanks @chirag127) -- **feat(providers):** add the **Kenari** OpenAI-compatible gateway (BYOK). Regression guard: `tests/unit/kenari.test.ts`. (thanks @doedja) -- **feat(models):** add `claude-sonnet-5` to the Antigravity model catalog (alias mapping in `antigravityModelAliases.ts`) ([#6103](https://github.com/diegosouzapw/OmniRoute/pull/6103)). Regression guard: `tests/unit/antigravity-model-aliases.test.ts`. (thanks @anki1kr) -- **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. ([#5950](https://github.com/diegosouzapw/OmniRoute/pull/5950)) (thanks @waguriagentic) -- **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface — `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` — under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools → Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**. ([#5939](https://github.com/diegosouzapw/OmniRoute/pull/5939)) -- **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) -- **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) -- **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) -- **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) -- **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) -- **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) -- **feat(claude-code):** add an opt-in auto-permission classifier compat mode (off/auto/always) for Claude Code, toggleable from the CLI Code settings. ([#5810](https://github.com/diegosouzapw/OmniRoute/pull/5810)) -- **feat(providers):** add optional client-identity header profiles for compatible nodes — preset User-Agent/fingerprint headers (e.g. matching a known CLI) merged into the existing customHeaders field. ([#5812](https://github.com/diegosouzapw/OmniRoute/pull/5812)) -- **feat(build):** add a backend-only fast build mode (`scripts/build/build-next-isolated.mjs` + `backendOnlyPages.mjs`) that skips compiling the dashboard frontend pages, cutting local/CI build time for backend-only changes. ([#6119](https://github.com/diegosouzapw/OmniRoute/pull/6119) — thanks @artickc) -- **feat(minimax):** extract MiniMax M3's raw `...` leakage into `reasoning_content` on the 8 OpenAI-format provider tiers, leaving the Claude-format `minimax`/`minimax-cn` tiers untouched (they already report reasoning correctly). ([#6073](https://github.com/diegosouzapw/OmniRoute/pull/6073) — thanks @KooshaPari) -- **feat(services):** promote **Bifrost** (`@maximhq/bifrost` — Go AI-gateway) from an env-only relay sidecar to a first-class embedded/supervised service, matching the existing cliproxy/9router model — installer, bootstrap `SERVICES[]` entry, migration 113 DB seed, 7 lifecycle API routes under `/api/services/bifrost/` (loopback-only), a dashboard tab, and relay auto-wiring that defaults `BIFROST_BASE_URL` to the supervised port when running. Implements item #2 of #5670; the broader RouterBackend contract (items #1, #3-#5) stays out of scope. ([#5817](https://github.com/diegosouzapw/OmniRoute/pull/5817), part of [#5670](https://github.com/diegosouzapw/OmniRoute/issues/5670)) -- **feat(services):** add **Mux** (`coder/mux` — local agent-orchestration daemon) as a fourth-tier embedded service on the existing `ServiceSupervisor` framework — npm-based installer, `bootstrap.ts` registration, migration 113 DB seed, 7 lifecycle API routes under `/api/services/mux/` (loopback-only, defense-in-depth bind to 127.0.0.1), and a dashboard tab reusing the shared service-management components. ([#6034](https://github.com/diegosouzapw/OmniRoute/pull/6034)) -- **feat(xai):** surface Grok/xAI usage on the quota dashboard via local `usageHistory` aggregation (`getXaiUsage`) — since xAI exposes no per-account quota API, this sums tokens routed to the connection from `usage_history` and reports them as a cumulative, uncapped quota, mirroring the existing Xiaomi MiMo self-track pattern. ([#5806](https://github.com/diegosouzapw/OmniRoute/pull/5806)) -- **feat(minimax):** extract MiniMax M3's raw `...` tags into a separate `reasoning_content` field on the 8 provider tiers that register M3 with `format:"openai"` (trae, huggingchat, bazaarlink, ollama-cloud, opencode, cline, opencode-zen, codebuddy-cn) — previously the thinking text leaked directly into `content`. Reuses the existing `extractThinkingFromContent` primitive, extending its allowlist with a minimax-m3-only pattern; the two direct minimax/minimax-cn tiers are untouched since they already surface reasoning natively over Anthropic's Messages format. (Inspired by 9router#2231.) ([#6050](https://github.com/diegosouzapw/OmniRoute/pull/6050) — thanks @KooshaPari) -- **feat(i18n):** auto-detect the browser language on first visit — a pure `detectBrowserLocale()` matcher (exact match, `zh-HK`/`zh-MO` folded to `zh-TW`, language-prefix match, else `null`) plus a client-only `LocaleAutoDetect` component mounted once in the root layout. When no locale cookie is set yet, it reads `navigator.languages`, computes a match against the supported locales, and persists it via the same cookie/localStorage writer `LanguageSelector` already used (extracted to `shared/lib/persistLocale.ts`). (Inspired by 9router#1324.) ([#5979](https://github.com/diegosouzapw/OmniRoute/pull/5979)) -- **feat(cli-tools):** add **CodeWhale** — the actively-maintained successor to DeepSeek TUI (same author, renamed project) — as a dual dashboard entry alongside the existing "deepseek-tui" catalog entry, so existing DeepSeek TUI users keep a working card while new users are steered to CodeWhale. New `/api/cli-tools/codewhale-settings` route writes `~/.codewhale/config.toml` and keeps the legacy `~/.deepseek/config.toml` in sync. (Inspired by 9router#1761.) ([#5996](https://github.com/diegosouzapw/OmniRoute/pull/5996)) -- **feat(server):** support reverse-proxy `basePath` deployment via a new opt-in `OMNIROUTE_BASE_PATH` env var (empty by default), using Next.js's native `basePath` support so a deployment behind a reverse-proxy subpath (e.g. `https://host/omniroute/`) works without manual header stripping; the two hardcoded auth-redirect targets in `src/server/authz/pipeline.ts` now prefix with `request.nextUrl.basePath`. Default empty basePath is a no-op for existing root-path deployments. (Inspired by 9router#1810.) ([#5992](https://github.com/diegosouzapw/OmniRoute/pull/5992)) -- **feat(providers):** add **SumoPod** (`ai.sumopod.com`) and **X5Lab** (`api.x5lab.dev`) OpenAI-compatible BYOK aggregator gateways, wired via the default executor with bearer API-key auth; both use `passthroughModels` with a live `/v1/models` fetcher instead of a hardcoded catalog. Regression guard: `tests/unit/sumopod-x5lab-provider.test.ts`. (Inspired by 9router#1288.) ([#5963](https://github.com/diegosouzapw/OmniRoute/pull/5963)) -- **feat(providers):** add **Charm Hyper** (`hyper.charm.land`) as a new OpenAI-compatible, bearer-auth API-key gateway provider with a free tier (100 monthly Hypercredits); models resolve via passthrough (`modelsUrl` + live `/v1/models`) since the catalog isn't publicly documented. (Inspired by 9router#2006.) ([#5961](https://github.com/diegosouzapw/OmniRoute/pull/5961)) -- **feat(providers):** add **Nube.sh** (`ai.nube.sh`) as a new BYOK OpenAI-compatible gateway (LiteLLM proxy), Bearer/API-key auth. Its live model catalog is only reachable with a valid key, so no model IDs are hardcoded — it uses `passthroughModels` + `modelsUrl` for live enumeration. (Inspired by 9router#2294.) ([#5936](https://github.com/diegosouzapw/OmniRoute/pull/5936) — thanks @whale9820) -- **feat(providers):** add **b.ai** (`api.b.ai`) as a new OpenAI-compatible BYOK provider, distinct from the existing thebai/theb.ai provider, using passthrough model discovery with no hardcoded model list. (Inspired by 9router#963.) ([#5969](https://github.com/diegosouzapw/OmniRoute/pull/5969)) -- **feat(providers):** add **Qiniu** (七牛云) AI inference gateway as a BYOK API-key provider — proxies many upstream models (DeepSeek V3/V4, Claude, Kimi, and more) behind a single key, shipping with an empty static seed and relying on `passthroughModels` + the live `/v1/models` catalog instead of a stale hardcoded model id. Regression guard: `tests/unit/qiniu-provider.test.ts`. (Inspired by 9router#911.) ([#5966](https://github.com/diegosouzapw/OmniRoute/pull/5966)) -- **feat(providers):** port **ModelScope** (Alibaba 魔搭) as a new API-key, OpenAI-compatible provider — verified against ModelScope's own docs that the real production domain is `api-inference.modelscope.cn` (`.cn`, not the upstream PR's `.ai`) and shipped `passthroughModels: true` with an empty seed + `modelsUrl` instead of the upstream PR's static 5-model snapshot, since the open-model catalog moves fast. (Ported from 9router#1764.) ([#5965](https://github.com/diegosouzapw/OmniRoute/pull/5965) — thanks @tn5052) -- **feat(providers):** add **Augment (Auggie CLI)** as a new local, no-auth provider that spawns the user's local `auggie` CLI and pipes a flattened prompt via stdin, wrapping stdout as an OpenAI-compatible SSE stream or single JSON body. Auth is delegated to `auggie login` outside OmniRoute (synthetic `noAuth: true` connection, no DB row required); "Test Connection" spawns `auggie --version`. Hardened against the untrusted-input spawn sink: no `shell: true` on Windows (argv passed straight to the OS loader, no metacharacter interpretation), and `model` is validated against the registry allowlist before spawn (rejecting unknown or `-`-prefixed values) with a trailing `--` end-of-options marker. (Inspired by 9router#1200.) ([#5972](https://github.com/diegosouzapw/OmniRoute/pull/5972) — thanks @chamdanilukman) -- **feat(providers):** add **NVIDIA NIM image generation** — a dedicated `nvidia-nim` image format/handler (separate host, `ai.api.nvidia.com/v1/genai/`, native NIM body shape) for the 4 FLUX models (flux.1-dev, flux.1-schnell, flux.1-kontext-dev, flux.2-klein-4b), shaping each model's per-model request body (dimension/mode validation, required input image + aspect ratio, optional edit image) and normalizing the NIM response's varying shapes into the OpenAI `{created, data}` shape. (Inspired by 9router#1195.) ([#5971](https://github.com/diegosouzapw/OmniRoute/pull/5971)) -- **feat(oauth):** import a Codex connection from a raw ChatGPT access token — OmniRoute's only Codex import path previously required both `access_token` and `refresh_token`, leaving no path for a user with only a bare ChatGPT website access token. `createProviderConnection` gains an explicit `access_token` auth-type branch (intentionally never deduped), a new `POST /api/oauth/codex/import-token` route (Zod-validated), and `OAuthModal`'s manual-paste path now detects an `eyJ`-prefixed pasted token and posts it to the new endpoint, mirroring the existing grok-cli raw-token flow. The executor's `refreshCredentials()` already degrades safely to `null` without a refresh token, forcing re-auth on expiry. (Inspired by 9router#1290.) ([#5995](https://github.com/diegosouzapw/OmniRoute/pull/5995) — thanks @ryanngit) -- **feat(dashboard):** add a tool-source diagnostics settings toggle — a new Settings → Advanced card lets operators flip the existing `logToolSources` flag from the UI instead of editing the DB row directly; `logToolSources` is added to the `.strict()` `/api/settings` Zod PATCH schema (previously rejected). (Inspired by 9router#1825.) ([#5978](https://github.com/diegosouzapw/OmniRoute/pull/5978) — thanks @DuyPrX) -- **feat(dashboard):** collapse and sort provider quota rows by remaining percentage — the expanded quota list is sorted highest-remaining-first and collapsed to the first 3 rows by default, with a "Show N more"/"Show less" toggle when a connection reports more than 3 quotas, keeping at-risk quotas visible above a long list of healthy ones. Sort/slice logic extracted into pure, directly-unit-tested helpers (`sortQuotasByRemaining`, `getVisibleQuotas`). (Inspired by 9router#1919.) ([#5977](https://github.com/diegosouzapw/OmniRoute/pull/5977)) -- **feat(dashboard):** suggest HuggingFace Hub media models — a new `GET /api/v1/providers/suggested-models` route proxies the public HF Hub models search API (Zod-validated, no token exposed client-side) and `ImageExampleCard` merges the results into the model picker as a selectable chip row for the huggingface provider; also adds a dedicated `huggingface-image` format/handler for HF's raw-image-bytes response. (Inspired by 9router#1633.) ([#5990](https://github.com/diegosouzapw/OmniRoute/pull/5990)) -- **feat(cli-tools):** add a **Crush** entry to the dashboard CLI-Tools catalog plus a new `/api/cli-tools/crush-settings` route (GET/POST/DELETE) — OmniRoute already shipped a `crush` CLI setup command (`bin/cli/commands/setup-crush.mjs`) but the dashboard catalog had no matching entry; the new route writes to the same canonical `~/.config/crush/crush.json` path so the dashboard and CLI command agree. (Inspired by 9router#1233.) ([#5970](https://github.com/diegosouzapw/OmniRoute/pull/5970)) -- **feat(providers):** extend Vercel AI Gateway (`vercel-ai-gateway`/`vag`) beyond chat-only to support **embeddings and image generation** — the gateway's OpenAI-compatible `/v1` API also exposes `/embeddings` and `/images/generations`, so entries were added to `EMBEDDING_PROVIDERS` (`embeddingRegistry.ts`) and `IMAGE_PROVIDERS` (`imageRegistry.ts`) modeled on the existing `openai` entries. ([#5968](https://github.com/diegosouzapw/OmniRoute/pull/5968) — thanks @tantai-newnol) -- **feat(api-keys):** add per-key **device/connection tracking** — a SHA-256 fingerprint of IP + User-Agent, with a 30-minute TTL and per-key/global caps, tracks distinct client devices seen with each API key (in-memory only, raw IP never stored). A new `GET /api/keys/[id]/devices` route exposes masked device details, and the API Keys dashboard tab gets a "Devices" count badge alongside the existing Sessions badge. This is a new granularity distinct from the existing `maxSessions` cap, which limits concurrent sticky-routing sessions rather than tracking device identity. ([#5998](https://github.com/diegosouzapw/OmniRoute/pull/5998) — thanks @mugni-rukita) -- **feat(proxy):** add **Webshare** (`proxy.webshare.io`) as a fourth source in the free-proxy provider framework alongside 1proxy, Proxifly, and IPLocate. `WebshareProvider` paginates the account's `/api/v2/proxy/list/` endpoint, upserts proxies into the shared `free_proxies` table, and tombstones proxies the account no longer lists while never touching rows already promoted into the live proxy pool. Unlike the other sources, Webshare is a paid per-account list, gated on `FREE_PROXY_WEBSHARE_API_KEY`. ([#5993](https://github.com/diegosouzapw/OmniRoute/pull/5993) — thanks @ricatix) -- **feat(antigravity):** support custom **Google Cloud project ID** settings from the connection edit modal (Antigravity family). ([#5905](https://github.com/diegosouzapw/OmniRoute/pull/5905) — thanks @nickwizard) -- **feat(dashboard):** add a **wildcard-CORS runtime warning** banner (Settings → Authorization) when `CORS_ALLOW_ALL`/`*` origins are in effect, plus a new `docs/security/CORS.md` security guide covering the risk and safer alternatives. ([#5602](https://github.com/diegosouzapw/OmniRoute/issues/5602), [#5759](https://github.com/diegosouzapw/OmniRoute/pull/5759)) -- **feat(api):** add a `/v1/audio/translations` endpoint (Whisper-style audio translation), a new `audioTranslation` handler, and translation providers wired into `audioRegistry`. Regression guard: `tests/unit/audio-translations-route.test.ts` (8, incl. no-stack-leak). ([#5809](https://github.com/diegosouzapw/OmniRoute/pull/5809)) -- **feat(providers):** allow a **custom icon URL** for compatible provider nodes (migration 113 + `nodes.ts` + Zod schema + API routes + catalog + `ProviderIcon` UI). Regression guards: 14 backend + 5 frontend(vitest) + 24 page-utils tests. ([#5815](https://github.com/diegosouzapw/OmniRoute/pull/5815)) -- **feat(xai):** register a dedicated `XaiExecutor` with reasoning-effort suffix parsing. Regression guard: `tests/unit/executors/xai-executor.test.ts` (6). ([#5800](https://github.com/diegosouzapw/OmniRoute/pull/5800)) -- **feat(webfetch):** support **self-hosted FireCrawl** instances via `FIRECRAWL_BASE_URL`/`FIRECRAWL_TIMEOUT_MS`. Regression guard: `tests/unit/executors/firecrawl-fetch.test.ts` (4). ([#5793](https://github.com/diegosouzapw/OmniRoute/pull/5793)) -- **feat(providers):** add **ClinePass** as a first-class API-key (BYOK) provider — Cline's paid gateway (`cline-pass/*` models, plain Bearer key), distinct from the existing OAuth `cline` provider. Regression guard: 16 clinepass tests. ([#5942](https://github.com/diegosouzapw/OmniRoute/pull/5942) — thanks @adentdk) -- **feat(relay):** gate **Bifrost auto-routing** by the provider plugin manifest — only manifest-eligible providers reach the sidecar; ineligible/unknown providers fall back to the existing TS routing path with explicit reasons. Regression guards: 4 provider-plugin-manifest + 11 relay-routing-backend tests. ([#5870](https://github.com/diegosouzapw/OmniRoute/pull/5870) — thanks @KooshaPari) -- **feat(providers):** wire **Claude Sonnet 5** end-to-end across the model pipeline — registries, `modelSpecs`, pricing (×3), cost, Sonnet-family fallback, 1M-context, and static models. ([#5833](https://github.com/diegosouzapw/OmniRoute/pull/5833) — thanks @ggiak) - -### 🔧 Bug Fixes - -- **dashboard (`/dashboard/system/proxy` 500 on every render):** `ProxyRegistryManager` called `useProxyBatchOperations(load)` before the `const load = useCallback(...)` declaration in the component body, so every server render threw a TDZ `ReferenceError: Cannot access 'load' before initialization` and the whole proxy page 500'd (#5918 regression, caught by the release-PR e2e smoke — the PR→release fast-gates never render pages). The hook block now sits after the `load` declaration. Regression guard: `tests/unit/ui/ProxyRegistryManager-tdz-render.test.tsx` (SSR renderToString — the exact crash mode). - -- **server (TRACE/TRACK/CONNECT returned raw 500 on every route):** methods that undici/fetch cannot represent blew up inside Next's middleware adapter (`TypeError: 'TRACE' HTTP method is unsupported.`) as an unhandled 500 (caught by the release-PR dast-smoke Schemathesis negative tests on the new `/api/keys/{id}/devices` endpoint). The raw HTTP method guard now answers a clean 405 + `Allow` header for these methods on any path, before Next sees the request. Regression guard: `tests/unit/dast-method-not-allowed.test.ts` (new case). - -- **i18n (auto-detect refreshed every first visit):** `LocaleAutoDetect` (#5979) called `router.refresh()` on every cookie-less first visit — even when the detected browser locale was exactly the one the server had just rendered — re-navigating the page mid-interaction (flaky e2e "execution context destroyed" + a visible flash for every new visitor). It now refreshes only when the detected locale differs from the server-rendered ``. Regression guard: `tests/unit/ui/LocaleAutoDetect-refresh.test.tsx`. - -- **models (`oc/` alias must reach the no-auth OpenCode provider):** restore the [#2901](https://github.com/diegosouzapw/OmniRoute/issues/2901) routing contract after the #5918 transitive-alias change made the registered no-auth `opencode` provider unreachable by any prefix (`oc/` chained through the manual `opencode` → `opencode-zen` slug override and misrouted its combo entries). `resolveProviderAlias` now stops the alias chain as soon as a hop lands on a registered provider id, while keeping #5918's transitivity across alias-only hops and its loop/depth guards. Regression guards: `tests/unit/combo-builder-opencode-prefix.test.ts`, `tests/unit/provider-alias-transitive-5918.test.ts`. - -- **providers (Auggie executor EPIPE crash):** a fast-exiting `auggie` CLI (e.g. binary present but immediately failing) delivered `EPIPE` **asynchronously** as an `'error'` event on the child's stdin stream — which a plain try/catch around `stdin.write()` cannot catch — crashing the request instead of surfacing the sanitized CLI error. Both spawn sites now attach a stdin `'error'` handler so the child's own exit/close handlers report the failure. Regression guard: `tests/unit/auggie-executor.test.ts` (deterministic 3/3 locally). - -- **dashboard (CoolingConnectionsPanel broke `next build`):** the cooling-connections panel from #6061 imported `Card` from a shadcn-style path that does not exist in this repo (`@/components/ui/card`) and pulled the server DB barrel (`@/lib/localDb`) into a client component — `next build` failed to compile on the release branch. The panel now renders with repo-native markup and reads `formatResetCountdown` from the new client-safe `src/shared/utils/formatting.ts`. Regression guards: `tests/unit/format-reset-countdown.test.ts`, `tests/unit/ui/CoolingConnectionsPanel.test.tsx`. ([#6155](https://github.com/diegosouzapw/OmniRoute/pull/6155)) - -- **oauth (Zed "Unknown provider" crash):** adding **Zed** from the providers dashboard threw an unhandled `OAuth GET error: Unknown provider: zed` (500) ([#6041](https://github.com/diegosouzapw/OmniRoute/issues/6041)). Zed is a **keychain-import-only** provider — it's listed in the OAuth catalog so the UI shows it, but has no OAuth handler, so the generic `/api/oauth/[provider]/[action]` route hit `getProvider("zed")` and crashed. The route now recognizes keychain-import-only providers and returns a clear **400** pointing users at the **Import** button (for both GET and POST OAuth actions), instead of a 500. Regression guard: `tests/unit/oauth-keychain-import-only-6041.test.ts`. (thanks @imblowsnow) - -- **fix(providers):** disable the unsupported `thinking` param for `minimax-m2.7` on NVIDIA NIM (the upstream rejects it) ([#6102](https://github.com/diegosouzapw/OmniRoute/pull/6102)). Regression guard: `tests/unit/nvidia-minimax-thinking-strip.test.ts`. (thanks @anki1kr) - -- **fix(mitm):** add an in-process guard so concurrent MITM server starts no longer race — a second start while one is already in flight is short-circuited instead of double-binding the listener ([#6107](https://github.com/diegosouzapw/OmniRoute/pull/6107)). Regression guard: `tests/unit/mitm-start-guard.test.ts`. (thanks @anki1kr) - -- **translator (Responses → Chat Completions):** strip the Responses-API-only `truncation` field before forwarding a `/v1/responses` request to a non-OpenAI Chat Completions upstream ([#6109](https://github.com/diegosouzapw/OmniRoute/pull/6109)). Strict upstreams (e.g. NVIDIA NIM) rejected it with HTTP 400 `Unsupported parameter(s): truncation`, breaking Codex-style clients routed to those providers. `client_metadata`, `background`, and `safety_identifier` were already stripped — `truncation` was the remaining gap. Regression guard: `tests/unit/responses-strip-truncation-2311.test.ts`. (thanks @TuanNguyen0708) - -- **combo (prefer known context capacity over unknown):** when a combo filters out at least one target for exceeding a _known_ context limit, the router now prefers the remaining known-compatible targets over targets whose context metadata is simply unknown, instead of letting unknown-metadata targets be the only survivors. If no known-compatible context target remains, context-only candidates fall back to the normal strategy order. Regression guard: `tests/unit/combo-context-window-filter.test.ts`. ([#6088](https://github.com/diegosouzapw/OmniRoute/pull/6088) — thanks @Thinkscape) - -- **models (GLM-5.2 context normalization):** stop treating every hosted GLM-5.2 provider alias as the native 1M-context model. Native/bare GLM-5.2 and verified OpenCode / ZenMux routes keep their 1,000,000-token context, while hosted-provider aliases now respect the caps declared in their provider metadata instead of inheriting the native max. Regression guards: `tests/unit/model-capabilities-registry.test.ts`, `tests/unit/models-catalog-route.test.ts`. ([#6091](https://github.com/diegosouzapw/OmniRoute/pull/6091) — thanks @Thinkscape) - -- **providers (Gemini Web):** refresh the Gemini Web cookie handling and model catalog so live Gemini Web sessions keep authenticating and routing to current models. Regression guard: `tests/unit/gemini-web.test.ts`. ([#6095](https://github.com/diegosouzapw/OmniRoute/pull/6095) — thanks @backryun) - -- **providers (Perplexity Web):** refresh the Perplexity Web model catalog to the current set (GPT-5.4/5.5, Claude Sonnet 5.0 / Opus 4.8, GLM-5.2, Kimi K2.6, Nemotron 3 Ultra) and update the internal mode / `model_preference` mappings and thinking variants so requests resolve to live upstream models. Regression guard: `tests/unit/perplexity-web.test.ts`. ([#6106](https://github.com/diegosouzapw/OmniRoute/pull/6106) — thanks @backryun) - -- **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991)) -- **fix(onboarding):** route the provider-details link in the onboarding wizard by the node's stable id instead of the composite provider slug, which could point at the wrong provider details page for multi-account/fingerprint nodes. Regression guard: `tests/unit/onboarding-wizard-details-link-6145.test.ts`. ([#6145](https://github.com/diegosouzapw/OmniRoute/pull/6145) — thanks @chirag127) -- **fix(cli):** give `setup-claude` a fallback profile generator mirroring `setup-codex`, so profile generation no longer silently no-ops when the primary generator path is unavailable. Regression guard: `tests/unit/cli/setup-claude.test.ts` (new cases). ([#6138](https://github.com/diegosouzapw/OmniRoute/pull/6138) — thanks @derhornspieler) -- **fix(glm):** suppress a leaked `` close marker in the GLM Anthropic transport, which was surfacing the raw reasoning-close tag in visible response content instead of being consumed as part of the thinking-block framing. Regression guard: `tests/unit/glm-think-close-marker-leak.test.ts`. ([#6133](https://github.com/diegosouzapw/OmniRoute/pull/6133) — thanks @dhaern) -- **fix(provider-limits):** close a TOCTOU race in quota-recovery clearing by moving the check-then-clear to a CAS (compare-and-swap) primitive in `src/lib/db/providers.ts`, so two concurrent recovery paths can no longer both observe stale state and double-clear/re-lock a connection. Regression guard: `tests/unit/provider-limits-recovery.test.ts`. ([#6139](https://github.com/diegosouzapw/OmniRoute/pull/6139) — thanks @janeza2) -- **fix(provider-limits):** clear transient rate-limit state (`rateLimitedUntil`, `lastError`, `backoffLevel`) as soon as quota recovers, instead of leaving stale rate-limit fields behind that could keep a now-healthy connection looking unavailable. Regression guard: `tests/unit/provider-limits-recovery.test.ts`. ([#6128](https://github.com/diegosouzapw/OmniRoute/pull/6128) — thanks @janeza2) -- **combos (OpenCode/MiMo fingerprint accounts):** expand fingerprint-scoped OpenCode/MiMo accounts into their full per-fingerprint set in the combo builder, which previously showed only the first matching account entry and hid the rest from combo target selection. Regression guard: `tests/unit/combo-builder-fingerprint-expansion.test.ts`. ([#6092](https://github.com/diegosouzapw/OmniRoute/pull/6092), closes [#6087](https://github.com/diegosouzapw/OmniRoute/issues/6087) — thanks @anki1kr) -- **fix(auth):** persist quota-preflight account lockouts until the reset window elapses, instead of losing the lockout on process restart and letting a still-quota-exhausted account be selected again immediately. Regression guards: `tests/unit/sse-auth.test.ts`, `tests/unit/opencode-quota-fetcher.test.ts`, `tests/unit/usage-service-hardening.test.ts`. ([#6090](https://github.com/diegosouzapw/OmniRoute/pull/6090) — thanks @Thinkscape) -- **combo (fingerprint-based provider expansion):** expand fingerprint-based providers into per-fingerprint combo targets (`open-sse/services/combo/fingerprintExpansion.ts`) so a combo referencing a fingerprint-scoped provider fans out to every matching fingerprint account instead of collapsing onto one. Regression guards: `tests/unit/combo-fingerprint-expansion.test.ts`, `tests/integration/fingerprint-expansion.test.ts`. ([#6082](https://github.com/diegosouzapw/OmniRoute/pull/6082) — thanks @pizzav-xyz) -- **fix (safety-net redirect `reqId` crash):** fix a `reqId` `ReferenceError` thrown inside the safety-net combo redirect path in `src/sse/handlers/chat.ts`, remove dead code in `src/domain/quotaCache.ts`, and rename the stray root `DESING.md` to `DESIGN.md`. Regression guard: `tests/unit/chat-safetynet-reqid-6097.test.ts`. ([#6097](https://github.com/diegosouzapw/OmniRoute/pull/6097) — thanks @fix2015) -- **fix(compression):** send a patch-only body to `PUT /api/settings/compression` from `CompressionHub`, instead of round-tripping the full settings object and risking clobbering fields changed elsewhere between load and save. Regression guard: `tests/unit/ui/CompressionHub-patch-only.test.tsx`. ([#6077](https://github.com/diegosouzapw/OmniRoute/pull/6077), closes [#6039](https://github.com/diegosouzapw/OmniRoute/issues/6039) — thanks @anki1kr) -- **fix(codex):** use `access_token.exp` instead of `id_token.exp` when computing `expiresAt` on Codex auth import, since the `id_token` can expire far sooner than the actual access token, causing imported connections to be treated as expired while still usable. Regression guard: `tests/unit/codex-auth-import-expiry.test.ts`. ([#6084](https://github.com/diegosouzapw/OmniRoute/pull/6084), closes [#6075](https://github.com/diegosouzapw/OmniRoute/issues/6075) — thanks @anki1kr) -- **fix(security):** persist the IP allow/block-list configuration (it was resetting to Disabled and clearing configured IPs on every restart/update) and actually enforce it in the authz pipeline (`src/server/authz/pipeline.ts`), where it was previously validated but never applied. Regression guards: `tests/unit/ip-filter-persistence-6131.test.ts`, `tests/unit/authz/ip-filter-enforcement-6131.test.ts`, `tests/unit/ip-filter.test.ts`. (closes [#6131](https://github.com/diegosouzapw/OmniRoute/issues/6131), [#6132](https://github.com/diegosouzapw/OmniRoute/pull/6132)) -- **fix (Claude tool_result adjacency):** reattach an OpenAI-shaped `tool_result` to sit directly adjacent to its originating `tool_use` before translating to Claude's message format (`open-sse/translator/request/openai-to-claude/toolResultAdjacency.ts`), since Claude's API rejects/mishandles a tool result separated from its tool call by intervening messages. Regression guard: `tests/unit/translator-openai-to-claude.test.ts` (new cases). ([#6035](https://github.com/diegosouzapw/OmniRoute/pull/6035) — thanks @KooshaPari) -- **fix(config):** externalize `ws`/`bufferutil`/`utf-8-validate` in `next.config.mjs` so the `copilot-m365-web` executor's WebSocket masking path works at runtime — chat requests through it were silently timing out because the bundler was inlining `ws` instead of leaving it as a real Node dependency. Regression guard: `tests/unit/next-config.test.ts`. ([#6130](https://github.com/diegosouzapw/OmniRoute/pull/6130), closes [#6062](https://github.com/diegosouzapw/OmniRoute/issues/6062) — thanks @anki1kr, whose #6098 fix it re-lands) -- **fix(registry):** update grok-cli model context lengths to match the actual Grok CLI `/context` capacities — `grok-build` 128k→256k, `grok-composer-2.5-fast` 128k→200k — so context-aware routing stops filtering these models out for exceeding a stale, too-low limit. Registry-only. ([#5913](https://github.com/diegosouzapw/OmniRoute/pull/5913) — thanks @Chewji9875) -- **fix(providers):** strip an orphan `tool_result` (one with no preceding `tool_use`) on the Antigravity MITM path before translating to OpenAI format, since an unpaired tool result upstream caused request failures. Regression guard: `tests/unit/antigravity-orphan-toolresult-6026.test.ts`. (closes [#6026](https://github.com/diegosouzapw/OmniRoute/issues/6026), [#6115](https://github.com/diegosouzapw/OmniRoute/pull/6115)) -- **fix(providers):** emulate OpenAI-style `tool_calls` in the GitLab Duo executor (new `open-sse/executors/gitlabResponses.ts`), since the executor previously didn't emulate tool-call semantics for Duo, breaking tool-using clients routed to GitLab Duo. Regression guard: `tests/unit/gitlab-duo-toolcalls-6051.test.ts`. (closes [#6051](https://github.com/diegosouzapw/OmniRoute/issues/6051), [#6111](https://github.com/diegosouzapw/OmniRoute/pull/6111)) -- **fix(429 / accountFallback):** persist the per-account 429 cooldown cascade across the request boundary and classify OpenCode's "Monthly usage limit. Resets in N days." message as a connection-scoped quota exhaustion with an N-day cooldown (instead of a ~5s transient retry), so an exhausted account stops being re-selected until its window resets. ([#6061](https://github.com/diegosouzapw/OmniRoute/pull/6061) — thanks @KooshaPari / @anki1kr, whose superseded #6086 carried the same day-parser approach) -- **combo (sibling-model fallback on per-model-quota 500s):** when a combo held multiple models from the same provider (e.g. two Gemini models) and the first returned a server 500, the router retried the same locked model and surfaced a 429 "cooling down" instead of trying the sibling — `markConnectionLevelExhaustion` was wrongly tripped by a model-level 500 for per-model-quota providers (gemini, github, passthrough, compatible), and the retry loop didn't check `isModelLocked` before re-hitting the same model. Both gaps are fixed; the combo now falls through to the untried sibling model. Regression guard: `tests/unit/combo/combo-target-exhaustion.test.ts` (21 cases). ([#5976](https://github.com/diegosouzapw/OmniRoute/pull/5976) — thanks @hartmark) -- **providers (Cline non-streaming envelope):** Cline can return OpenAI-compatible chat completions wrapped as `{ success, data: { choices, usage, ... } }`; the non-streaming path checked the top-level body for empty content before unwrapping, so a valid wrapped response could be misclassified as malformed/empty. The envelope is now unwrapped immediately after provider-envelope handling, before empty-content detection, usage extraction, and translation. Regression guard: `tests/unit/cline-response-envelope.test.ts`. ([#6046](https://github.com/diegosouzapw/OmniRoute/pull/6046) — thanks @KooshaPari) -- **providers (kimi-web, qwen-web):** align the kimi-web model catalog and request-scenario selection with `www.kimi.com`'s live `GetAvailableModels` response, and stop aliasing `qwen3-coder-plus` on qwen-web now that it is present as its own model in the live Qwen web catalog. ([#5915](https://github.com/diegosouzapw/OmniRoute/pull/5915) — thanks @janeza2) -- **translator (Antigravity/Gemini tool schemas):** strip `multipleOf` from function-declaration parameters before forwarding to Antigravity/Gemini — it is not part of the Gemini OpenAPI 3.0 schema subset accepted upstream and triggered a hard 400 ("Unknown name multipleOf"). Added to `GEMINI_UNSUPPORTED_SCHEMA_KEYS` so it is stripped at every schema level; `minimum`/`maximum` are unaffected since Gemini accepts them. (Ported from 9router#2309, reported by @abil0321.) ([#6052](https://github.com/diegosouzapw/OmniRoute/pull/6052)) -- **translator (Kiro system prompt leak):** Kiro/CodeWhisperer has no system role, so system messages were normalized into a bare user turn — the full Claude Code system prompt then appeared as raw user text, polluting model context. System-origin content is now wrapped in `` tags before merging into the Kiro user message; real user turns are unaffected. (Ported from 9router#2306, reported by @VitzS7.) ([#6053](https://github.com/diegosouzapw/OmniRoute/pull/6053)) -- **fix(codex):** convert Chat Completions `json_schema` `response_format` → Responses API `text.format` on the Codex path, and preserve an existing `text.format` through verbosity normalization. Regression guards: 48 translator-openai-responses-req + 8 codex-verbosity tests. ([#5933](https://github.com/diegosouzapw/OmniRoute/pull/5933) — thanks @yusufrahadika) -- **fix(thinking):** only inject the `redacted_thinking` replay block when `tool_use` is present and thinking is enabled, avoiding a fabricated replay block on plain (non-tool) turns. ([#5945](https://github.com/diegosouzapw/OmniRoute/issues/5945), [#5953](https://github.com/diegosouzapw/OmniRoute/pull/5953)) -- **fix(resilience):** honor active **codex session affinity** over per-request reset-aware re-scoring, so an in-flight session sticks to its pinned account instead of being re-scored away mid-conversation. New `src/sse/services/sessionAffinityPin.ts` module. Regression guard: `tests/unit/codex-session-affinity-reset-aware-5903.test.ts`. ([#5903](https://github.com/diegosouzapw/OmniRoute/issues/5903), [#5943](https://github.com/diegosouzapw/OmniRoute/pull/5943)) -- **fix(resilience):** compute per-window `is_exhausted` and honor the quota-exhaustion preflight for **priority combos**, so a combo no longer keeps routing to a target whose current window is already exhausted. New `open-sse/services/combo/quotaExhaustionCutoff.ts`. Regression guard: `tests/unit/combo-priority-quota-exhaustion-cutoff-5923.test.ts`. ([#5923](https://github.com/diegosouzapw/OmniRoute/issues/5923), [#5941](https://github.com/diegosouzapw/OmniRoute/pull/5941)) -- **fix(providers):** strip a `/v1` suffix from the base URL unconditionally in both models-discovery paths, avoiding a doubled `/v1/v1/models` fetch error (e.g. Api Airforce). Regression guard: `tests/unit/airforce-v1-double-prefix-5899.test.ts`. ([#5899](https://github.com/diegosouzapw/OmniRoute/issues/5899), [#5920](https://github.com/diegosouzapw/OmniRoute/pull/5920) — thanks @anki1kr) -- **fix(api):** relax provider-scoped chat completion validation on `/api/providers/[provider]/chat/completions`. Regression guard: `tests/unit/provider-scoped-chat-completions-validation.test.ts`. ([#5907](https://github.com/diegosouzapw/OmniRoute/pull/5907) — thanks @nickwizard) -- **fix(providers):** validate **v0 Platform** (Vercel) API keys via the `/chats` endpoint instead of a probe that rejected valid keys. Regression guard: `tests/unit/provider-validation-specialty.test.ts`. ([#5954](https://github.com/diegosouzapw/OmniRoute/pull/5954) — thanks @vittoroliveira-dev) -- **fix(mcp):** auto-recover stale streamable HTTP MCP sessions on `initialize` instead of failing the reconnect. Regression guard: `tests/unit/mcp-session-sweep.test.ts`. ([#5957](https://github.com/diegosouzapw/OmniRoute/pull/5957) — thanks @Chewji9875) -- **fix(translator):** enforce strict Anthropic content-block compliance when converting an antigravity → openai request. Regression guard: `tests/unit/translator-antigravity-to-openai.test.ts` (9). ([#5935](https://github.com/diegosouzapw/OmniRoute/pull/5935)) -- **fix(sse):** strip ANSI/VT100 escape codes from `gemini-cli` stream frames using a ReDoS-safe pattern. Regression guard: `tests/unit/gemini-cli-ansi-sanitization.test.ts` (5). ([#5934](https://github.com/diegosouzapw/OmniRoute/pull/5934) — thanks @anki1kr) -- **fix(discovery):** resolve a doubled `/v1` discovery path and a `REDIRECT_BLOCKED` probe-loop abort in the model-discovery route. Regression guard: `tests/unit/provider-models-route.test.ts`. ([#5904](https://github.com/diegosouzapw/OmniRoute/pull/5904) — thanks @hamsa0x7) -- **fix(providers): Perplexity Web now emits real `tool_calls` in streaming mode** — previously only non-streaming requests (`hasTools && !stream`) converted `{...}` text into OpenAI `tool_calls`; streaming requests (the default for agentic coding clients) got the raw `` text as plain `delta.content` and never emitted a `tool_calls` SSE delta. Now mirrors the `chatgpt-web` `toolMode` helpers (`buildToolModeResponse()`/`toolCompletionToSseStream()`, extended with a caller-supplied `idSeed` so tool-call ids stay provider-specific), buffering the completion and emitting a terminal SSE replay carrying `delta.tool_calls` + `finish_reason: tool_calls` regardless of the caller's stream flag. ([#5927](https://github.com/diegosouzapw/OmniRoute/issues/5927), [#5937](https://github.com/diegosouzapw/OmniRoute/pull/5937)) -- **providers (openai-family model inference no longer hijacks cataloged models):** `resolveModelByProviderInference()` had an unconditional `/^gpt-/i` heuristic that hijacked any model id starting with `gpt-`/`o1`/`o3` into provider `openai`, even when the id is cataloged under other providers — breaking bare (non-combo) requests for open-weight models like `gpt-oss-120b` (served by fireworks/cerebras/scaleway/byteplus/sambanova/heroku), which don't exist on openai's catalog, producing a 404 with no fallback. The heuristic is now gated on `providers.length === 0` so it only fires for genuinely uncataloged openai-family ids. Regression guard: `tests/unit/gptoss-provider-inference-5852.test.ts`. ([#5852](https://github.com/diegosouzapw/OmniRoute/issues/5852), [#5938](https://github.com/diegosouzapw/OmniRoute/pull/5938)) -- **fix(providers): deepseek-web reliability** — auto-refresh the session on `401`/`403`, refresh the v2.0.0 client headers, and fix the token-kind bulk import path. Regression guards: `tests/unit/deepseek-web-autorefresh-401-response.test.ts`, `tests/unit/bulk-web-session-import.test.ts`. ([#5988](https://github.com/diegosouzapw/OmniRoute/pull/5988) — thanks @backryun) -- **fix(api):** guard the shared frontend API client (`handleResponse` in `src/shared/utils/api.ts`) against non-JSON error responses — it previously called `response.json()` unconditionally and read `data.error` directly, throwing an unrelated parse error (or `undefined`) instead of a useful message when an upstream/proxy returned a non-JSON error body. Now routes through `parseResponseBody`/`getErrorMessage` to build a safe message regardless of body shape. Regression guard: `tests/unit/shared-api-utils.test.ts`. ([#5973](https://github.com/diegosouzapw/OmniRoute/pull/5973)) -- **fix(embeddings):** forward the connection-level proxy configuration to embedding requests — `src/lib/embeddings/service.ts` previously ignored a connection's configured proxy when making embedding calls, so proxy-only network setups leaked embedding traffic outside the proxy. Regression guard: `tests/unit/embeddings-proxy-forwarding.test.ts`. ([#5975](https://github.com/diegosouzapw/OmniRoute/pull/5975)) -- **fix(resilience):** parse `Retry-After` from a 429's JSON body for cooldown calculation, not just the HTTP header — a new `retryAfterJson.ts` helper extracts a retry-after hint from common JSON error-body shapes and `accountFallback.ts`'s cooldown path now prefers it when the header is absent. Regression guard: `tests/unit/account-fallback-retry-after-json.test.ts`. (Includes #6013's retry-after-json extraction.) ([#5974](https://github.com/diegosouzapw/OmniRoute/pull/5974) — thanks @KooshaPari) - -### 📝 Maintenance - -- **release close (release-PR one-pass CI sweep):** restore Zod validation on the provider-scoped chat route with a `.passthrough()` schema that keeps #5907's relaxed semantics (t06 route-validation gate); point `/api/keys/{id}/devices`' 401 response at the management error envelope in `docs/openapi.yaml` (Schemathesis schema-conformance); rebaseline `i18nUiCoverage.pct` 77.5→76.8 (~1352 new en.json UI keys from the cycle await the async translation workflow — same shape as the v3.8.39 rebaseline); dismiss 2 CodeQL `js/incomplete-url-substring-sanitization` false positives on unit-test asserts (v3.8.35 precedent). - -- **release close (Phase 0 pre-flight):** align cycle-stale tests with merged behavior — provider count 166→167 (Kenari #6104), Linux-regenerated translate-path golden (+`kenari`), OpenCode quota scope `provider`→`connection` (#6061) — and absorb cycle ratchet drift (file-size caps for `oauth/[provider]/[action]/route.ts` 960, `providerLimits.ts` 998, `chat.ts` 1662, `auth.ts` 2426, with #6158 tracked to restore the oauth-route freeze). The test-masking gate gains a narrowly-scoped `_deletedWithReplacement` allowlist section (deletion is exempt ONLY when the declared replacement test file exists in HEAD — used for `targetExhaustion.test.ts` → `tests/unit/combo/combo-target-exhaustion.test.ts`, which has MORE coverage: 21 cases/52 asserts vs 13/37), plus 5 new gate unit tests and reduction-allowlist entries for the verified-legitimate #5958/#6088/#5816 assert migrations. - -- **test (deflake `setup-claude`):** `tests/unit/cli/setup-claude.test.ts` failed ~50% of runs with `Unable to deserialize cloned data due to invalid or unsupported version` at file teardown (all subtests passed), randomly reddening `Unit Tests fast-path (2/2)` / `Fast Quality Gates` across the PR→release queue. Root cause: `node --test` streams each file's report to the parent as V8-serialized frames on fd 1 (stdout), and the CLI helper under test (`syncClaudeProfilesFromModels`) prints progress via `console.log` — that stdout output interleaved with the serialized frames and corrupted the stream. The test now silences the stdout-writing `console` methods for the file's duration (no assertion inspects stdout), making it deterministic (15/15 green locally). ([#5959](https://github.com/diegosouzapw/OmniRoute/issues/5959)) ([#6021](https://github.com/diegosouzapw/OmniRoute/pull/6021)) - -- **API validation:** add a `validatedJsonBody(request, schema)` helper in `src/shared/validation/helpers.ts` that fuses JSON body parsing and Zod validation into a single call, returning either the type-narrowed data or a ready-to-return 400 `NextResponse` with the standard error envelope. Salvaged from the closed refactor PR #5075 (Tier 1 portable helper) with a focused 6-case regression test. Co-authored-by: KooshaPari -- **repo (Windows case-conflict cleanup):** remove the stale root `DESIGN.md`, which case-conflicted with `design.md` and broke checkouts/clones on case-insensitive Windows filesystems. ([#6140](https://github.com/diegosouzapw/OmniRoute/pull/6140) — thanks @backryun) -- **i18n(zh-CN):** translate the CHANGELOG entries and section headings, adopting zh-CN as a fully translated locale alongside the existing supporting docs. ([#6043](https://github.com/diegosouzapw/OmniRoute/pull/6043) — thanks @studyzy) -- **docs (env-doc-sync base-red):** document `BIFROST_PORT` in `.env.example` / `docs/reference/ENVIRONMENT.md` — the Bifrost embedded-service merge referenced `process.env.BIFROST_PORT` (default 8080) without documenting it, so `check:env-doc-sync` failed on the release tip and reddened Fast Quality Gates for every open PR→release. Docs-only (`8d7e3e28f`). -- **test (CI-runner-independent translate-path golden):** normalize OS/arch-derived request headers (`X-Stainless-Os`/`X-Stainless-Arch`, `(OS;arch)` User-Agent segments, and Antigravity's `os.platform()`-derived platform substring) in the provider translate-path golden snapshot, so the test no longer depends on the OS/arch of the CI runner that generated it — a Mac-literal Antigravity UA was failing on Linux CI. Regression guard: `tests/unit/provider-translate-path-golden.test.ts`. ([#6076](https://github.com/diegosouzapw/OmniRoute/pull/6076) — thanks @KooshaPari) -- **release-green base-reds (#5695 regex + file-size rebaseline):** `tests/unit/ui/quick-start-api-keys-link-5695.test.ts` now tolerates Prettier splitting a multi-line `` so the `step1Desc` regex matches the `/dashboard/api-manager` link instead of skipping to `step2`'s single-line `/dashboard/providers` link (test was brittle, not the code). Also rebaselines 5 files that grew via already-merged release-tip PRs in `config/quality/file-size-baseline.json` (`ApiManagerPageClient` 3017→3058, `OAuthModal` 969→989, `cliRuntime` 1090→1100, `webProvidersA` 805→809, `deepseek-web.test` 1081→1092), with shrink tracked in #3501. ([#6093](https://github.com/diegosouzapw/OmniRoute/pull/6093)) -- **release close (LEDGER-4 base-red):** the `cline-pass` provider's `minimax-m3` registry entry was missing `supportsVision`, breaking the LEDGER-4 registry-consistency test (every `minimax-m3` entry must set `supportsVision` to match `lite.ts` — the model is multimodal). Flagged it to match every other `minimax-m3` entry (trae, bazaarlink, cline, ollama-cloud, ...). ([#6003](https://github.com/diegosouzapw/OmniRoute/pull/6003)) -- **release close (stryker `tap.testFiles` drift):** additional release-green cleanup clearing the `qoder` registry's `minimax-m3` `supportsVision` LEDGER-4 base-red and `stryker.conf.json`'s `tap.testFiles` drift. ([#6012](https://github.com/diegosouzapw/OmniRoute/pull/6012)) -- **install (pnpm 11+ support):** pnpm 11 introduced `ERR_PNPM_IGNORED_BUILDS` for native addon packages — without explicit `allowBuilds` approval, packages silently skip their build scripts and OmniRoute fails to start with missing native modules. Sets `allowBuilds=true` for all 13 native addon packages in `pnpm-workspace.yaml` (`@parcel/watcher`, `@swc/core`, `better-sqlite3`, `core-js`, `esbuild`, `keytar`, `koffi`, `libxmljs2`, `onnxruntime-node`, `protobufjs`, `sharp`, `tls-client-node`, `unrs-resolver`) and migrates `onlyBuiltDependencies` from the deprecated `package.json` field to a new `pnpm.json`. (commit 39349da18 — thanks @chirag127) -- **refactor (Block J hot-path decomposition):** extract pure leaves with no behavior change from the executor, translator, combo, and SSE hot paths — orphaned executor tests moved to top-level so a runner collects them, and `handleComboChat`'s auto-strategy/target-timeout regions split into named helpers. ([#6063](https://github.com/diegosouzapw/OmniRoute/pull/6063), [#6049](https://github.com/diegosouzapw/OmniRoute/pull/6049), [#6036](https://github.com/diegosouzapw/OmniRoute/pull/6036), [#6030](https://github.com/diegosouzapw/OmniRoute/pull/6030), [#6020](https://github.com/diegosouzapw/OmniRoute/pull/6020), [#6018](https://github.com/diegosouzapw/OmniRoute/pull/6018), [#6017](https://github.com/diegosouzapw/OmniRoute/pull/6017), [#6016](https://github.com/diegosouzapw/OmniRoute/pull/6016), [#6015](https://github.com/diegosouzapw/OmniRoute/pull/6015), [#6014](https://github.com/diegosouzapw/OmniRoute/pull/6014), [#6008](https://github.com/diegosouzapw/OmniRoute/pull/6008), [#6006](https://github.com/diegosouzapw/OmniRoute/pull/6006), [#6000](https://github.com/diegosouzapw/OmniRoute/pull/6000), [#5999](https://github.com/diegosouzapw/OmniRoute/pull/5999), [#5994](https://github.com/diegosouzapw/OmniRoute/pull/5994), [#5967](https://github.com/diegosouzapw/OmniRoute/pull/5967), [#5962](https://github.com/diegosouzapw/OmniRoute/pull/5962), [#5960](https://github.com/diegosouzapw/OmniRoute/pull/5960), [#5947](https://github.com/diegosouzapw/OmniRoute/pull/5947), [#5949](https://github.com/diegosouzapw/OmniRoute/pull/5949), [#5940](https://github.com/diegosouzapw/OmniRoute/pull/5940), [#5932](https://github.com/diegosouzapw/OmniRoute/pull/5932)) -- **chore (quality/CI housekeeping):** rebaseline residual ESLint/cognitive-complexity/file-size drift accumulated over the v3.8.44 cycle, move orphaned executor tests to a top-level location so a runner actually collects them, harden the release pipeline with a test-masking pre-flight gate plus contributors/uncovered helpers, and make the `pr-evidence` FAIL output tell the author to push (a body edit alone does not re-run the gate). ([#5926](https://github.com/diegosouzapw/OmniRoute/pull/5926), [#5944](https://github.com/diegosouzapw/OmniRoute/pull/5944), [#5952](https://github.com/diegosouzapw/OmniRoute/pull/5952), [#6027](https://github.com/diegosouzapw/OmniRoute/pull/6027), [#5928](https://github.com/diegosouzapw/OmniRoute/pull/5928), plus a #5975-collateral test hardening pinning a seeded connection to direct egress in route-edge-coverage) -- **docs (housekeeping):** normalize mixed-language documentation content, restore the OpenAPI coverage ratchet by documenting 9 newly-added routes, record Hard Rule #22 (cross-session safety — `git stash` + in-flight PR bans), and document the compression-engine's upstream sync policy for the RTK/Caveman engines. ([#6105](https://github.com/diegosouzapw/OmniRoute/pull/6105), [#5955](https://github.com/diegosouzapw/OmniRoute/pull/5955), [#5948](https://github.com/diegosouzapw/OmniRoute/pull/5948), plus docs-only commit 926b08aa8) - -### 🙌 Contributors - -Thanks to everyone whose work landed in v3.8.44: - -| Contributor | PRs / Issues | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| [@adentdk](https://github.com/adentdk) | #5942 | -| [@anki1kr](https://github.com/anki1kr) | #5899, #5920, #5934, #6039, #6061, #6062, #6075, #6077, #6084, #6086, #6087, #6092, #6098, #6130 | -| [@artickc](https://github.com/artickc) | #6119 | -| [@backryun](https://github.com/backryun) | #5988, #6095, #6106, #6140 | -| [@chamdanilukman](https://github.com/chamdanilukman) | #5972 | -| [@Chewji9875](https://github.com/Chewji9875) | #5913, #5957 | -| [@chirag127](https://github.com/chirag127) | #6145 | -| [@derhornspieler](https://github.com/derhornspieler) | #6138 | -| [@dhaern](https://github.com/dhaern) | #6133 | -| [@doedja](https://github.com/doedja) | direct commit / report | -| [@DuyPrX](https://github.com/DuyPrX) | #5978 | -| [@fix2015](https://github.com/fix2015) | #6097 | -| [@ggiak](https://github.com/ggiak) | #5833 | -| [@hamsa0x7](https://github.com/hamsa0x7) | #5904 | -| [@hartmark](https://github.com/hartmark) | #5976 | -| [@imblowsnow](https://github.com/imblowsnow) | direct commit / report | -| [@janeza2](https://github.com/janeza2) | #5915, #6128, #6139 | -| [@KooshaPari](https://github.com/KooshaPari) | #5870, #5974, #6035, #6046, #6050, #6061, #6073, #6076, #6086 | -| [@mugni-rukita](https://github.com/mugni-rukita) | #5998 | -| [@nickwizard](https://github.com/nickwizard) | #5905, #5907 | -| [@ofekbetzalel](https://github.com/ofekbetzalel) | direct commit / report | -| [@pizzav-xyz](https://github.com/pizzav-xyz) | #6082 | -| [@powellnorma](https://github.com/powellnorma) | direct commit / report | -| [@ricatix](https://github.com/ricatix) | #5993 | -| [@ryanngit](https://github.com/ryanngit) | #5995 | -| [@studyzy](https://github.com/studyzy) | #6043 | -| [@tantai-newnol](https://github.com/tantai-newnol) | #5968 | -| [@Thinkscape](https://github.com/Thinkscape) | #6088, #6090, #6091 | -| [@tn5052](https://github.com/tn5052) | #5965 | -| [@TuanNguyen0708](https://github.com/TuanNguyen0708) | direct commit / report | -| [@vittoroliveira-dev](https://github.com/vittoroliveira-dev) | #5954 | -| [@waguriagentic](https://github.com/waguriagentic) | direct commit / report | -| [@whale9820](https://github.com/whale9820) | #5936 | -| [@WslzGmzs](https://github.com/WslzGmzs) | direct commit / report | -| [@yusufrahadika](https://github.com/yusufrahadika) | #5933 | -| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer | +- **chatcore (tools): stop the default 128-tool cap from silently dropping opencode's `task`/MCP tools.** opencode (used as an MCP/agent host) sends a large tool list; when it exceeds the speculative `MAX_TOOLS_LIMIT` (128) default, `truncateToolList` did a blind `tools.slice(0, 128)`, dropping every tool past index 128 — including opencode's built-in `task` tool (subagent launch) and many MCP tools, so models routed through OmniRoute could no longer spawn subagents or reach part of their tools. The cap exists to avoid upstream `400`s for providers with real hard limits (e.g. grok-cli 200), so it is kept for those: detection of the opencode client (`isOpencodeClient` — any `x-opencode-*` header, or `opencode` in the user-agent) now only bypasses the **speculative 128 default**, never a known provider ceiling. Precedence is explicit — a proactive/detected provider limit always truncates (even for opencode); otherwise opencode forwards its full tool list; otherwise the unchanged 128 default applies to every other client. Refactors `getEffectiveToolLimit` into `getKnownToolLimit(provider) ?? DEFAULT_LIMIT` (byte-identical for existing callers) and fixes a cosmetic debug-log that reported the truncated count instead of the original. Regression guard: `tests/unit/tool-limit-detector.test.ts`. --- @@ -290,8 +78,6 @@ Thanks to everyone whose work landed in v3.8.44: - **providers (CLI profile auto-sync):** opt-in CLI profile auto-sync toggles, including Claude Code auto-sync, so generated CLI profiles can track provider changes automatically. ([#5755](https://github.com/diegosouzapw/OmniRoute/pull/5755) — thanks @diegosouzapw) -- **feat(minimax):** surface MiniMax M3 `` reasoning as `reasoning_content` on OpenAI-format provider tiers. (thanks @zmf963) - ### 🔧 Bug Fixes - **fix(opencode):** stop fabricating `User-Agent: opencode/local` and `x-opencode-client: cli` headers when the client sends none — the executor-dedup refactor ([#5720](https://github.com/diegosouzapw/OmniRoute/pull/5720)) accidentally re-introduced header fabrication, violating the forward-only contract (inventing opencode-internal values risks upstream rejection). Restored to forward-only: those headers are emitted only when a real client source is present. Regression guard: `tests/unit/opencode-executor.test.ts`. (thanks @diegosouzapw) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 54657d6737..33c33d9ffb 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -583,6 +583,7 @@ export async function handleChatCore({ isResponsesEndpoint, nativeCodexPassthrough, isDroidCLI, + isOpencodeClient, copilotCompatibleReasoning, clientResponseFormat, } = resolveChatCoreRequestFormat({ clientRawRequest, body, provider, userAgent }); @@ -2236,6 +2237,7 @@ export async function handleChatCore({ targetFormat, credentials, log, + bypassDefaultToolLimit: isOpencodeClient, }); updatePendingScope(pendingScope, { diff --git a/open-sse/handlers/chatCore/requestFormat.ts b/open-sse/handlers/chatCore/requestFormat.ts index 63ad12589b..fa9e9194fb 100644 --- a/open-sse/handlers/chatCore/requestFormat.ts +++ b/open-sse/handlers/chatCore/requestFormat.ts @@ -37,6 +37,33 @@ function isCopilotClient( return false; } +function isOpencodeClient( + headers: Headers | Record | null | undefined, + userAgent?: string | null +): boolean { + const matchesUserAgent = (value: unknown) => + typeof value === "string" && value.toLowerCase().includes("opencode"); + const matchesHeaderKey = (key: string) => key.toLowerCase().startsWith("x-opencode-"); + + if (matchesUserAgent(userAgent)) return true; + + if (headers instanceof Headers) { + for (const [key, value] of headers as unknown as Iterable<[string, string]>) { + if (matchesHeaderKey(key) || (key.toLowerCase() === "user-agent" && matchesUserAgent(value))) { + return true; + } + } + } else if (headers && typeof headers === "object") { + for (const [key, value] of Object.entries(headers)) { + if (matchesHeaderKey(key) || (key.toLowerCase() === "user-agent" && matchesUserAgent(value))) { + return true; + } + } + } + + return false; +} + /** * Resolve the per-request endpoint/format facts at the top of handleChatCore. Pure: a function of * the inbound endpoint, the (possibly already-mutated) body, the resolved provider, and the @@ -64,6 +91,7 @@ export function resolveChatCoreRequestFormat(opts: { const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); const copilotCompatibleReasoning = isCopilotClient(clientRawRequest?.headers, userAgent); + const isOpencodeClientRequest = isOpencodeClient(clientRawRequest?.headers, userAgent); const clientResponseFormat = sourceFormat === FORMATS.OPENAI_RESPONSES && !isResponsesEndpoint && !isDroidCLI ? FORMATS.OPENAI @@ -75,6 +103,7 @@ export function resolveChatCoreRequestFormat(opts: { nativeCodexPassthrough, isDroidCLI, copilotCompatibleReasoning, + isOpencodeClient: isOpencodeClientRequest, clientResponseFormat, }; } diff --git a/open-sse/handlers/chatCore/upstreamBody.ts b/open-sse/handlers/chatCore/upstreamBody.ts index 1a302552d8..c426b240a7 100644 --- a/open-sse/handlers/chatCore/upstreamBody.ts +++ b/open-sse/handlers/chatCore/upstreamBody.ts @@ -14,7 +14,7 @@ import { applyConfiguredPayloadRules, resolvePayloadRuleProtocols, } from "../../services/payloadRules.ts"; -import { getEffectiveToolLimit } from "../../services/toolLimitDetector.ts"; +import { getEffectiveToolLimit, getKnownToolLimit } from "../../services/toolLimitDetector.ts"; import { providerSupportsCaching } from "../../utils/cacheControlPolicy.ts"; import { FORMATS } from "../../translator/formats.ts"; @@ -41,15 +41,35 @@ function buildAppliedRulesSummary( function truncateToolList( bodyToSend: Body, provider: string | null | undefined, + bypassDefaultToolLimit: boolean, log?: LoggerLike ): Body { + if (!Array.isArray(bodyToSend.tools)) return bodyToSend; + + const knownLimit = getKnownToolLimit(provider); + if (knownLimit !== null) { + if (bodyToSend.tools.length > knownLimit) { + const originalCount = bodyToSend.tools.length; + const truncatedTools = bodyToSend.tools.slice(0, knownLimit); + bodyToSend = { ...bodyToSend, tools: truncatedTools }; + log?.debug?.( + "TOOL_LIMIT", + `Truncated ${originalCount} tools to ${knownLimit} for ${provider}` + ); + } + return bodyToSend; + } + + if (bypassDefaultToolLimit === true) return bodyToSend; + const effectiveToolLimit = getEffectiveToolLimit(provider); - if (Array.isArray(bodyToSend.tools) && bodyToSend.tools.length > effectiveToolLimit) { + if (bodyToSend.tools.length > effectiveToolLimit) { + const originalCount = bodyToSend.tools.length; const truncatedTools = bodyToSend.tools.slice(0, effectiveToolLimit); bodyToSend = { ...bodyToSend, tools: truncatedTools }; log?.debug?.( "TOOL_LIMIT", - `Truncated ${(bodyToSend.tools as unknown[]).length} tools to ${effectiveToolLimit} for ${provider}` + `Truncated ${originalCount} tools to ${effectiveToolLimit} for ${provider}` ); } return bodyToSend; @@ -104,9 +124,18 @@ export async function prepareUpstreamBody(opts: { provider: string | null | undefined; targetFormat: string; credentials: CredentialsLike; + bypassDefaultToolLimit?: boolean; log?: LoggerLike; }): Promise { - const { translatedBody, modelToCall, provider, targetFormat, credentials, log } = opts; + const { + translatedBody, + modelToCall, + provider, + targetFormat, + credentials, + bypassDefaultToolLimit = false, + log, + } = opts; let bodyToSend: Body = translatedBody.model === modelToCall @@ -131,7 +160,7 @@ export async function prepareUpstreamBody(opts: { ); } - bodyToSend = truncateToolList(bodyToSend, provider, log); + bodyToSend = truncateToolList(bodyToSend, provider, bypassDefaultToolLimit ?? false, log); bodyToSend = backfillQwenOAuthUser(bodyToSend, provider, credentials, log); bodyToSend = await injectPromptCacheKey(bodyToSend, provider, targetFormat); diff --git a/open-sse/services/toolLimitDetector.ts b/open-sse/services/toolLimitDetector.ts index 5685f26e98..b743aedb0b 100644 --- a/open-sse/services/toolLimitDetector.ts +++ b/open-sse/services/toolLimitDetector.ts @@ -19,7 +19,7 @@ if (typeof _detectedLimitsSweep === "object" && "unref" in _detectedLimitsSweep) (_detectedLimitsSweep as { unref?: () => void }).unref?.(); } -export function getEffectiveToolLimit(provider: string): number { +export function getKnownToolLimit(provider: string | null | undefined): number | null { const proactiveLimit = PROVIDER_TOOL_LIMITS[provider]; if (proactiveLimit !== undefined) { return proactiveLimit; @@ -28,7 +28,11 @@ export function getEffectiveToolLimit(provider: string): number { if (cached && Date.now() - cached.timestamp < TTL_MS) { return cached.limit; } - return DEFAULT_LIMIT; + return null; +} + +export function getEffectiveToolLimit(provider: string | null | undefined): number { + return getKnownToolLimit(provider) ?? DEFAULT_LIMIT; } export function setDetectedToolLimit(provider: string, limit: number): void { diff --git a/tests/unit/tool-limit-detector.test.ts b/tests/unit/tool-limit-detector.test.ts index 82e89323bd..ba4bba552d 100644 --- a/tests/unit/tool-limit-detector.test.ts +++ b/tests/unit/tool-limit-detector.test.ts @@ -7,6 +7,7 @@ import assert from "node:assert/strict"; import { getEffectiveToolLimit, + getKnownToolLimit, setDetectedToolLimit, parseToolLimitFromError, shouldDetectLimit, @@ -18,8 +19,37 @@ describe("toolLimitDetector", () => { clearDetectedLimits(); }); + it("should return null from getKnownToolLimit when no proactive or detected limit exists", () => { + assert.strictEqual(getKnownToolLimit("openai"), null); + }); + + it("should return null from getKnownToolLimit for null/undefined provider", () => { + assert.strictEqual(getKnownToolLimit("openai"), null); + assert.strictEqual(getKnownToolLimit(null), null); + assert.strictEqual(getKnownToolLimit(undefined), null); + }); + it("should return default limit when no cached value", () => { assert.strictEqual(getEffectiveToolLimit("openai"), 128); + assert.strictEqual(getEffectiveToolLimit(null), 128); + assert.strictEqual(getEffectiveToolLimit(undefined), 128); + }); + + it("should return proactive known limit for grok-cli", () => { + assert.strictEqual(getKnownToolLimit("grok-cli"), 200); + }); + + it("should return detected known limit when available", () => { + setDetectedToolLimit("openai", 100); + assert.strictEqual(getKnownToolLimit("openai"), 100); + assert.strictEqual(getEffectiveToolLimit("openai"), 100); + }); + + it("should keep getEffectiveToolLimit contract for default, proactive, and detected limits", () => { + assert.strictEqual(getEffectiveToolLimit("openai"), 128); + assert.strictEqual(getEffectiveToolLimit("grok-cli"), 200); + setDetectedToolLimit("openai", 100); + assert.strictEqual(getEffectiveToolLimit("openai"), 100); }); it("should return cached limit when available", () => { @@ -61,9 +91,14 @@ describe("toolLimitDetector", () => { }); it("should return proactive limit for grok-cli (200) without any detection", () => { + assert.strictEqual(getKnownToolLimit("grok-cli"), 200); assert.strictEqual(getEffectiveToolLimit("grok-cli"), 200); }); + it("should document grok-cli known limit precedence for opencode bypass truncation", () => { + assert.strictEqual(getKnownToolLimit("grok-cli"), 200); + }); + it("should not override proactive limit with setDetectedToolLimit", () => { setDetectedToolLimit("grok-cli", 150); assert.strictEqual(getEffectiveToolLimit("grok-cli"), 200); From e755c5ac68fdd3ffbb42862111e256d4007daba2 Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 5 Jul 2026 14:33:32 +0900 Subject: [PATCH 22/61] fix(providers): refresh GitHub Copilot catalog (#6154) * fix(providers): refresh github copilot catalog Limit GitHub Copilot discovery to the curated supported model set and keep the provider cooldown panel client-safe by moving countdown formatting out of localDb. * chore(quality): rebaseline providerPageHelpers.ts file-size (+13, #6154 copilot catalog) The GitHub Copilot catalog refresh grows the provider-page model-section helper (1021->1034). Fast-path PR->release skips check:file-size, so the bump lands with the PR. Justification recorded in file-size-baseline.json. Co-authored-by: diegosouzapw --------- Co-authored-by: diegosouzapw --- config/quality/eslint-suppressions.json | 5 - config/quality/file-size-baseline.json | 5 +- .../config/providers/registry/github/index.ts | 166 ++++++++++++------ open-sse/services/githubCopilotModels.ts | 28 +++ .../providers/[id]/__tests__/phase1e.test.tsx | 58 ++++-- .../components/CompatibleModelsSection.tsx | 15 +- .../components/PassthroughModelsSection.tsx | 10 +- .../[id]/components/ProviderModelsSection.tsx | 31 ++-- .../providers/[id]/providerPageHelpers.ts | 29 ++- src/shared/constants/modelSpecs.ts | 2 +- ...t-gemini-claude-route-no-responses.test.ts | 48 +++-- tests/unit/github-copilot-gpt-4o-mini.test.ts | 7 +- .../github-copilot-model-discovery.test.ts | 56 ++++-- tests/unit/provider-models-config.test.ts | 28 +-- ...ider-registry-github-copilot-gpt-4.test.ts | 54 +++--- ...der-registry-github-copilot-gpt-4o.test.ts | 33 ++-- ...gistry-github-copilot-targetformat.test.ts | 33 +++- 17 files changed, 405 insertions(+), 203 deletions(-) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 0cab8df64d..47f146c9c6 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1132,11 +1132,6 @@ "count": 4 } }, - "tests/unit/copilot-gemini-claude-route-no-responses.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, "tests/unit/cursor-usage-fetcher.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 0b2beb0367..b589c177cb 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -206,7 +206,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264, - "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1021, + "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1034, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906, "src/app/(dashboard)/dashboard/providers/page.tsx": 1927, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, @@ -372,5 +372,6 @@ "_rebaseline_2026_06_25_rc17_pr_batch": "rc17 PR batch own growth (cohesive, not extractable): responseSanitizer.ts 1103->1122 (+19 = SanitizeOpenAIResponseOptions interface + stripReasoning option, #4678); tokenRefresh.ts 2070->2090 (+20 = codex 401 defense-in-depth unrecoverable-refresh guard, #4686); token-refresh-service.test.ts 1322->1353 (+31 = 401-unfamiliar-payload regression case, #4686); translator-openai-responses-req.test.ts 1047->1050 (+3 = reasoning_effort non-Copilot assertion update, #4688). All are the merged PRs own surgical additions at existing chokepoints.", "_rebaseline_2026_06_25_rc17b_leva2": "rc17 leva2 PR batch own growth (cohesive, not extractable): providerLimits.ts 950->955 (#4786 generalized accesstoken fallback); default.ts NEW frozen entry at 828 (#4729 anthropic-compatible Bearer + #4766 json_schema fallback + #4787 cline workos headers — three provider-specific header branches); openai-to-kiro.ts 807->814 (#4763 Claude-capability image gate); openai-responses.ts 923->937 (#4764 computeFinishReason guard); executor-default-base.test.ts 1339->1440 (#4766 json_schema fallback tests); translator-openai-to-kiro.test.ts 918->980 (#4763 non-Claude image gate tests).", "_rebaseline_2026_06_27_v3838_filesize_drift": "Mid-cycle drift on release/v3.8.38 — feature/fix growth from already-merged PRs that the fast-path (PR->release skips check:file-size) let accumulate without a bump. src/shared/constants/sidebarVisibility.ts 1100->1198 (+98 = #3812 colored menu-icon support, per-item accent map across the sidebar entries; #5142 then dropped one orphan settings entry, net still above the frozen). src/sse/handlers/chat.ts 1560->1575 (+15 = #5064 self-inflicted-timeout cooldown skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS LIVE_WS_HOST honour / early empty-message reject). Localized feature/fix code next to existing branches, each covered by its own PR tests; not extractable without hiding the chokepoint. Structural shrink of chat.ts tracked in #3501.", - "_rebaseline_2026_07_05_6211_providerlimits_fetch_timeout": "PR #6211 own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1121->1127 (+6 = data-timeout guard for the quota page's two first-paint fetches — a PROVIDER_LIMITS_FETCH_TIMEOUT_MS const + fetchWithTimeout at the /api/providers/client and /api/usage/provider-limits call sites — so a never-settling connection can no longer wedge initialLoading on the skeleton, same infinite-skeleton class the PR also fixes on the providers page). Cohesive fix code at the existing fetch chokepoints (the 5-line rationale comment explains why a timeout/abort is needed where a try/catch only rescues a rejection); not extractable. Covered by tests/unit/providers-page-data-timeout.test.ts. Fast-path PR->release skips check:file-size, so this bump lands with the PR. Structural shrink of this file tracked in #3501." + "_rebaseline_2026_07_05_6211_providerlimits_fetch_timeout": "PR #6211 own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1121->1127 (+6 = data-timeout guard for the quota page's two first-paint fetches — a PROVIDER_LIMITS_FETCH_TIMEOUT_MS const + fetchWithTimeout at the /api/providers/client and /api/usage/provider-limits call sites — so a never-settling connection can no longer wedge initialLoading on the skeleton, same infinite-skeleton class the PR also fixes on the providers page). Cohesive fix code at the existing fetch chokepoints (the 5-line rationale comment explains why a timeout/abort is needed where a try/catch only rescues a rejection); not extractable. Covered by tests/unit/providers-page-data-timeout.test.ts. Fast-path PR->release skips check:file-size, so this bump lands with the PR. Structural shrink of this file tracked in #3501.", + "_rebaseline_2026_07_05_6154_copilot_catalog_helpers": "PR #6154 own growth: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 1021->1034 (+13 = GitHub Copilot catalog refresh — model-section helper wiring for the refreshed passthrough/compatible model lists). Cohesive UI-helper growth alongside the registry/modelSpecs catalog refresh; not extractable. Covered by the PR's provider-registry-github-copilot-* unit tests. Fast-path PR->release skips check:file-size, so this bump lands with the PR (contributor backryun)." } diff --git a/open-sse/config/providers/registry/github/index.ts b/open-sse/config/providers/registry/github/index.ts index f8c25d05a1..41673dcf7d 100644 --- a/open-sse/config/providers/registry/github/index.ts +++ b/open-sse/config/providers/registry/github/index.ts @@ -25,69 +25,137 @@ export const githubProvider: RegistryEntry = { defaultContextLength: 128000, headers: getGitHubCopilotChatHeaders(), models: [ - // Copilot still serves the original GPT-4 via chat/completions; keep it - // alongside GPT-4o and the GPT-5.x family so apps that hard-code `gpt-4` resolve here. - { id: "gpt-4", name: "GPT-4", contextLength: 128000 }, - // 9router#98 — Copilot still serves GPT-4o via chat/completions; keep it - // alongside the GPT-5.x family so apps that hard-code `gpt-4o` resolve here. - { id: "gpt-4o", name: "GPT-4o", contextLength: 128000 }, - // Copilot also serves the cheaper GPT-4o mini via chat/completions; keep it - // alongside gpt-4o so apps that hard-code `gpt-4o-mini` resolve to the Copilot - // (`gh`) provider rather than only the github-models (`ghm`) marketplace entry. - { id: "gpt-4o-mini", name: "GPT-4o mini", contextLength: 128000 }, - { id: "gpt-5-mini", name: "GPT-5 Mini", targetFormat: "openai-responses" }, - { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" }, - { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" }, { - id: "gpt-5.4", - name: "GPT-5.4", - targetFormat: "openai-responses", - supportsXHighEffort: true, + id: "claude-fable-5", + name: "Claude Fable 5", + contextLength: 1000000, + maxOutputTokens: 64000, }, - { id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES }, { - id: "claude-haiku-4.5", - name: "Claude Haiku 4.5", + id: "claude-opus-4.8-fast", + name: "Claude Opus 4.8 (fast mode)", + contextLength: 1000000, + maxOutputTokens: 64000, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, + { + id: "claude-opus-4.8", + name: "Claude Opus 4.8", + contextLength: 1000000, + maxOutputTokens: 64000, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, + { + id: "claude-opus-4.7", + name: "Claude Opus 4.7", + contextLength: 1000000, + maxOutputTokens: 64000, + }, + { + id: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + contextLength: 1000000, + maxOutputTokens: 64000, + }, + { + id: "claude-opus-4.5", + name: "Claude Opus 4.5", contextLength: 200000, + maxOutputTokens: 32000, + }, + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000, - maxOutputTokens: 64000, + maxOutputTokens: 32000, }, { - id: "claude-sonnet-4.6", - name: "Claude Sonnet 4.6", + id: "claude-haiku-4.5", + name: "Claude Haiku 4.5", contextLength: 200000, - maxOutputTokens: 64000, - }, - { - // #2911: GitHub Copilot's Responses API does not serve Claude/Gemini — - // route them via chat/completions (provider default) like claude-opus-4.6. - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5 (Full ID)", - contextLength: 200000, - maxOutputTokens: 64000, - }, - { - id: "claude-opus-4.6", - name: "Claude Opus 4.6", - contextLength: 1000000, - maxOutputTokens: 128000, - }, - { - // #2911: Claude on Copilot must use chat/completions, not the Responses API. - id: "claude-opus-4.7", - name: "Claude Opus 4.7", - contextLength: 1000000, - maxOutputTokens: 128000, + maxOutputTokens: 32000, }, // #2911: Gemini on Copilot must use chat/completions, not the Responses API. - { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, - { id: "gemini-3-flash-preview", name: "Gemini 3 Flash" }, - { id: "oswe-vscode-prime", name: "Raptor Mini", targetFormat: "openai-responses" }, - //{ id: "?", name: "Goldeneye" }, + { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro", + contextLength: 1000000, + maxOutputTokens: 64000, + }, + { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + contextLength: 1000000, + maxOutputTokens: 64000, + }, + { id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES, maxOutputTokens: 128000 }, + { + id: "gpt-5.4", + name: "GPT-5.4", + targetFormat: "openai-responses", + supportsXHighEffort: true, + contextLength: 1050000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + targetFormat: "openai-responses", + contextLength: 400000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5.3-codex", + name: "GPT-5.3-Codex", + targetFormat: "openai-responses", + contextLength: 400000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5-mini", + name: "GPT-5 mini", + targetFormat: "openai-responses", + contextLength: 264000, + maxOutputTokens: 64000, + }, + { + id: "gpt-4o-2024-11-20", + name: "GPT-4o", + contextLength: 128000, + maxOutputTokens: 16384, + }, + { id: "gpt-4o-mini", name: "GPT-4o mini", contextLength: 128000, maxOutputTokens: 4096 }, + { + id: "gpt-4-0125-preview", + name: "GPT 4 Turbo", + contextLength: 128000, + maxOutputTokens: 4096, + }, + { + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + contextLength: 256000, + maxOutputTokens: 32000, + }, + { + id: "mai-code-1-flash", + name: "MAI-Code-1-Flash", + targetFormat: "openai-responses", + contextLength: 256000, + maxOutputTokens: 128000, + }, + { + id: "oswe-vscode-prime", + name: "Raptor mini", + targetFormat: "openai-responses", + contextLength: 264000, + maxOutputTokens: 64000, + }, ], }; diff --git a/open-sse/services/githubCopilotModels.ts b/open-sse/services/githubCopilotModels.ts index b24a57c3e8..5ccbcb623b 100644 --- a/open-sse/services/githubCopilotModels.ts +++ b/open-sse/services/githubCopilotModels.ts @@ -20,6 +20,32 @@ import { getGitHubCopilotChatHeaders } from "../config/providerHeaderProfiles.ts"; export const GITHUB_COPILOT_MODELS_URL = "https://api.githubcopilot.com/models"; +export const GITHUB_COPILOT_MODEL_ALLOWLIST = [ + "claude-fable-5", + "claude-opus-4.8-fast", + "claude-opus-4.8", + "claude-opus-4.7", + "claude-sonnet-4.6", + "claude-opus-4.5", + "claude-sonnet-5", + "claude-sonnet-4.5", + "claude-haiku-4.5", + "gemini-3.1-pro-preview", + "gemini-3.5-flash", + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5-mini", + "gpt-4o-2024-11-20", + "gpt-4o-mini", + "gpt-4-0125-preview", + "kimi-k2.7-code", + "mai-code-1-flash", + "oswe-vscode-prime", +] as const; + +const GITHUB_COPILOT_MODEL_ALLOWLIST_SET = new Set(GITHUB_COPILOT_MODEL_ALLOWLIST); export type GitHubCopilotModel = { id: string; @@ -59,6 +85,7 @@ export function parseGitHubCopilotModels(data: unknown): GitHubCopilotModel[] { const item = asRecord(value); const id = toNonEmptyString(item.id) || toNonEmptyString(item.model); if (!id || seen.has(id)) continue; + if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(id)) continue; seen.add(id); const name = toNonEmptyString(item.name) || toNonEmptyString(item.display_name) || id; models.push({ id, name, owned_by: "github" }); @@ -89,6 +116,7 @@ function toFallbackResult( .map((model) => { const id = toNonEmptyString(model.id); if (!id) return null; + if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(id)) return null; return { id, name: toNonEmptyString(model.name) || id, owned_by: "github" }; }) .filter((model): model is GitHubCopilotModel => Boolean(model)); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx index fb0562c8a8..a65bad6019 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { buildCompatMap, isModelHiddenFn, + getDisplayModelAlias, effectiveNormalizeForProtocol, effectivePreserveForProtocol, anyNormalizeCompatBadge, @@ -35,10 +36,7 @@ vi.mock("next/navigation", () => ({ vi.mock("next-intl", () => ({ useTranslations: () => (key: string, values?: Record) => { if (values) { - return Object.entries(values).reduce( - (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), - key - ); + return Object.entries(values).reduce((acc, [k, v]) => acc.replace(`{${k}}`, String(v)), key); } return key; }, @@ -85,6 +83,22 @@ describe("providerPageHelpers — model-compat pure functions", () => { expect(isModelHiddenFn("unknown-model", customMap, overrideMap)).toBe(false); }); + it("isModelHiddenFn ignores deleted tombstones when reading visibility", () => { + const customMap = buildCompatMap([]); + const overrideMap = buildCompatMap([ + { id: "gpt-4o-2024-11-20", isHidden: true, isDeleted: true }, + { id: "gpt-5-mini", isHidden: true }, + ]); + + expect(isModelHiddenFn("gpt-4o-2024-11-20", customMap, overrideMap)).toBe(false); + expect(isModelHiddenFn("gpt-5-mini", customMap, overrideMap)).toBe(true); + }); + + it("getDisplayModelAlias ignores provider-scoped identity aliases", () => { + expect(getDisplayModelAlias("gpt-4o-2024-11-20", "gpt-4o-2024-11-20")).toBeNull(); + expect(getDisplayModelAlias("gpt-5-mini", "fast-mini")).toBe("fast-mini"); + }); + it("effectiveNormalizeForProtocol returns correct flag", () => { const customMap = buildCompatMap(customModels); const overrideMap = buildCompatMap(overrideModels); @@ -115,10 +129,10 @@ describe("providerPageHelpers — model-compat pure functions", () => { }); it("formatProviderModelsErrorResponse extracts error.message", async () => { - const mockRes = new Response( - JSON.stringify({ error: { message: "Model not found" } }), - { status: 422, statusText: "Unprocessable Entity" } - ); + const mockRes = new Response(JSON.stringify({ error: { message: "Model not found" } }), { + status: 422, + statusText: "Unprocessable Entity", + }); const detail = await formatProviderModelsErrorResponse(mockRes); expect(detail).toBe("Model not found"); }); @@ -185,7 +199,9 @@ describe("PassthroughModelRow — render smoke test", () => { }); afterEach(() => { - act(() => { root.unmount(); }); + act(() => { + root.unmount(); + }); container.remove(); }); @@ -222,7 +238,9 @@ describe("ModelVisibilityToolbar — render smoke test", () => { }); afterEach(() => { - act(() => { root.unmount(); }); + act(() => { + root.unmount(); + }); container.remove(); }); @@ -259,7 +277,9 @@ describe("useModelCompatState — hook unit test via component wrapper", () => { }); afterEach(() => { - act(() => { root.unmount(); }); + act(() => { + root.unmount(); + }); container.remove(); }); @@ -267,7 +287,12 @@ describe("useModelCompatState — hook unit test via component wrapper", () => { const { useModelCompatState } = await import("../hooks/useModelCompatState"); const customModels = [ - { id: "gpt-4o", normalizeToolCallId: true, preserveOpenAIDeveloperRole: false, isHidden: true }, + { + id: "gpt-4o", + normalizeToolCallId: true, + preserveOpenAIDeveloperRole: false, + isHidden: true, + }, ]; const modelCompatOverrides: any[] = []; @@ -281,7 +306,9 @@ describe("useModelCompatState — hook unit test via component wrapper", () => { compat.effectiveModelPreserveDeveloper("gpt-4o"), compat.anyNormalizeCompatBadge("gpt-4o"), compat.anyNoPreserveCompatBadge("gpt-4o"), - ].map(String).join(","); + ] + .map(String) + .join(","); return {results}; } @@ -291,8 +318,9 @@ describe("useModelCompatState — hook unit test via component wrapper", () => { const span = container.querySelector("[data-testid='results']"); expect(span).not.toBeNull(); - const [hidden, notHidden, normalize, preserve, anyNorm, anyNoPreserve] = - (span!.textContent ?? "").split(","); + const [hidden, notHidden, normalize, preserve, anyNorm, anyNoPreserve] = ( + span!.textContent ?? "" + ).split(","); expect(hidden).toBe("true"); expect(notHidden).toBe("false"); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx index 94c1307175..7a8542265b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx @@ -17,6 +17,7 @@ import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; import { useNotificationStore } from "@/store/notificationStore"; import { buildCompatMap, + getDisplayModelAlias, providerText, type CompatModelRow, } from "../providerPageHelpers"; @@ -57,10 +58,7 @@ export interface CompatibleModelsSectionProps { effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; - saveModelCompatFlags: ( - modelId: string, - flags: CompatibleModelsSaveFlags - ) => Promise; + saveModelCompatFlags: (modelId: string, flags: CompatibleModelsSaveFlags) => Promise; compatSavingModelId?: string; onModelsChanged?: () => void; isModelHidden: (modelId: string) => boolean; @@ -155,7 +153,8 @@ export default function CompatibleModelsSection({ for (const [alias, fullModel] of providerAliases) { const fmStr = fullModel as string; const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; - aliasByModelId.set(modelId, alias as string); + const displayAlias = getDisplayModelAlias(modelId, alias as string); + if (displayAlias) aliasByModelId.set(modelId, displayAlias); } const addModel = (model: CompatModelRow, source: string) => { @@ -194,11 +193,13 @@ export default function CompatibleModelsSection({ const fmStr = fullModel as string; const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; if (!modelId || seenModelIds.has(modelId)) continue; + const displayAlias = getDisplayModelAlias(modelId, alias as string); + if (!displayAlias) continue; const customModel = customModelMap.get(modelId); rows.push({ modelId, - alias: alias as string, - displayName: alias as string, + alias: displayAlias, + displayName: displayAlias, source: customModel ? customModel.source || "custom" : "alias", isFree: modelId.endsWith(":free") || diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx index e3dda0a620..fb0d2b6e62 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -21,6 +21,7 @@ import { import { useNotificationStore } from "@/store/notificationStore"; import { buildCompatMap, + getDisplayModelAlias, providerText, testAllResultsText, evaluateTestAllEntry, @@ -228,7 +229,8 @@ export default function PassthroughModelsSection({ for (const [alias, fullModel] of providerAliases) { const fmStr = fullModel as string; const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; - aliasByModelId.set(modelId, alias as string); + const displayAlias = getDisplayModelAlias(modelId, alias as string); + if (displayAlias) aliasByModelId.set(modelId, displayAlias); fullModelByModelId.set(modelId, fmStr); } @@ -266,12 +268,14 @@ export default function PassthroughModelsSection({ const fmStr = fullModel as string; const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; if (!modelId || seenModelIds.has(modelId)) continue; + const displayAlias = getDisplayModelAlias(modelId, alias as string); + if (!displayAlias) continue; const customModel = customModelMap.get(modelId); rows.push({ modelId, fullModel: fmStr, - alias: alias as string, - displayName: alias as string, + alias: displayAlias, + displayName: displayAlias, source: customModel ? customModel.source || "custom" : "alias", isFree: modelId.endsWith(":free") || diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx index c2edb4b633..1b252b209e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx @@ -15,7 +15,11 @@ import { useState } from "react"; import { Button } from "@/shared/components"; import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch"; import { isFreeModel, sortModelsFreeFirst } from "@/shared/utils/freeModels"; -import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; +import { + getDisplayModelAlias, + providerText, + type ProviderMessageTranslator, +} from "../providerPageHelpers"; import ModelRow, { ModelVisibilityToolbar } from "./ModelRow"; import PassthroughModelsSection from "./PassthroughModelsSection"; import CompatibleModelsSection from "./CompatibleModelsSection"; @@ -86,11 +90,7 @@ export interface ProviderModelsSectionProps { setAutoHideFailed: (v: boolean) => void; setVisibilityFilter: (v: "all" | "visible" | "hidden") => void; saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise; - handleToggleModelHidden: ( - providerKey: string, - modelId: string, - hidden: boolean - ) => Promise; + handleToggleModelHidden: (providerKey: string, modelId: string, hidden: boolean) => Promise; handleBulkToggleModelHidden: ( providerKey: string, modelIds: string[], @@ -187,8 +187,7 @@ export default function ProviderModelsSection({ ); - const clearAllButton = (modelMeta.customModels.length > 0 || - providerAliasEntries.length > 0) && ( + const clearAllButton = (modelMeta.customModels.length > 0 || providerAliasEntries.length > 0) && ( ))} +
+ + +
{error &&
{error}
} @@ -122,9 +170,9 @@ export default function FreeProviderRankingsPage() { ) : ( <> {/* Top 3 Podium */} - {rankings.length >= 3 && ( + {displayedRankings.length >= 3 && (
- {rankings.slice(0, 3).map((provider, idx) => ( + {displayedRankings.slice(0, 3).map((provider, idx) => (
0 && ( + {displayedRankings.length > 0 && (
@@ -181,10 +229,11 @@ export default function FreeProviderRankingsPage() { + - {rankings.map((provider, idx) => ( + {displayedRankings.map((provider, idx) => ( + ))} @@ -237,9 +297,13 @@ export default function FreeProviderRankingsPage() { )} - {rankings.length === 0 && !error && ( + {displayedRankings.length === 0 && !error && ( -
{t("emptyState")}
+
+ {configuredOnly && rankings.length > 0 + ? t("noConfiguredProviders") + : t("emptyState")} +
)} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 75b632a949..8a34193da8 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -9030,7 +9030,11 @@ "colScore": "Score", "colAvgScore": "Avg Score", "colModels": "Models", - "colType": "Type" + "colType": "Type", + "configuredOnly": "Configured Only", + "configuredOnlyHint": "Show only providers with active connections", + "noConfiguredProviders": "No configured providers found. Add a provider connection first.", + "colConfigured": "Status" }, "discovery": { "title": "Provider Discovery", diff --git a/tests/unit/free-provider-rankings-configured-filter.test.ts b/tests/unit/free-provider-rankings-configured-filter.test.ts new file mode 100644 index 0000000000..86b16552de --- /dev/null +++ b/tests/unit/free-provider-rankings-configured-filter.test.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for the "Configured Only" filter on the Free Provider Rankings page. + * + * Phase 1 of #6150 — verifies the toggle state, filtering logic, status column, + * cleanup flag, and i18n keys exist in the source code. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = join(import.meta.dirname, "../.."); +const read = (p: string) => readFileSync(join(root, p), "utf8"); +const pageSrc = read("src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx"); +const en = JSON.parse(read("src/i18n/messages/en.json")); + +test("page declares configuredOnly state", () => { + assert.ok(pageSrc.includes("useState(false)"), "configuredOnly defaults to false"); + assert.ok(pageSrc.includes("setConfiguredOnly"), "setConfiguredOnly setter exists"); +}); + +test("page declares configuredProviderIds state", () => { + assert.ok(pageSrc.includes("configuredProviderIds"), "configuredProviderIds state exists"); + assert.ok(pageSrc.includes("Set"), "configuredProviderIds is typed as Set"); +}); + +test("page fetches /api/providers on mount", () => { + assert.ok(pageSrc.includes('fetch("/api/providers")'), "fetches /api/providers"); + assert.ok(pageSrc.includes("conn?.provider"), "uses optional chaining for conn.provider"); +}); + +test("useEffect has cleanup flag to prevent stale state updates", () => { + assert.ok(pageSrc.includes("let active = true"), "declares cleanup flag"); + assert.ok(pageSrc.includes("if (!active) return"), "guards state update with active flag"); + assert.ok(pageSrc.includes("active = false"), "cleanup function sets active to false"); +}); + +test("displayedRankings filters by configuredProviderIds when toggle is on", () => { + assert.ok(pageSrc.includes("displayedRankings"), "displayedRankings derived variable exists"); + assert.ok( + pageSrc.includes("configuredProviderIds.has(r.id)"), + "filters rankings by configuredProviderIds.has(r.id)" + ); + assert.ok( + pageSrc.includes("configuredOnly\n ? rankings.filter"), + "conditional: when configuredOnly is true, filters rankings" + ); +}); + +test("toggle switch has accessible attributes", () => { + assert.ok(pageSrc.includes('role="switch"'), "toggle has role=switch"); + assert.ok( + pageSrc.includes("aria-checked={configuredOnly}"), + "toggle has aria-checked bound to configuredOnly" + ); + assert.ok( + pageSrc.includes('htmlFor="configured-only-toggle"'), + "label is linked to toggle via htmlFor" + ); +}); + +test("table has a 'Configured' status column", () => { + assert.ok(pageSrc.includes('t("colConfigured")'), "table header includes colConfigured key"); + assert.ok( + pageSrc.includes("configuredProviderIds.has(provider.id)"), + "status column checks configuredProviderIds" + ); +}); + +test("empty state shows noConfiguredProviders when toggle is on", () => { + assert.ok( + pageSrc.includes('t("noConfiguredProviders")'), + "empty state uses noConfiguredProviders i18n key" + ); + assert.ok( + pageSrc.includes("configuredOnly && rankings.length > 0"), + "shows noConfiguredProviders only when toggle is on and data exists" + ); +}); + +test("i18n: en.json has all required filter keys", () => { + const keys = en.freeProviderRankingsPage; + assert.ok(keys, "freeProviderRankingsPage namespace exists in en.json"); + assert.equal(typeof keys.configuredOnly, "string", "configuredOnly is a string"); + assert.equal(typeof keys.configuredOnlyHint, "string", "configuredOnlyHint is a string"); + assert.equal(typeof keys.noConfiguredProviders, "string", "noConfiguredProviders is a string"); + assert.equal(typeof keys.colConfigured, "string", "colConfigured is a string"); + assert.ok(keys.configuredOnly.length > 0, "configuredOnly is non-empty"); + assert.ok(keys.configuredOnlyHint.length > 0, "configuredOnlyHint is non-empty"); + assert.ok(keys.noConfiguredProviders.length > 0, "noConfiguredProviders is non-empty"); + assert.ok(keys.colConfigured.length > 0, "colConfigured is non-empty"); +}); From c347abb7747017182385947221de5ccd44721f42 Mon Sep 17 00:00:00 2001 From: serverless83 <35410475+serverless83@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:48:35 +0200 Subject: [PATCH 35/61] fix(i18n): add 118 missing Italian translations (#6212) i18n(it): add 118 Italian translations (#6212). Audited net-additive (0 keys dropped, valid JSON). Thanks @serverless83. Integrated into release/v3.8.45. --- CHANGELOG.md | 4 + src/i18n/messages/it.json | 154 ++++++++++++++++++++++++++++++++++---- 2 files changed, 143 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4fec7f6e9..eeefce72b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ - **feat(rankings):** add a **'Configured Only'** filter to the Free Provider Rankings page, so the table can be narrowed to just the providers you have configured connections for (with an empty-state hint when none are configured). New `en.json` keys and a pure filter helper covered by `tests/unit/free-provider-rankings-configured-filter.test.ts`. ([#6245](https://github.com/diegosouzapw/OmniRoute/pull/6245), closes [#6150](https://github.com/diegosouzapw/OmniRoute/issues/6150) — thanks @Iammilansoni) +### 📝 Maintenance + +- **i18n(it):** add 118 missing Italian (`it`) translations (net-additive — no existing keys dropped, valid JSON), improving Italian UI coverage. ([#6212](https://github.com/diegosouzapw/OmniRoute/pull/6212) — thanks @serverless83) + --- ## [3.8.43] — 2026-07-02 diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 751b0b4bb8..f10799a667 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -841,7 +841,9 @@ "batchDetailCancelConfirm": "Cancel this batch? In-progress requests will stop.", "batchActionCancelError": "Failed to cancel batch. Try again.", "batchActionRetryError": "Failed to retry failed requests. Try again.", - "batchConceptRetentionNote": "Results and error files are retained for 30 days (Anthropic: 29 days)" + "batchConceptRetentionNote": "Results and error files are retained for 30 days (Anthropic: 29 days)", + "manualConfig": "Configurazione manuale", + "unknownProvider": "Provider sconosciuto" }, "featureFlagOmnirouteEmergencyFallbackDescription": "__MISSING__:Route budget-exhausted requests to the emergency free fallback provider/model.", "featureFlagArenaEloSyncEnabledDescription": "__MISSING__:Enable periodic Arena AI leaderboard ELO sync for model intelligence rankings.", @@ -1114,7 +1116,9 @@ "dragReorderItem": "__MISSING__:Drag to reorder", "cannotHide": "__MISSING__:This item cannot be hidden", "alwaysVisible": "__MISSING__:Always visible", - "groupSeparatorLabel": "__MISSING__:Separator" + "groupSeparatorLabel": "__MISSING__:Separator", + "discovery": "Discovery", + "discoverySubtitle": "Scansiona provider per accesso gratuito" }, "webhooks": { "title": "Webhook", @@ -1806,7 +1810,9 @@ "normalKeysSection": "__MISSING__:Normal keys", "quotaKeysSection": "__MISSING__:Quota keys", "quotaPill": "__MISSING__:QUOTA", - "quotaModeOnly": "__MISSING__:qtSd-only" + "quotaModeOnly": "__MISSING__:qtSd-only", + "devicesCount": "{count, plural, one {# dispositivo} other {# dispositivi}}", + "devicesTooltip": "{count, plural, one {# IP/User-Agent distinto visto con questa chiave (ultimi 30 min)} other {# IP/User-Agent distinti visti con questa chiave (ultimi 30 min)}}" }, "auditLog": { "title": "Registro di controllo", @@ -1853,14 +1859,16 @@ "webSearch": "__MISSING__:Web Search", "webFetch": "__MISSING__:Web Fetch", "video": "__MISSING__:Video", - "music": "__MISSING__:Music" + "music": "__MISSING__:Music", + "ocr": "OCR" }, "noProviders": "__MISSING__:No providers configured for this kind yet.", "addConnection": "__MISSING__:Add Connection", "backToProviders": "__MISSING__:Back to Providers", "connections": "__MISSING__:{count} Connections", "noConnections": "__MISSING__:No connections yet — add one from the provider page.", - "loading": "__MISSING__:Loading..." + "loading": "__MISSING__:Loading...", + "suggestedModels": "Modelli suggeriti dal provider" }, "search": { "searchQuery": "Search Query", @@ -2804,7 +2812,22 @@ "agentFeaturesContextLengthErrorInteger": "La lunghezza del contesto deve essere un numero intero valido", "agentFeaturesContextLengthErrorRange": "La lunghezza del contesto deve essere compresa tra 1.000 e 2.000.000", "compressionOverride": "Sostituzione della compressione", - "modePack": "Pacchetto modalità" + "modePack": "Pacchetto modalità", + "fusionJudgeModel": "Modello giudice", + "fusionJudgeModelHelp": "Modello che sintetizza le risposte del panel in un'unica risposta finale. Lascia vuoto per usare il primo modello del panel.", + "fusionMinPanel": "Panel minimo", + "fusionMinPanelHelp": "Risposte del panel necessarie prima che i ritardatari ricevano una finestra di grazia (default 2).", + "fusionPanelHardTimeoutMs": "Timeout massimo panel (ms)", + "fusionPanelHardTimeoutMsHelp": "Limite assoluto per evitare che un modello bloccato fermi l'intero panel (default 90000).", + "fusionStragglerGraceMs": "Grazia ritardatari (ms)", + "fusionStragglerGraceMsHelp": "Quanto attendere i modelli lenti del panel una volta raggiunto il quorum (default 8000).", + "responseValidationAddCheck": "+ Aggiungi controllo", + "responseValidationForbidden": "Sottostringhe vietate (una per riga)", + "responseValidationHelp": "Passa al prossimo target quando un corpo 200 OK non supera questi controlli (contenuto dell'assistente).", + "responseValidationJsonPaths": "Controlli JSON-path", + "responseValidationMinLength": "Lunghezza minima contenuto (caratteri)", + "responseValidationRequired": "Sottostringhe richieste (una per riga)", + "responseValidationTitle": "Validazione risposta" }, "costs": { "title": "Costi", @@ -3432,7 +3455,11 @@ "cleaning": "__MISSING__:Cleaning...", "cleanNow": "__MISSING__:Clean now", "cleanupSuccess": "__MISSING__:{count} point(s) removed", - "cleanupFailed": "__MISSING__:Cleanup failed" + "cleanupFailed": "__MISSING__:Cleanup failed", + "banner": "Vector store Tier 2 — un'alternativa esterna e scalabile al sqlite-vec integrato (Tier 1). Attivalo solo se hai un insieme di memorie molto grande o vuoi memoria condivisa tra istanze; la maggior parte degli utenti sta bene con sqlite-vec. Quando attivato diventa lo store primario e fa automaticamente fallback a sqlite-vec se non raggiungibile.", + "collectionHelp": "Qualsiasi nome — OmniRoute lo crea al primo utilizzo", + "embeddingModelHelp": "Imposta automaticamente la dimensione del vettore al primo utilizzo. Le memorie esistenti non vengono popolate retroattivamente, e cambiare modello dopo aver inserito dati richiede una nuova collezione.", + "hostHelp": "Docker locale: http://localhost:6333 · Qdrant Cloud: l'URL del tuo cluster" }, "rerank": { "enableLabel": "__MISSING__:Enable Rerank", @@ -3538,7 +3565,8 @@ "installSkillModalDesc": "__MISSING__:Paste a skill manifest JSON or upload a .json file.", "uploadJson": "__MISSING__:Upload JSON", "cancel": "__MISSING__:Cancel", - "installSkill": "Installa abilità" + "installSkill": "Installa abilità", + "delete": "Elimina" }, "health": { "title": "Salute del sistema", @@ -4813,7 +4841,13 @@ "kimiWebLabel": "__MISSING__:Kimi Web", "kimiWebDesc": "__MISSING__:Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)", "doubaoWebLabel": "__MISSING__:Doubao Web", - "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com" + "doubaoWebDesc": "__MISSING__:ByteDance AI chat via doubao.com", + "clientIdentityHint": "Opzionale. Aggiunge header di fingerprint client (es. User-Agent) corrispondenti a una CLI nota per gateway compatibili che li richiedono.", + "clientIdentityLabel": "Identità Client", + "compatibleDefaultModelHint": "Inserisci l'ID modello esattamente come lo aspetta il tuo endpoint compatibile. Questo modello verrà salvato come default della connessione.", + "compatibleDefaultModelLabel": "Modello Predefinito", + "iconUrlHint": "Opzionale. URL dell'immagine mostrata come icona di questo provider.", + "iconUrlLabel": "URL Icona" }, "settings": { "title": "Impostazioni", @@ -5955,6 +5989,12 @@ "INSUFFICIENT_SCOPE": "__MISSING__:API key lacks the manage scope.", "BYPASS_PREFIX_NOT_ALLOWED": "__MISSING__:One or more prefixes target spawn-capable routes and cannot be bypassed.", "GENERIC": "__MISSING__:Failed to update authz settings." + }, + "cors": { + "wildcard": { + "desc": "Qualsiasi sito web può chiamare l'API di questo server dal browser di un visitatore. Usa solo su reti fidate — imposta origini esplicite in ALLOWED_ORIGINS e disabilita CORS_ALLOW_ALL in produzione.", + "title": "CORS è aperto a tutte le origini (CORS_ALLOW_ALL=true)" + } } }, "resilienceBaseCooldownLabel": "Tempo di recupero della base", @@ -6059,7 +6099,56 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "compressionCavemanPanelHint": "L'attivazione e il livello si impostano nel panel:", + "compressionPreserveSystemAlways": "Sempre", + "compressionPreserveSystemNever": "Mai", + "compressionPreserveSystemWhenNoCache": "Quando nessuna cache", + "description": "Descrizione", + "disable": "Disabilita", + "enable": "Abilita", + "logToolSourcesDescription": "Emette una riga di log diagnostico per richiesta che riepiloga il conteggio tool e la suddivisione per sorgente MCP/hosted/client.", + "logToolSourcesToggle": "Log Sorgenti Tool", + "logsDeleted": "{count, plural, =0 {Nessun log scaduto eliminato} one {Eliminato # log scaduto} other {Eliminati # log scaduti}}", + "queueDepth": "Profondità Coda", + "redisLauncherContainer": "Container", + "redisLauncherDesc": "Avvia con un clic un container Redis 7 (Podman o Docker) per cache risposte, tracciamento quote e rate limiting.", + "redisLauncherError": "Errore: {message}", + "redisLauncherHint": "Equivalente a eseguire `omniroute redis up`. Il container si chiama `omniroute-redis` e ascolta su 127.0.0.1:6379.", + "redisLauncherLaunch": "Avvia Redis", + "redisLauncherLaunching": "Avvio in corso...", + "redisLauncherReachable": "Raggiungibile", + "redisLauncherRefresh": "Aggiorna", + "redisLauncherRunning": "In esecuzione", + "redisLauncherStop": "Ferma", + "redisLauncherTitle": "Redis Locale", + "reset": "Ripristina", + "resetUsageData": "Ripristina Dati di Utilizzo", + "resetUsageDataDesc": "Seleziona fino a quando eliminare i dati di utilizzo. Questa azione non può essere annullata.", + "resetUsageFailed": "Ripristino dati di utilizzo fallito", + "resetUsagePeriod_12h": "12 ore", + "resetUsagePeriod_1d": "1 giorno", + "resetUsagePeriod_1h": "1 ora", + "resetUsagePeriod_30d": "30 giorni", + "resetUsagePeriod_3h": "3 ore", + "resetUsagePeriod_5m": "5 minuti", + "resetUsagePeriod_6h": "6 ore", + "resetUsagePeriod_7d": "7 giorni", + "resetUsagePeriod_all": "Tutto", + "resetUsageSuccess": "{count, plural, =0 {Nessuna riga dati utilizzo eliminata} one {Ripristinati dati utilizzo (# riga eliminata)} other {Ripristinati dati utilizzo (# righe eliminate)}}", + "resetting": "Ripristino in corso...", + "resilienceComboCooldownBudgetMs": "Budget attesa totale", + "resilienceComboCooldownMaxWaitMs": "Attesa massima per tentativo", + "resilienceComboCooldownWaitDesc": "Solo per combo quota-share: attende un breve cooldown transitorio e reinoltra invece di restituire subito un 429. Non attende mai su quota_exhausted.", + "resilienceComboCooldownWaitTitle": "Attesa cooldown combo quota-share", + "resilienceComboCooldownWaitToggleDesc": "Solo combo quota-share; non attende mai su quota_exhausted.", + "resilienceQuotaShareConcurrencyDesc": "Solo per combo quota-share: quando una connessione imposta un limite Max Concurrent, serializza le richieste concorrenti verso quell'account di sottoscrizione in modo che non venga mai inondato oltre il suo tetto. Le richieste in eccesso aspettano in coda invece di ricevere un 429. Il limite deriva dal campo Max Concurrent di ogni connessione; questo interruttore abilita o disabilita solo il suo rispetto.", + "resilienceQuotaShareConcurrencyTitle": "Concorrenza per connessione quota-share", + "resilienceQuotaShareConcurrencyToggleDesc": "Solo combo quota-share; rispetta il limite Max Concurrent di ogni connessione.", + "searchProviderAria": "Provider di ricerca", + "searchProviderPlaceholder": "Cerca provider...", + "selectProviderPlaceholder": "Seleziona provider...", + "update": "Aggiorna" }, "contextRtk": { "title": "RTK Engine", @@ -6837,7 +6926,9 @@ "updatedShort": "__MISSING__:Updated", "lastRefreshed": "__MISSING__:Last refreshed", "providerQuota": "__MISSING__:Provider Quota", - "providerQuotaHomeHint": "__MISSING__:Live status across connected accounts" + "providerQuotaHomeHint": "__MISSING__:Live status across connected accounts", + "showLessQuotas": "Mostra meno", + "showMoreQuotas": "Mostra {count} altri" }, "modals": { "waitingAuth": "In attesa di autorizzazione", @@ -7834,7 +7925,12 @@ "bulkImportMaxExceeded": "Massimo 100 proxy per importazione", "bulkImportPreview": "Anteprima", "clearAssignment": "(incarico chiaro)", - "bulkProxyAssignment": "Assegnazione di proxy in blocco" + "bulkProxyAssignment": "Assegnazione di proxy in blocco", + "batchDeleteSelected": "Elimina {count} selezionati", + "batchSelectedCount": "{count} selezionati", + "errorTestFailed": "Test dei proxy fallito", + "testAll": "Testa tutti", + "testPassed": "✓ OK" }, "playground": { "title": "Title", @@ -8376,7 +8472,8 @@ "verified": "__MISSING__:Verified", "install": "__MISSING__:Install", "installedFromMarketplace": "__MISSING__:Plugin {name} installed!", - "hooks": "Hooks" + "hooks": "Hooks", + "marketplaceInstallComingSoon": "Le installazioni dal marketplace saranno presto disponibili." }, "quotaPlans": { "title": "__MISSING__:Plans & Quotas", @@ -8606,7 +8703,8 @@ "goNow": "__MISSING__:Go now", "message": "__MISSING__:The MITM Proxy now lives under AgentBridge.", "title": "__MISSING__:This page has moved" - } + }, + "certManualTitle": "Il certificato non può essere installato automaticamente (es. dentro un container). Il bridge può comunque funzionare — aggiungi la CA manualmente:" }, "trafficInspector": { "title": "__MISSING__:Traffic Inspector", @@ -8932,5 +9030,31 @@ "colAvgScore": "__MISSING__:Avg Score", "colModels": "__MISSING__:Models", "colType": "__MISSING__:Type" + }, + "disabled": "Disabilitato", + "discovery": { + "title": "Provider Discovery", + "subtitle": "Scansiona i provider per metodi di accesso gratuiti/illimitati e rivedi i risultati. Opt-in, solo locale.", + "scanLabel": "Provider da scansionare", + "scanPlaceholder": "es. huggingchat", + "scan": "Scansiona", + "scanning": "Scansione in corso…", + "scanQueued": "Scansione accodata per {provider}.", + "scanFailed": "Scansione fallita.", + "loadFailed": "Caricamento risultati discovery fallito.", + "localOnlyNote": "Questo strumento è solo locale (loopback). Le scansioni vengono eseguite da questa macchina e non sono mai raggiungibili da remoto.", + "verify": "Verifica", + "verifyFailed": "Verifica del risultato fallita.", + "delete": "Elimina", + "deleteFailed": "Eliminazione del risultato fallita.", + "deleteTitle": "Elimina risultato discovery", + "deleteConfirm": "Eliminare il risultato discovery per {provider}? L'operazione non può essere annullata.", + "emptyTitle": "Nessun risultato discovery", + "emptyDescription": "Esegui una scansione qui sopra per cercare metodi di accesso gratuiti su un provider.", + "risk": "Rischio", + "method": "Metodo", + "auth": "Auth", + "feasibility": "Fattibilità", + "models": "Modelli" } -} +} \ No newline at end of file From 58abebd1065c0dd3a9b6b943ebb8151ce8aba151 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:34:49 -0300 Subject: [PATCH 36/61] test(dashboard): realign #6145 onboarding-href guard to the #6166 helper refactor (#6270) Realign the #6145 onboarding-href guard to the #6166 helper refactor (buildProviderDetailsHref). Test-only; unblocks the fast-path unit job across the open PR queue. Base-reds only (dast-smoke #6228, docs version-drift, executor-kiro anys). Integrated into release/v3.8.45. --- .../onboarding-wizard-details-link-6145.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/unit/onboarding-wizard-details-link-6145.test.ts b/tests/unit/onboarding-wizard-details-link-6145.test.ts index 78ff6c67a7..52efbd241b 100644 --- a/tests/unit/onboarding-wizard-details-link-6145.test.ts +++ b/tests/unit/onboarding-wizard-details-link-6145.test.ts @@ -9,6 +9,13 @@ import { dirname, join } from "node:path"; // `/dashboard/providers/[id]` route expects), NOT `connection.provider` (the // provider slug/type). The old code produced `/dashboard/providers/` // which 404s for openai-compatible / anthropic-compatible providers. +// +// #6166 refactored the inline `href={`/dashboard/providers/${connection.id}`}` +// literal into the tested `buildProviderDetailsHref(connection)` helper (its +// id-based routing + null-safety is guarded behaviorally in +// `provider-onboarding-href.test.ts`). This guard now tracks that refactor: the +// wizard must delegate to the helper and must NOT reintroduce a raw +// `connection.provider` URL. const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, "..", ".."); @@ -20,11 +27,11 @@ const wizard = readFileSync( "utf8" ); -test("#6145: provider-details link routes by connection.id (matches the [id] route)", () => { +test("#6145: provider-details link routes through buildProviderDetailsHref (id-based helper)", () => { assert.match( wizard, - /href=\{`\/dashboard\/providers\/\$\{connection\.id\}`\}/, - "the details link must build the URL from connection.id" + /buildProviderDetailsHref\(connection\)/, + "the details link must be built by the tested buildProviderDetailsHref helper (routes by connection.id)" ); }); From 826a66f2878ac0b275365bbc0423107300334a46 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:37:17 -0300 Subject: [PATCH 37/61] feat(providers): add Yuanbao (web) cookie-session provider (#6196) (#6256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(providers): add Yuanbao (web) cookie-session provider (#6196). TDD-covered; base-reds only (dast-smoke #6228, docs version-drift, executor-kiro anys — #6145 guard fixed on tip via #6270). Integrated into release/v3.8.45. --- CHANGELOG.md | 4 + open-sse/config/providers/index.ts | 2 + .../providers/registry/yuanbao-web/index.ts | 37 ++ open-sse/executors/index.ts | 4 + open-sse/executors/yuanbao-web.ts | 504 ++++++++++++++++++ src/shared/constants/providers/web-cookie.ts | 15 + src/shared/providers/webSessionCredentials.ts | 7 + tests/snapshots/provider/translate-path.json | 23 + tests/unit/providers-yuanbao-web.test.ts | 201 +++++++ 9 files changed, 797 insertions(+) create mode 100644 open-sse/config/providers/registry/yuanbao-web/index.ts create mode 100644 open-sse/executors/yuanbao-web.ts create mode 100644 tests/unit/providers-yuanbao-web.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eeefce72b1..442e2a572f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### ✨ New Features + +- **feat(providers):** add **Yuanbao (web)** as a cookie-session provider ([#6196](https://github.com/diegosouzapw/OmniRoute/issues/6196)) — `yuanbao-web` (Tencent Yuanbao, `yuanbao.tencent.com`) with cookie-only auth (`hy_user`/`hy_token` + public agent id), SSE→OpenAI translation incl. `reasoning_content`, exposing DeepSeek V3/R1 + Hunyuan / Hunyuan-T1. Regression guard: `tests/unit/providers-yuanbao-web.test.ts`. `together-web` was **deferred** (no verifiable web-session endpoint — needs a captured request) and `huggingchat-web` **dropped** (the existing `huggingchat` already is a web-cookie provider). (thanks @chirag127) + ### 🐛 Bug Fixes - **chatcore (tools): stop the default 128-tool cap from silently dropping opencode's `task`/MCP tools.** opencode (used as an MCP/agent host) sends a large tool list; when it exceeds the speculative `MAX_TOOLS_LIMIT` (128) default, `truncateToolList` did a blind `tools.slice(0, 128)`, dropping every tool past index 128 — including opencode's built-in `task` tool (subagent launch) and many MCP tools, so models routed through OmniRoute could no longer spawn subagents or reach part of their tools. The cap exists to avoid upstream `400`s for providers with real hard limits (e.g. grok-cli 200), so it is kept for those: detection of the opencode client (`isOpencodeClient` — any `x-opencode-*` header, or `opencode` in the user-agent) now only bypasses the **speculative 128 default**, never a known provider ceiling. Precedence is explicit — a proactive/detected provider limit always truncates (even for opencode); otherwise opencode forwards its full tool list; otherwise the unchanged 128 default applies to every other client. Refactors `getEffectiveToolLimit` into `getKnownToolLimit(provider) ?? DEFAULT_LIMIT` (byte-identical for existing callers) and fixes a cosmetic debug-log that reported the truncated count instead of the original. Regression guard: `tests/unit/tool-limit-detector.test.ts`. diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index aa733053e6..f109a87171 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -134,6 +134,7 @@ import { agentrouterProvider } from "./registry/agentrouter/index.ts"; import { zaiProvider } from "./registry/zai/index.ts"; import { waferProvider } from "./registry/wafer/index.ts"; import { huggingchatProvider } from "./registry/huggingchat/index.ts"; +import { yuanbao_webProvider } from "./registry/yuanbao-web/index.ts"; import { galadrielProvider } from "./registry/galadriel/index.ts"; import { qianfanProvider } from "./registry/qianfan/index.ts"; import { meta_llamaProvider } from "./registry/meta-llama/index.ts"; @@ -314,6 +315,7 @@ export const REGISTRY: Record = { agentrouter: agentrouterProvider, zai: zaiProvider, huggingchat: huggingchatProvider, + "yuanbao-web": yuanbao_webProvider, galadriel: galadrielProvider, qianfan: qianfanProvider, "meta-llama": meta_llamaProvider, diff --git a/open-sse/config/providers/registry/yuanbao-web/index.ts b/open-sse/config/providers/registry/yuanbao-web/index.ts new file mode 100644 index 0000000000..c3dc4b1565 --- /dev/null +++ b/open-sse/config/providers/registry/yuanbao-web/index.ts @@ -0,0 +1,37 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const yuanbao_webProvider: RegistryEntry = { + id: "yuanbao-web", + alias: "ybw", + format: "openai", + executor: "yuanbao-web", + baseUrl: "https://yuanbao.tencent.com/api/chat", + authType: "apikey", + authHeader: "cookie", + models: [ + { id: "deepseek-v3", name: "DeepSeek V3 (via Yuanbao)", toolCalling: false }, + { + id: "deepseek-r1", + name: "DeepSeek R1 (via Yuanbao)", + supportsReasoning: true, + }, + { id: "hunyuan", name: "Hunyuan (via Yuanbao)" }, + { + id: "hunyuan-t1", + name: "Hunyuan T1 (via Yuanbao)", + supportsReasoning: true, + }, + { id: "deepseek-v3-search", name: "DeepSeek V3 + Web Search (via Yuanbao)" }, + { + id: "deepseek-r1-search", + name: "DeepSeek R1 + Web Search (via Yuanbao)", + supportsReasoning: true, + }, + { id: "hunyuan-search", name: "Hunyuan + Web Search (via Yuanbao)" }, + { + id: "hunyuan-t1-search", + name: "Hunyuan T1 + Web Search (via Yuanbao)", + supportsReasoning: true, + }, + ], +}; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 5984fde2ee..a3ab06cc6f 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -41,6 +41,7 @@ import { T3ChatWebExecutor } from "./t3-chat-web.ts"; import { ClaudeWebExecutor } from "./claude-web.ts"; import { InnerAiExecutor } from "./inner-ai.ts"; import { HuggingChatExecutor } from "./huggingchat.ts"; +import { YuanbaoWebExecutor } from "./yuanbao-web.ts"; import { PoeWebExecutor } from "./poe-web.ts"; import { VeniceWebExecutor } from "./venice-web.ts"; import { V0VercelWebExecutor } from "./v0-vercel-web.ts"; @@ -129,6 +130,8 @@ const executors = { "in-ai": new InnerAiExecutor(), // Alias huggingchat: new HuggingChatExecutor(), hc: new HuggingChatExecutor(), // Alias + "yuanbao-web": new YuanbaoWebExecutor(), + ybw: new YuanbaoWebExecutor(), // Alias "poe-web": new PoeWebExecutor(), poe: new PoeWebExecutor(), // Alias "venice-web": new VeniceWebExecutor(), @@ -212,6 +215,7 @@ export { ClaudeWebExecutor } from "./claude-web.ts"; export { DeepSeekWebExecutor } from "./deepseek-web.ts"; export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; export { AdaptaWebExecutor } from "./adapta-web.ts"; +export { YuanbaoWebExecutor } from "./yuanbao-web.ts"; export { T3ChatWebExecutor } from "./t3-chat-web.ts"; export { InnerAiExecutor } from "./inner-ai.ts"; export { QwenWebExecutor } from "./qwen-web.ts"; diff --git a/open-sse/executors/yuanbao-web.ts b/open-sse/executors/yuanbao-web.ts new file mode 100644 index 0000000000..20ef5662e0 --- /dev/null +++ b/open-sse/executors/yuanbao-web.ts @@ -0,0 +1,504 @@ +/** + * YuanbaoWebExecutor — Tencent Yuanbao (yuanbao.tencent.com) Web Provider + * + * Routes chat requests through the Tencent Yuanbao consumer web session. + * Requires the `hy_user` + `hy_token` cookies from a logged-in + * yuanbao.tencent.com browser session (paste the full Cookie header). + * + * API flow (verified against the reverse-engineered references below): + * 1. POST /api/user/agent/conversation/create { agentId } -> { id } (conversationId) + * 2. POST /api/chat/{conversationId} (JSON body) -> SSE stream + * + * Streaming format (SSE, `data: {json}` lines): + * - { type: "think", content: "..." } -- reasoning tokens (DeepSeek-R1 / Hunyuan-T1) + * - { type: "text", msg: "..." } -- answer tokens + * - { ..., stopReason: "..." } -- terminal marker + * + * References (endpoint/payload/session shape lifted + cross-checked): + * - juzeon/yuanbao-chat2api (Rust) — cookie-only auth: hy_user + hy_token + agentId + * - chenwr727/yuanbao-free-api (Python) — endpoints, body shape, model map + */ +import { + BaseExecutor, + mergeAbortSignals, + mergeUpstreamExtraHeaders, + type ExecuteInput, +} from "./base.ts"; +import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; +import { extractCookieValue, stripCookieInputPrefix } from "@/lib/providers/webCookieAuth"; + +const YUANBAO_BASE = "https://yuanbao.tencent.com"; +const CREATE_URL = `${YUANBAO_BASE}/api/user/agent/conversation/create`; +const CHAT_URL = `${YUANBAO_BASE}/api/chat`; + +// Public default DeepSeek agent id used by the Yuanbao web app. Not a secret — +// it is the shared consumer agent every logged-in session addresses by default. +const DEFAULT_AGENT_ID = "naQivTmsDa"; + +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"; + +const DEFAULT_MODEL = "deepseek-v3"; + +// OmniRoute model id -> Yuanbao internal chatModelId + optional supportFunctions. +const MODEL_MAP: Record = { + "deepseek-v3": { chatModelId: "deep_seek_v3" }, + "deepseek-r1": { chatModelId: "deep_seek" }, + "deepseek-v3-search": { + chatModelId: "deep_seek_v3", + supportFunctions: ["supportInternetSearch"], + }, + "deepseek-r1-search": { + chatModelId: "deep_seek", + supportFunctions: ["supportInternetSearch"], + }, + hunyuan: { chatModelId: "hunyuan_gpt_175B_0404" }, + "hunyuan-t1": { chatModelId: "hunyuan_t1" }, + "hunyuan-search": { + chatModelId: "hunyuan_gpt_175B_0404", + supportFunctions: ["supportInternetSearch"], + }, + "hunyuan-t1-search": { + chatModelId: "hunyuan_t1", + supportFunctions: ["supportInternetSearch"], + }, +}; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function isEncryptedCredentialBlob(value: unknown): boolean { + return typeof value === "string" && value.trim().startsWith("enc:v1:"); +} + +function extractText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return String(content ?? ""); + return content + .map((part: unknown) => { + if (!part || typeof part !== "object") return ""; + const item = part as Record; + if ((item.type === "text" || item.type === "input_text") && typeof item.text === "string") { + return item.text; + } + return ""; + }) + .filter((p: string) => p.length > 0) + .join("\n"); +} + +/** Flatten OpenAI messages into the single-prompt shape Yuanbao expects. */ +function buildPrompt(messages: Array>): string { + const parts: Array<{ role: string; content: string }> = []; + for (const msg of messages) { + const role = String(msg.role || "user"); + const text = extractText(msg.content).trim(); + if (!text) continue; + parts.push({ role, content: text }); + } + if (parts.length === 0) return ""; + if (parts.length === 1) return parts[0].content; + // Multi-turn: label each turn (matches the reference chat2api formatting). + return parts.map((p) => `#[${p.role.trim()}]\n${p.content}`).join("\n\n"); +} + +/** Build the `hy_source=web; hy_user=...; hy_token=...` cookie from the pasted header. */ +function buildYuanbaoCookie(rawApiKey: string): { cookie: string; hasToken: boolean } { + const raw = stripCookieInputPrefix(rawApiKey || ""); + const hyUser = extractCookieValue(raw, "hy_user"); + const hyToken = extractCookieValue(raw, "hy_token"); + + if (hyUser && hyToken) { + return { cookie: `hy_source=web; hy_user=${hyUser}; hy_token=${hyToken}`, hasToken: true }; + } + + // Fall back to forwarding whatever the user pasted (may already be a full + // Cookie header). Only usable if it plausibly carries the session token. + const hasToken = raw.includes("hy_token="); + return { cookie: raw, hasToken }; +} + +function estimateTokens(text: string): number { + return Math.max(1, Math.ceil((text || "").length / 4)); +} + +async function readUpstreamErrorDetails(response: Response): Promise<{ + message: string | null; + details: unknown; +}> { + const contentType = response.headers.get("content-type") || ""; + const text = await response.text().catch(() => ""); + if (!text) return { message: null, details: null }; + + if (contentType.includes("json")) { + try { + const parsed = JSON.parse(text) as Record; + const message = + typeof parsed.message === "string" + ? parsed.message + : typeof parsed.error === "string" + ? parsed.error + : null; + return { message: message ? sanitizeErrorMessage(message) : null, details: parsed }; + } catch { + // fall through + } + } + return { message: sanitizeErrorMessage(text), details: { body: text } }; +} + +// ── Executor ──────────────────────────────────────────────────────────────── + +export class YuanbaoWebExecutor extends BaseExecutor { + constructor() { + super("yuanbao-web", { id: "yuanbao-web", baseUrl: CHAT_URL }); + } + + private errorResponse(status: number, message: string, url: string, details?: unknown) { + return { + response: new Response(JSON.stringify(buildErrorBody(status, message, details)), { + status, + headers: { "Content-Type": "application/json" }, + }), + url, + headers: {}, + transformedBody: undefined, + }; + } + + async execute(input: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const { model, body, stream, credentials, signal, log, upstreamExtraHeaders } = input; + const messages = (body as Record).messages as + | Array> + | undefined; + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return this.errorResponse(400, "Missing or empty messages array", CHAT_URL); + } + + if (isEncryptedCredentialBlob(credentials.apiKey)) { + return this.errorResponse( + 401, + "Yuanbao credentials are encrypted but STORAGE_ENCRYPTION_KEY is not loaded. " + + "Restore the encryption key or re-save the Yuanbao cookie.", + CREATE_URL + ); + } + + const { cookie, hasToken } = buildYuanbaoCookie(credentials.apiKey || ""); + if (!hasToken) { + return this.errorResponse( + 401, + "Yuanbao requires a session cookie. Log in to yuanbao.tencent.com, open " + + "DevTools > Application > Cookies, and paste the full Cookie header " + + "(it must contain hy_user and hy_token).", + CREATE_URL + ); + } + + const resolvedModel = model && MODEL_MAP[model] ? model : DEFAULT_MODEL; + const modelSpec = MODEL_MAP[resolvedModel]; + const prompt = buildPrompt(messages); + if (!prompt.trim()) { + return this.errorResponse(400, "Empty prompt after processing messages", CHAT_URL); + } + + const baseHeaders: Record = { + Cookie: cookie, + "User-Agent": USER_AGENT, + Origin: YUANBAO_BASE, + Referer: `${YUANBAO_BASE}/chat/${DEFAULT_AGENT_ID}`, + "X-Agentid": DEFAULT_AGENT_ID, + }; + + const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); + const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; + + // ── Step 1: create conversation ───────────────────────────────────────── + let conversationId: string; + try { + const createRes = await fetch(CREATE_URL, { + method: "POST", + headers: { ...baseHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: DEFAULT_AGENT_ID }), + signal: combinedSignal, + }); + + if (!createRes.ok) { + const status = createRes.status; + const upstreamError = await readUpstreamErrorDetails(createRes); + let message = `Yuanbao conversation creation failed (HTTP ${status})`; + if (status === 401 || status === 403) { + message = + "Yuanbao auth failed — your hy_user/hy_token cookies may be missing or expired. " + + "Log in to yuanbao.tencent.com and re-paste your Cookie header."; + } else if (status === 429) { + message = "Yuanbao rate limited. Wait a moment and retry."; + } + if (upstreamError.message) message = `${message}: ${upstreamError.message}`; + return this.errorResponse(status, message, CREATE_URL, upstreamError.details); + } + + const createData = (await createRes.json()) as Record; + conversationId = String(createData.id || ""); + if (!conversationId) { + return this.errorResponse( + 502, + "Yuanbao did not return a conversation id", + CREATE_URL + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log?.error?.("YUANBAO-WEB", `Conversation creation failed: ${message}`); + return this.errorResponse( + 502, + `Yuanbao connection failed: ${sanitizeErrorMessage(message)}`, + CREATE_URL + ); + } + + // ── Step 2: send message ──────────────────────────────────────────────── + const messageUrl = `${CHAT_URL}/${conversationId}`; + const chatBody: Record = { + model: "gpt_175B_0404", + prompt, + plugin: "Adaptive", + displayPrompt: prompt, + displayPromptType: 1, + options: { + imageIntention: { + needIntentionModel: true, + backendUpdateFlag: 2, + intentionStatus: true, + }, + }, + multimedia: [], + agentId: DEFAULT_AGENT_ID, + supportHint: 1, + version: "v2", + chatModelId: modelSpec.chatModelId, + }; + if (modelSpec.supportFunctions) chatBody.supportFunctions = modelSpec.supportFunctions; + + const chatHeaders: Record = { + ...baseHeaders, + "Content-Type": "application/json", + Accept: "text/event-stream", + }; + mergeUpstreamExtraHeaders(chatHeaders, upstreamExtraHeaders); + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(messageUrl, { + method: "POST", + headers: chatHeaders, + body: JSON.stringify(chatBody), + signal: combinedSignal, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log?.error?.("YUANBAO-WEB", `Message send failed: ${message}`); + return this.errorResponse( + 502, + `Yuanbao connection failed: ${sanitizeErrorMessage(message)}`, + messageUrl + ); + } + + if (!upstreamResponse.ok) { + const status = upstreamResponse.status; + const upstreamError = await readUpstreamErrorDetails(upstreamResponse); + let message = `Yuanbao returned HTTP ${status}`; + if (status === 401 || status === 403) { + message = "Yuanbao auth failed — session cookie may be expired."; + } else if (status === 429) { + message = "Yuanbao rate limited. Wait a moment and retry."; + } + if (upstreamError.message) message = `${message}: ${upstreamError.message}`; + return this.errorResponse(status, message, messageUrl, upstreamError.details); + } + + if (!upstreamResponse.body) { + return this.errorResponse(502, "Yuanbao returned empty response body", messageUrl); + } + + // ── Step 3: translate SSE → OpenAI ────────────────────────────────────── + const id = `chatcmpl-yuanbao-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + if (stream) { + return { + response: new Response( + transformYuanbaoStream(upstreamResponse.body, resolvedModel, id, created, signal, log), + { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + } + ), + url: messageUrl, + headers: chatHeaders, + transformedBody: chatBody, + }; + } + + const { content, reasoning } = await collectYuanbaoResponse(upstreamResponse.body, signal); + const completionTokens = estimateTokens(content + reasoning); + const messagePayload: Record = { role: "assistant", content }; + if (reasoning) messagePayload.reasoning_content = reasoning; + + return { + response: new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model: resolvedModel, + choices: [{ index: 0, message: messagePayload, finish_reason: "stop" }], + usage: { + prompt_tokens: estimateTokens(prompt), + completion_tokens: completionTokens, + total_tokens: estimateTokens(prompt) + completionTokens, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + url: messageUrl, + headers: chatHeaders, + transformedBody: chatBody, + }; + } +} + +// ── SSE translation helpers ─────────────────────────────────────────────────── + +interface YuanbaoEvent { + type?: string; + content?: string; + msg?: string; + stopReason?: string; +} + +function parseYuanbaoDataLine(line: string): YuanbaoEvent | null { + if (!line.startsWith("data: ")) return null; + const payload = line.slice(6).trim(); + if (!payload || payload === "[DONE]" || !payload.startsWith("{")) return null; + try { + return JSON.parse(payload) as YuanbaoEvent; + } catch { + return null; + } +} + +function transformYuanbaoStream( + upstream: ReadableStream, + model: string, + id: string, + created: number, + signal: AbortSignal | null | undefined, + log?: ExecuteInput["log"] +): ReadableStream { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + let roleEmitted = false; + + return new ReadableStream({ + async start(controller) { + const reader = upstream.getReader(); + let buffer = ""; + + const emit = (delta: object, finish?: string | null) => { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finish ?? null }], + })}\n\n` + ) + ); + }; + + const ensureRole = () => { + if (!roleEmitted) { + roleEmitted = true; + emit({ role: "assistant", content: "" }); + } + }; + + try { + while (true) { + if (signal?.aborted) break; + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const event = parseYuanbaoDataLine(line); + if (!event) continue; + if (event.type === "think" && event.content) { + ensureRole(); + emit({ reasoning_content: event.content }); + } else if (event.type === "text" && typeof event.msg === "string" && event.msg) { + ensureRole(); + emit({ content: event.msg }); + } + } + } + } catch (err) { + log?.error?.("YUANBAO-WEB", `Stream error: ${err}`); + } finally { + ensureRole(); + emit({}, "stop"); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + reader.releaseLock(); + } + }, + }); +} + +async function collectYuanbaoResponse( + upstream: ReadableStream, + signal: AbortSignal | null | undefined +): Promise<{ content: string; reasoning: string }> { + const decoder = new TextDecoder(); + const reader = upstream.getReader(); + let buffer = ""; + let content = ""; + let reasoning = ""; + + try { + while (true) { + if (signal?.aborted) break; + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const event = parseYuanbaoDataLine(line); + if (!event) continue; + if (event.type === "think" && event.content) reasoning += event.content; + else if (event.type === "text" && typeof event.msg === "string") content += event.msg; + } + } + } finally { + reader.releaseLock(); + } + + return { content, reasoning }; +} diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index c8000e6e30..39e0bbc053 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -185,6 +185,21 @@ export const WEB_COOKIE_PROVIDERS = { "Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons.", riskNoticeVariant: "webCookie", }, + "yuanbao-web": { + id: "yuanbao-web", + alias: "ybw", + name: "Tencent Yuanbao (Free)", + icon: "auto_awesome", + color: "#0052D9", + textIcon: "YB", + website: "https://yuanbao.tencent.com", + hasFree: true, + freeNote: + "Free consumer web session — DeepSeek V3/R1 and Hunyuan / Hunyuan-T1, optional web search. No subscription required. Rate limits apply.", + authHint: + "Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token.", + riskNoticeVariant: "webCookie", + }, huggingchat: { id: "huggingchat", // "hc" belongs to the hackclub provider; huggingchat uses its own id as alias. diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 1a12284562..77d3d3ca5e 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -141,6 +141,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "hf-chat"], }, + "yuanbao-web": { + kind: "cookie", + credentialName: "full Cookie header (hy_user + hy_token)", + placeholder: "hy_user=...; hy_token=... (full Cookie header from yuanbao.tencent.com)", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "hy_user", "hy_token"], + }, "poe-web": { kind: "cookie", credentialName: "p-b", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index f2f0f03029..428fe1abef 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -4402,6 +4402,29 @@ "stream": "https://api.lingyiwanwu.com/v1/chat/completions" } }, + "yuanbao-web": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://yuanbao.tencent.com/api/chat", + "stream": "https://yuanbao.tencent.com/api/chat" + } + }, "zai": { "format": "claude", "headers": { diff --git a/tests/unit/providers-yuanbao-web.test.ts b/tests/unit/providers-yuanbao-web.test.ts new file mode 100644 index 0000000000..2feab63371 --- /dev/null +++ b/tests/unit/providers-yuanbao-web.test.ts @@ -0,0 +1,201 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { ExecuteInput } from "../../open-sse/executors/base.ts"; + +const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); +const providers = await import("../../src/shared/constants/providers.ts"); +const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); +const { YuanbaoWebExecutor } = await import("../../open-sse/executors/yuanbao-web.ts"); + +type Dict = Record; +const registry = REGISTRY as unknown as Record; +const catalog = providers.WEB_COOKIE_PROVIDERS as unknown as Record; +const creds = (apiKey: string) => ({ apiKey }) as unknown as ExecuteInput["credentials"]; + +// ── Registry wiring ─────────────────────────────────────────────────────────── + +test("yuanbao-web is registered as a cookie-auth provider in the registry", () => { + const entry = registry["yuanbao-web"]; + assert.ok(entry, "yuanbao-web missing from REGISTRY"); + assert.equal(entry.id, "yuanbao-web"); + assert.equal(entry.alias, "ybw"); + assert.equal(entry.executor, "yuanbao-web"); + assert.equal(entry.format, "openai"); + assert.equal(entry.authHeader, "cookie"); + assert.equal(entry.baseUrl, "https://yuanbao.tencent.com/api/chat"); + const models = entry.models as Array<{ id: string }>; + assert.ok(Array.isArray(models) && models.length > 0); + const ids = models.map((m) => m.id); + assert.ok(ids.includes("deepseek-v3")); + assert.ok(ids.includes("hunyuan-t1")); +}); + +test("yuanbao-web appears in the web-cookie catalog with a cookie authHint", () => { + const entry = catalog["yuanbao-web"]; + assert.ok(entry, "yuanbao-web missing from WEB_COOKIE_PROVIDERS"); + assert.equal(entry.id, "yuanbao-web"); + assert.equal(entry.riskNoticeVariant, "webCookie"); + assert.match(String(entry.authHint), /hy_token/); + assert.match(String(entry.website), /yuanbao\.tencent\.com/); +}); + +test("YuanbaoWebExecutor is wired under id and alias", () => { + assert.ok(hasSpecializedExecutor("yuanbao-web")); + assert.ok(hasSpecializedExecutor("ybw")); + assert.ok(getExecutor("yuanbao-web") instanceof YuanbaoWebExecutor); + assert.ok(getExecutor("ybw") instanceof YuanbaoWebExecutor); +}); + +// ── Behavioral: SSE → OpenAI translation (mocked upstream) ───────────────────── + +function makeSSEBody(lines: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const line of lines) controller.enqueue(encoder.encode(line)); + controller.close(); + }, + }); +} + +async function readStreamText(res: Response): Promise { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let out = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + out += decoder.decode(value, { stream: true }); + } + return out; +} + +test("missing hy_token cookie returns a 401 auth error", async () => { + const exec = new YuanbaoWebExecutor(); + const { response } = await exec.execute({ + model: "deepseek-v3", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: creds("some_unrelated_cookie=abc"), + signal: null, + }); + assert.equal(response.status, 401); + const body = (await response.json()) as { error: { message: string } }; + assert.match(body.error.message, /hy_user|hy_token|session cookie/); + // Never leak stack traces. + assert.ok(!body.error.message.includes("at /")); +}); + +test("streaming request translates think/text events into OpenAI chunks", async () => { + const original = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + calls.push(String(url)); + if (String(url).includes("/conversation/create")) { + return new Response(JSON.stringify({ id: "conv-123" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response( + makeSSEBody([ + 'data: {"type":"think","content":"reasoning..."}\n', + 'data: {"type":"text","msg":"Hello"}\n', + 'data: {"type":"text","msg":" world"}\n', + 'data: {"stopReason":"stop"}\n', + ]), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + }) as typeof fetch; + + try { + const exec = new YuanbaoWebExecutor(); + const { response, url } = await exec.execute({ + model: "deepseek-r1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: creds("hy_source=web; hy_user=u1; hy_token=t1"), + signal: null, + }); + assert.equal(response.status, 200); + assert.match(url, /\/api\/chat\/conv-123$/); + assert.ok(calls[0].includes("/conversation/create")); + + const text = await readStreamText(response); + assert.match(text, /"reasoning_content":"reasoning\.\.\."/); + assert.match(text, /"content":"Hello"/); + assert.match(text, /"content":" world"/); + assert.match(text, /"finish_reason":"stop"/); + assert.match(text, /data: \[DONE\]/); + } finally { + globalThis.fetch = original; + } +}); + +test("non-streaming request collects content and reasoning", async () => { + const original = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).includes("/conversation/create")) { + return new Response(JSON.stringify({ id: "conv-9" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response( + makeSSEBody([ + 'data: {"type":"think","content":"think-part"}\n', + 'data: {"type":"text","msg":"Answer"}\n', + 'data: {"stopReason":"stop"}\n', + ]), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + }) as typeof fetch; + + try { + const exec = new YuanbaoWebExecutor(); + const { response } = await exec.execute({ + model: "hunyuan-t1", + body: { messages: [{ role: "user", content: "q" }] }, + stream: false, + credentials: creds("hy_source=web; hy_user=u1; hy_token=t1"), + signal: null, + }); + assert.equal(response.status, 200); + const body = (await response.json()) as { + object: string; + model: string; + choices: Array<{ message: { content: string; reasoning_content?: string } }>; + }; + assert.equal(body.object, "chat.completion"); + assert.equal(body.choices[0].message.content, "Answer"); + assert.equal(body.choices[0].message.reasoning_content, "think-part"); + assert.equal(body.model, "hunyuan-t1"); + } finally { + globalThis.fetch = original; + } +}); + +test("upstream 401 on conversation create surfaces an auth error (no stack leak)", async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ message: "unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch; + + try { + const exec = new YuanbaoWebExecutor(); + const { response } = await exec.execute({ + model: "deepseek-v3", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: creds("hy_source=web; hy_user=u1; hy_token=t1"), + signal: null, + }); + assert.equal(response.status, 401); + const body = (await response.json()) as { error: { message: string } }; + assert.ok(!body.error.message.includes("at /")); + } finally { + globalThis.fetch = original; + } +}); From 5531fc7f0589b87906c3fac6edc371d2c18adb44 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:41:15 -0300 Subject: [PATCH 38/61] feat(providers): route built-in agentrouter through dynamic CC wire image (#6056) (#6255) feat(providers): route built-in agentrouter through dynamic CC wire image (#6056). TDD-covered (agentrouter-cc-wire-image.test.ts). Base-reds only. Integrated into release/v3.8.45. --- CHANGELOG.md | 1 + .../providers/registry/agentrouter/index.ts | 16 +--- open-sse/services/ccWireImageBuiltins.ts | 26 ++++++ open-sse/services/claudeCodeCompatible.ts | 8 +- open-sse/services/provider.ts | 34 ++++++- tests/snapshots/provider/translate-path.json | 53 +++++------ tests/unit/agentrouter-cc-wire-image.test.ts | 91 +++++++++++++++++++ 7 files changed, 189 insertions(+), 40 deletions(-) create mode 100644 open-sse/services/ccWireImageBuiltins.ts create mode 100644 tests/unit/agentrouter-cc-wire-image.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 442e2a572f..d67ed16ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### ✨ New Features - **feat(providers):** add **Yuanbao (web)** as a cookie-session provider ([#6196](https://github.com/diegosouzapw/OmniRoute/issues/6196)) — `yuanbao-web` (Tencent Yuanbao, `yuanbao.tencent.com`) with cookie-only auth (`hy_user`/`hy_token` + public agent id), SSE→OpenAI translation incl. `reasoning_content`, exposing DeepSeek V3/R1 + Hunyuan / Hunyuan-T1. Regression guard: `tests/unit/providers-yuanbao-web.test.ts`. `together-web` was **deferred** (no verifiable web-session endpoint — needs a captured request) and `huggingchat-web` **dropped** (the existing `huggingchat` already is a web-cookie provider). (thanks @chirag127) +- **feat(providers):** route the built-in **agentrouter** through the dynamic Claude-Code wire image ([#6056](https://github.com/diegosouzapw/OmniRoute/issues/6056)) — a small static allow-set (`CC_WIRE_IMAGE_BUILTINS` in `open-sse/services/ccWireImageBuiltins.ts`), consulted by `isClaudeCodeCompatible` / `isClaudeCodeCompatibleProvider` / `applyFingerprint`, makes agentrouter adopt the CC wire-image headers + fingerprint **while guarding the CC baseUrl/auth branches** so it keeps its own registry `baseUrl` and `x-api-key` auth. Regression guard: `tests/unit/agentrouter-cc-wire-image.test.ts` (asserts the wire image is applied AND agentrouter's baseUrl/auth are preserved). Live WAF-acceptance against agentrouter.org is a VPS validation follow-up (Hard Rule #18). ### 🐛 Bug Fixes diff --git a/open-sse/config/providers/registry/agentrouter/index.ts b/open-sse/config/providers/registry/agentrouter/index.ts index a4156d76ca..ebe9d4598f 100644 --- a/open-sse/config/providers/registry/agentrouter/index.ts +++ b/open-sse/config/providers/registry/agentrouter/index.ts @@ -1,14 +1,4 @@ import type { RegistryEntry } from "../../shared.ts"; -import { - getClaudeCliHeaders, - mapStainlessOs, - mapStainlessArch, - ANTHROPIC_BETA_CLAUDE_OAUTH, - ANTHROPIC_VERSION_HEADER, - CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, - CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, - CLAUDE_CLI_USER_AGENT, -} from "../../shared.ts"; export const agentrouterProvider: RegistryEntry = { id: "agentrouter", @@ -19,7 +9,11 @@ export const agentrouterProvider: RegistryEntry = { authType: "apikey", authHeader: "x-api-key", defaultContextLength: 128000, - headers: getClaudeCliHeaders(), + // No static `headers` here: agentrouter now adopts the DYNAMIC Claude-Code + // wire image via CC_WIRE_IMAGE_BUILTINS (#6056) — the fingerprint/headers are + // applied by buildProviderHeaders + applyFingerprint, keeping this entry's + // own baseUrl + x-api-key auth. A static fingerprint here would drift and + // trip AgentRouter's WAF ("unauthorized client detected"). models: [ { id: "claude-opus-4-6", name: "Claude 4.6 Opus" }, { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, diff --git a/open-sse/services/ccWireImageBuiltins.ts b/open-sse/services/ccWireImageBuiltins.ts new file mode 100644 index 0000000000..870e4f78f1 --- /dev/null +++ b/open-sse/services/ccWireImageBuiltins.ts @@ -0,0 +1,26 @@ +/** + * Built-in provider ids that must adopt the dynamic Claude-Code wire image + * (fingerprint headers/order + system transforms + `?beta=true` chat path) + * WITHOUT inheriting the Claude-Code-Compatible family's default anthropic + * baseUrl / Bearer auth. + * + * These providers keep their own registry `baseUrl` and auth scheme + * (e.g. `agentrouter` → `https://agentrouter.org/v1/messages` + `x-api-key`), + * while the two CC predicates (`isClaudeCodeCompatible` / + * `isClaudeCodeCompatibleProvider`) and `applyFingerprint` treat them as CC + * for the wire-image concerns only. The CC-baseUrl / CC-Bearer branches in + * `buildProviderUrl` / `buildProviderHeaders` are guarded so the registry + * baseUrl + auth are preserved. + * + * Single source of truth — imported by both predicates so they never diverge. + * See issue #6056. + */ +export const CC_WIRE_IMAGE_BUILTINS: ReadonlySet = new Set(["agentrouter"]); + +/** + * True when `provider` is a built-in that adopts the dynamic Claude-Code wire + * image while keeping its own registry baseUrl + auth. + */ +export function usesCcWireImage(provider: unknown): boolean { + return typeof provider === "string" && CC_WIRE_IMAGE_BUILTINS.has(provider); +} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 4fd5f22711..eff67c4203 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -15,6 +15,7 @@ import { import { applyClaudeCodeCompatibleThinkingDisplay } from "./claudeCodeCompatibleThinkingDisplay.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; import { applySystemTransformPipeline, PROVIDER_CC_BRIDGE } from "./systemTransforms.ts"; +import { usesCcWireImage } from "./ccWireImageBuiltins.ts"; import { fixToolPairs, fixToolAdjacency, @@ -95,7 +96,12 @@ function supportsClaudeXHighEffort(model: string | null | undefined): boolean { } export function isClaudeCodeCompatibleProvider(provider: string | null | undefined): boolean { - return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); + return ( + (typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX)) || + // Built-in providers (e.g. agentrouter) that adopt the dynamic CC wire image + // while keeping their own registry baseUrl + auth (#6056). + usesCcWireImage(provider) + ); } export function stripAnthropicMessagesSuffix(baseUrl: string | null | undefined): string { diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 9149c7e70b..9ba0a0acff 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -8,6 +8,7 @@ import { } from "./claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; +import { usesCcWireImage } from "./ccWireImageBuiltins.ts"; const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; const OPENAI_COMPATIBLE_DEFAULTS = { @@ -29,7 +30,12 @@ function isAnthropicCompatible(provider) { } export function isClaudeCodeCompatible(provider) { - return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); + return ( + (typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX)) || + // Built-in providers (e.g. agentrouter) that adopt the dynamic CC wire image + // while keeping their own registry baseUrl + auth (#6056). + usesCcWireImage(provider) + ); } export function getOpenAICompatibleType( @@ -256,6 +262,15 @@ export function buildProviderUrl( providerSpecificData?: Record | null; } = {} ) { + // Built-in CC-wire-image providers (e.g. agentrouter): keep the registry's + // OWN baseUrl (NOT the CC family's anthropic default) but adopt the CC chat + // path so the request still targets `?beta=true` (#6056). + if (usesCcWireImage(provider)) { + const entry = getRegistryEntry(provider); + const config = getProviderConfig(provider); + const baseUrl = options?.baseUrl || entry?.baseUrl || config.baseUrl; + return joinClaudeCodeCompatibleUrl(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH); + } if (isOpenAICompatible(provider)) { const providerSpecificData = options?.providerSpecificData || null; const apiType = getOpenAICompatibleType(provider, providerSpecificData); @@ -318,12 +333,27 @@ export function buildProviderHeaders(provider, credentials, stream = true, body const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults( credentials?.providerSpecificData ); - return buildClaudeCodeCompatibleHeaders( + const ccHeaders = buildClaudeCodeCompatibleHeaders( token, stream, credentials?.providerSpecificData?.ccSessionId, { redactThinking: ccRequestDefaults.redactThinking === true } ); + // Built-in CC-wire-image providers (e.g. agentrouter): adopt the CC wire + // image headers but keep the registry's OWN auth scheme (e.g. x-api-key) + // instead of the CC family's Bearer auth (#6056). + if (usesCcWireImage(provider)) { + delete ccHeaders["Authorization"]; + const authHeader = entry?.authHeader || "bearer"; + if (authHeader === "x-api-key") { + if (token) ccHeaders["x-api-key"] = token; + } else if (authHeader === "key") { + if (token) ccHeaders["Authorization"] = `Key ${token}`; + } else { + ccHeaders["Authorization"] = `Bearer ${token}`; + } + } + return ccHeaders; } if (isAnthropicCompatible(provider)) { if (credentials.apiKey) { diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 428fe1abef..d760b0f7ac 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -27,64 +27,65 @@ "headers": { "apiKey": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07", - "Anthropic-Dangerous-Direct-Browser-Access": "true", - "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.195 (external, cli)", - "X-App": "cli", + "User-Agent": "claude-cli/2.1.195 (external, sdk-cli)", "X-Stainless-Arch": "", - "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", - "X-Stainless-Os": "", + "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Timeout": "600", - "x-api-key": "" + "accept-encoding": "gzip, deflate, br, zstd", + "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-version": "2023-06-01", + "x-api-key": "", + "x-app": "cli" }, "nonStream": { - "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07", - "Anthropic-Dangerous-Direct-Browser-Access": "true", - "Anthropic-Version": "2023-06-01", + "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.195 (external, cli)", - "X-App": "cli", + "User-Agent": "claude-cli/2.1.195 (external, sdk-cli)", "X-Stainless-Arch": "", - "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", - "X-Stainless-Os": "", + "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Timeout": "600", - "x-api-key": "" + "accept-encoding": "gzip, deflate, br, zstd", + "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-version": "2023-06-01", + "x-api-key": "", + "x-app": "cli" }, "oauth": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07", - "Anthropic-Dangerous-Direct-Browser-Access": "true", - "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.195 (external, cli)", - "X-App": "cli", + "User-Agent": "claude-cli/2.1.195 (external, sdk-cli)", "X-Stainless-Arch": "", - "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", - "X-Stainless-Os": "", + "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Timeout": "600", - "x-api-key": "" + "accept-encoding": "gzip, deflate, br, zstd", + "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-version": "2023-06-01", + "x-api-key": "", + "x-app": "cli" } }, "url": { - "nonStream": "https://agentrouter.org/v1/messages", - "stream": "https://agentrouter.org/v1/messages" + "nonStream": "https://agentrouter.org/v1/messages?beta=true", + "stream": "https://agentrouter.org/v1/messages?beta=true" } }, "agy": { diff --git a/tests/unit/agentrouter-cc-wire-image.test.ts b/tests/unit/agentrouter-cc-wire-image.test.ts new file mode 100644 index 0000000000..1cc334990d --- /dev/null +++ b/tests/unit/agentrouter-cc-wire-image.test.ts @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isClaudeCodeCompatible, + buildProviderUrl, + buildProviderHeaders, +} from "../../open-sse/services/provider.ts"; +import { isClaudeCodeCompatibleProvider } from "../../open-sse/services/claudeCodeCompatible.ts"; +import { + CC_WIRE_IMAGE_BUILTINS, + usesCcWireImage, +} from "../../open-sse/services/ccWireImageBuiltins.ts"; +import { CLAUDE_CODE_COMPATIBLE_USER_AGENT } from "../../open-sse/services/claudeCodeCompatible.ts"; +import { CLAUDE_CLI_USER_AGENT } from "../../open-sse/config/anthropicHeaders.ts"; +import { applyFingerprint } from "../../open-sse/config/cliFingerprints.ts"; + +// Regression guard for #6056 — the built-in `agentrouter` provider must route +// through the DYNAMIC Claude-Code wire image (fingerprint headers + `?beta=true` +// chat path) while KEEPING its own registry baseUrl + x-api-key auth. + +test("agentrouter is registered in the CC-wire-image built-in allow-set", () => { + assert.ok(CC_WIRE_IMAGE_BUILTINS.has("agentrouter")); + assert.equal(usesCcWireImage("agentrouter"), true); + assert.equal(usesCcWireImage("claude"), false); + assert.equal(usesCcWireImage(null), false); +}); + +test("(a) both CC predicates return true for agentrouter", () => { + assert.equal(isClaudeCodeCompatible("agentrouter"), true); + assert.equal(isClaudeCodeCompatibleProvider("agentrouter"), true); +}); + +test("(a) predicates are unaffected for non-allow-set providers", () => { + // Official Claude OAuth provider must NOT be treated as CC-compatible. + assert.equal(isClaudeCodeCompatible("claude"), false); + assert.equal(isClaudeCodeCompatibleProvider("claude"), false); + // Genuine CC-family providers still match via the prefix. + assert.equal(isClaudeCodeCompatible("anthropic-compatible-cc-foo"), true); + assert.equal(isClaudeCodeCompatibleProvider("anthropic-compatible-cc-foo"), true); +}); + +test("(b) agentrouter outbound headers carry the dynamic CC wire image", () => { + const headers = buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true); + + // CC wire image markers (not the static getClaudeCliHeaders() shape). + assert.equal(headers["User-Agent"], CLAUDE_CODE_COMPATIBLE_USER_AGENT); + assert.notEqual(headers["User-Agent"], CLAUDE_CLI_USER_AGENT); + assert.equal(headers["x-app"], "cli"); + assert.equal(headers["anthropic-dangerous-direct-browser-access"], "true"); + assert.ok(headers["anthropic-beta"], "expected the CC anthropic-beta header"); + assert.ok(headers["X-Stainless-Package-Version"], "expected CC X-Stainless anchors"); +}); + +test("(b) applyFingerprint selects the claude-code-compatible fingerprint for agentrouter", () => { + const { headers } = applyFingerprint( + "agentrouter", + buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true), + { model: "claude-opus-4-6", messages: [] } + ); + // Fingerprint reordering keeps the CC wire image + the preserved x-api-key auth. + assert.equal(headers["x-api-key"], "sk-agentrouter"); + assert.equal(headers["User-Agent"], CLAUDE_CODE_COMPATIBLE_USER_AGENT); +}); + +test("(c) CRUX: agentrouter keeps its OWN x-api-key auth (NOT CC Bearer)", () => { + const headers = buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true); + assert.equal(headers["x-api-key"], "sk-agentrouter"); + assert.equal(headers["Authorization"], undefined); +}); + +test("(c) CRUX: agentrouter keeps its OWN registry baseUrl + ?beta=true", () => { + const url = buildProviderUrl("agentrouter", "claude-opus-4-6", true); + assert.equal(url, "https://agentrouter.org/v1/messages?beta=true"); + // NOT the CC-family anthropic default baseUrl. + assert.ok(!url.includes("api.anthropic.com")); +}); + +test("(c) real CC-family provider still uses the CC default baseUrl + Bearer auth", () => { + // The wire-image guard must NOT leak into genuine anthropic-compatible-cc-* providers. + const headers = buildProviderHeaders( + "anthropic-compatible-cc-foo", + { apiKey: "sk-foo" }, + true + ); + assert.equal(headers["Authorization"], "Bearer sk-foo"); + assert.equal(headers["x-api-key"], undefined); + + const url = buildProviderUrl("anthropic-compatible-cc-foo", "claude-sonnet-4-6", true); + assert.ok(url.includes("api.anthropic.com")); +}); From 776a7a3a587aebf4e6bd497c78ab0f5328cd6edf Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:42:29 -0300 Subject: [PATCH 39/61] feat(providers): bulk-add API keys for Cloudflare Workers AI (#6174) (#6254) feat(providers): bulk-add API keys for Cloudflare Workers AI (#6174). Per-entry providerSpecificData (fixes shared-object reuse); TDD guard bulk-api-key-parser-cloudflare.test.ts. Base-reds only. Integrated into release/v3.8.45. (thanks @muflifadla38) --- CHANGELOG.md | 1 + .../[id]/components/modals/AddApiKeyModal.tsx | 18 +- src/app/api/providers/bulk/route.ts | 13 +- src/i18n/messages/en.json | 3 +- src/shared/constants/providers.ts | 1 - src/shared/utils/bulkApiKeyParser.ts | 48 ++++- src/shared/validation/schemas/provider.ts | 15 ++ .../bulk-api-key-parser-cloudflare.test.ts | 176 ++++++++++++++++++ tests/unit/providers-bulk-route.test.ts | 5 +- 9 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 tests/unit/bulk-api-key-parser-cloudflare.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d67ed16ee6..21bc85eae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **feat(providers):** add **Yuanbao (web)** as a cookie-session provider ([#6196](https://github.com/diegosouzapw/OmniRoute/issues/6196)) — `yuanbao-web` (Tencent Yuanbao, `yuanbao.tencent.com`) with cookie-only auth (`hy_user`/`hy_token` + public agent id), SSE→OpenAI translation incl. `reasoning_content`, exposing DeepSeek V3/R1 + Hunyuan / Hunyuan-T1. Regression guard: `tests/unit/providers-yuanbao-web.test.ts`. `together-web` was **deferred** (no verifiable web-session endpoint — needs a captured request) and `huggingchat-web` **dropped** (the existing `huggingchat` already is a web-cookie provider). (thanks @chirag127) - **feat(providers):** route the built-in **agentrouter** through the dynamic Claude-Code wire image ([#6056](https://github.com/diegosouzapw/OmniRoute/issues/6056)) — a small static allow-set (`CC_WIRE_IMAGE_BUILTINS` in `open-sse/services/ccWireImageBuiltins.ts`), consulted by `isClaudeCodeCompatible` / `isClaudeCodeCompatibleProvider` / `applyFingerprint`, makes agentrouter adopt the CC wire-image headers + fingerprint **while guarding the CC baseUrl/auth branches** so it keeps its own registry `baseUrl` and `x-api-key` auth. Regression guard: `tests/unit/agentrouter-cc-wire-image.test.ts` (asserts the wire image is applied AND agentrouter's baseUrl/auth are preserved). Live WAF-acceptance against agentrouter.org is a VPS validation follow-up (Hard Rule #18). +- **feat(providers):** **bulk-add API keys for Cloudflare Workers AI** ([#6174](https://github.com/diegosouzapw/OmniRoute/issues/6174)) — `cloudflare-ai` is removed from the bulk-add exclusion list and the bulk parser gains a 3-field `name|accountId|apiKey` mode; the bulk route now builds a **per-entry** `providerSpecificData` so each key carries its own `accountId` (fixing the previous shared-object reuse), and both the create + key-validation paths receive it. Regression guard: `tests/unit/bulk-api-key-parser-cloudflare.test.ts`. (thanks @muflifadla38) ### 🐛 Bug Fixes diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index fd112a3b0c..ef0c741b89 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -348,7 +348,7 @@ export default function AddApiKeyModal({ const handleBulkSubmit = async () => { if (!provider) return; - const parsed = parseBulkApiKeys(bulkText); + const parsed = parseBulkApiKeys(bulkText, { withAccountId: isCloudflare }); setBulkWarnings(parsed.warnings); if (parsed.entries.length === 0) return; @@ -378,7 +378,11 @@ export default function AddApiKeyModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, - entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })), + entries: parsed.entries.map((e) => ({ + name: e.name, + apiKey: e.apiKey, + ...(e.accountId ? { accountId: e.accountId } : {}), + })), priority: formData.priority || 1, providerSpecificData, validateKeys: bulkValidateKeys, @@ -457,12 +461,18 @@ export default function AddApiKeyModal({ {bulkSupported && mode === "bulk" && (
-

{t("bulkAddFormatHint")}

+

+ {isCloudflare ? t("bulkAddFormatHintCloudflare") : t("bulkAddFormatHint")} +

{openRouterPreset.input} {freeModelsToggle}
{t("colAvgScore")} {t("colModels")} {t("colType")}{t("colConfigured")}
{idx + 1} @@ -229,6 +278,17 @@ export default function FreeProviderRankingsPage() { {provider.category.toUpperCase()} + {configuredProviderIds.has(provider.id) ? ( + + ✓ + + ) : ( + + — + + )} +