From 6105bc1713f07a61900a0b7e4cecef9a220383ce Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:36:03 +0530 Subject: [PATCH 01/56] feat(fusion): let judge use its own knowledge and override the panel (#6804) The judge prompt said to write an answer 'grounded in that analysis', implicitly capping output at the panel's union. When all panel members miss or are collectively wrong on something, the judge should apply its own reasoning as a full participant and override consensus, while keeping an honesty guard against fabrication. Adds a regression test. Co-authored-by: Chirag Singhal --- open-sse/services/fusion.ts | 6 +++- .../fusion-judge-own-intelligence.test.ts | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/unit/fusion-judge-own-intelligence.test.ts diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index 55ec0ce7d8..d089186823 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -122,7 +122,11 @@ export function buildJudgePrompt(answers: Array<{ text: string }>): string { "", "Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.", "", - "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.", + "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — usually higher-confidence, but NOT automatically correct), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed.", + "", + "You are not a vote-counter, and the panel is not a ceiling — treat it as strong evidence, not as the limit of what you may say. Apply your OWN reasoning and knowledge as a full participant: if the consensus is wrong, incomplete, or outdated, override it and state what is correct; if every source missed something you know, add it; if a lone source is right against the majority, side with it. Do not water down a correct answer to match panel agreement. The only hard limit is honesty — do not assert facts you are not confident about.", + "", + "Then write the best possible final answer — more complete and correct than any single response, and than the panel as a whole — with no filler.", "", "=== PANEL RESPONSES ===", panel, diff --git a/tests/unit/fusion-judge-own-intelligence.test.ts b/tests/unit/fusion-judge-own-intelligence.test.ts new file mode 100644 index 0000000000..52c6317b80 --- /dev/null +++ b/tests/unit/fusion-judge-own-intelligence.test.ts @@ -0,0 +1,30 @@ +// ABOUTME: buildJudgePrompt must license the judge to use its own knowledge and override +// ABOUTME: the panel — not just synthesize within it — while still embedding panel responses. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildJudgePrompt } from "../../open-sse/services/fusion.ts"; + +test("judge prompt embeds all panel answers, anonymized by source", () => { + const prompt = buildJudgePrompt([ + { text: "answer-alpha" }, + { text: "answer-beta" }, + ]); + assert.match(prompt, /\[Source 1\]/); + assert.match(prompt, /\[Source 2\]/); + assert.match(prompt, /answer-alpha/); + assert.match(prompt, /answer-beta/); + assert.match(prompt, /2 expert models/); +}); + +test("judge is licensed to use its own intelligence and override the panel", () => { + const prompt = buildJudgePrompt([{ text: "x" }]); + // Must NOT cap the judge at panel content ("grounded in that analysis" was the old ceiling). + assert.doesNotMatch(prompt, /grounded in that analysis/); + // Must explicitly grant own-reasoning + override authority. + assert.match(prompt, /OWN reasoning and knowledge/); + assert.match(prompt, /override/i); + assert.match(prompt, /not a vote-counter/i); + // Must keep the honesty guard so it doesn't fabricate. + assert.match(prompt, /not confident about/i); +}); From 23c4086a81f54e6d3d3cf0273b6d06772d57267c Mon Sep 17 00:00:00 2001 From: Ronaldo Davi Date: Fri, 10 Jul 2026 15:06:09 -0300 Subject: [PATCH 02/56] fix(api): raise provider apiKey cap for cookie-based web providers (#6715) (#6759) --- src/shared/validation/schemas/provider.ts | 17 +++++- tests/unit/provider-apikey-cap-6715.test.ts | 65 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/unit/provider-apikey-cap-6715.test.ts diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index bd854cae80..dcf9457217 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -41,10 +41,21 @@ const providerNodeIconUrlSchema = z }) .optional(); +// #6715: the `apiKey` field is reused as the raw `Cookie:` header value for +// cookie-based web providers (Gemini Business, Copilot M365, ChatGPT Web, +// Claude Web, …). Real multi-cookie session headers (many `__Secure-*` entries, +// large session tokens) legitimately exceed the old 10,000-char cap, so saving +// a cookie that the provider's own `validate` check (validateProviderApiKeySchema, +// uncapped) had already accepted failed with HTTP 400 "Too big …<=10000". Raised +// to a still-bounded ceiling — well under the 10 MB default request-body limit and +// the unconstrained SQLite TEXT column — so garbage input is still rejected. +// Same fix shape as #6562 (priority cap raised to 100_000). +export const MAX_PROVIDER_CREDENTIAL_LENGTH = 100_000; + export const createProviderSchema = z .object({ provider: z.string().min(1).max(100), - apiKey: z.string().max(10000).optional(), + apiKey: z.string().max(MAX_PROVIDER_CREDENTIAL_LENGTH).optional(), name: z.string().min(1).max(200), priority: z.number().int().min(1).max(100).optional(), globalPriority: z.number().int().min(1).max(100).nullable().optional(), @@ -91,7 +102,7 @@ export const bulkCreateProviderSchema = z .array( z.object({ name: z.string().min(1).max(200), - apiKey: z.string().min(1).max(10000), + apiKey: z.string().min(1).max(MAX_PROVIDER_CREDENTIAL_LENGTH), // Per-key account id — required for cloudflare-ai (enforced in superRefine below). accountId: z.string().min(1).max(200).optional(), }) @@ -304,7 +315,7 @@ export const updateProviderConnectionSchema = z globalPriority: z.union([z.coerce.number().int().min(1).max(100_000), z.null()]).optional(), defaultModel: z.union([z.string().max(200), z.null()]).optional(), isActive: z.boolean().optional(), - apiKey: z.string().max(10000).optional(), + apiKey: z.string().max(MAX_PROVIDER_CREDENTIAL_LENGTH).optional(), testStatus: z.string().max(50).optional(), lastError: z.union([z.string(), z.null()]).optional(), lastErrorAt: z.union([z.string(), z.null()]).optional(), diff --git a/tests/unit/provider-apikey-cap-6715.test.ts b/tests/unit/provider-apikey-cap-6715.test.ts new file mode 100644 index 0000000000..e831f8963c --- /dev/null +++ b/tests/unit/provider-apikey-cap-6715.test.ts @@ -0,0 +1,65 @@ +// Regression guard for #6715 — the provider connection `apiKey` field was +// hard-capped at 10,000 chars in the Zod save schemas: +// src/shared/validation/schemas/provider.ts +// createProviderSchema (add connection) +// bulkCreateProviderSchema (bulk import) +// updateProviderConnectionSchema (edit connection) +// +// That `apiKey` field is reused as the raw `Cookie:` header value for cookie- +// based web providers (Gemini Business, Copilot M365, ChatGPT Web, Claude Web, +// …). Real multi-cookie session headers (many `__Secure-*` entries, large +// session tokens) legitimately exceed 10,000 chars. The provider's own +// `validate` schema (validateProviderApiKeySchema) has NO cap, so the cookie +// validated as OK — then `save` rejected it with HTTP 400 +// "Too big: expected string to have <=10000 characters". +// +// Fix: raise the ceiling to MAX_PROVIDER_CREDENTIAL_LENGTH (100_000) — still a +// sane anti-abuse bound (well under the 10 MB default request-body limit and +// unconstrained SQLite TEXT storage), just wide enough for real cookie headers. +// Same fix shape as #6562 (priority cap raised to 100_000). +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createProviderSchema, bulkCreateProviderSchema, updateProviderConnectionSchema } = + await import("../../src/shared/validation/schemas.ts"); + +// A realistic large cookie-header value: > 10_000 chars, < the new 100_000 cap. +const LARGE_COOKIE = "__Secure-session=" + "a".repeat(20_000); +// Beyond the new ceiling — must still be rejected (anti-abuse bound preserved). +const OVERSIZE_COOKIE = "x".repeat(100_001); + +test("createProviderSchema accepts a >10000-char cookie apiKey (#6715)", () => { + const result = createProviderSchema.safeParse({ + provider: "gemini-business", + name: "Gemini Business (cookie)", + apiKey: LARGE_COOKIE, + }); + assert.equal(result.success, true, JSON.stringify(result.error?.issues)); +}); + +test("bulkCreateProviderSchema accepts a >10000-char cookie apiKey (#6715)", () => { + const result = bulkCreateProviderSchema.safeParse({ + provider: "gemini-business", + entries: [{ name: "cookie-1", apiKey: LARGE_COOKIE }], + }); + assert.equal(result.success, true, JSON.stringify(result.error?.issues)); +}); + +test("updateProviderConnectionSchema accepts a >10000-char cookie apiKey (#6715)", () => { + const result = updateProviderConnectionSchema.safeParse({ apiKey: LARGE_COOKIE }); + assert.equal(result.success, true, JSON.stringify(result.error?.issues)); +}); + +test("createProviderSchema still rejects an oversize apiKey past the new ceiling (control)", () => { + const result = createProviderSchema.safeParse({ + provider: "gemini-business", + name: "too big", + apiKey: OVERSIZE_COOKIE, + }); + assert.equal(result.success, false); +}); + +test("updateProviderConnectionSchema still rejects an oversize apiKey past the new ceiling (control)", () => { + const result = updateProviderConnectionSchema.safeParse({ apiKey: OVERSIZE_COOKIE }); + assert.equal(result.success, false); +}); From de193f8b24ea90fb8c6d82bd6ede405f0fbedb0f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:06:15 -0300 Subject: [PATCH 03/56] fix(cli): fall back to settings.json when Claude Code binary is unresolvable (#6701) (#6734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCliRuntimeStatus() only ever answered `installed` from binary resolution (known install paths + where/which PATH search), so a stale PATH, moved binary, or uncatalogued install method reported "not found" even when ~/.claude/settings.json proved the CLI was installed and used before — regressing behind upstream 9router's checkClaudeInstalled(), which already falls back to the settings file when where/which fails. withSettingsFallback() (new src/shared/services/cliInstallFallback.ts, kept out of the frozen cliRuntime.ts to respect its file-size ceiling) restores that parity: only when the binary lookup's own reason is "not_found" (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk. --- CHANGELOG.md | 1 + src/shared/services/cliInstallFallback.ts | 69 +++++++++++++++++ src/shared/services/cliRuntime.ts | 6 +- .../repro-6701-claude-detect-fallback.test.ts | 74 +++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 src/shared/services/cliInstallFallback.ts create mode 100644 tests/unit/repro-6701-claude-detect-fallback.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64098b3fd6..857b85deaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral ### 🐛 Bug Fixes +- **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. - **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) - **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) - **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). diff --git a/src/shared/services/cliInstallFallback.ts b/src/shared/services/cliInstallFallback.ts new file mode 100644 index 0000000000..dfe57c3bd8 --- /dev/null +++ b/src/shared/services/cliInstallFallback.ts @@ -0,0 +1,69 @@ +import fsSync from "fs"; + +/** + * #6701 — 9router-parity fallback for CLI install detection. + * + * `getCliRuntimeStatus()` in `cliRuntime.ts` determines `installed` from + * binary resolution alone (known install paths + a `where`/`which` PATH + * search). If the binary is not currently resolvable — stale PATH inherited + * by a long-running/background OmniRoute process, the binary having moved, + * or an install method we don't enumerate yet — it used to unconditionally + * report `installed:false`, even when the tool's own settings/config file on + * disk proves it was installed and used before. + * + * Upstream 9router's equivalent route + * (`src/app/api/cli-tools/claude-settings/route.js::checkClaudeInstalled()`) + * has a second-chance fallback: when `where`/`which` fails, it still reports + * `installed:true` if the settings file exists. This restores that fallback + * for any CLI tool that declares a `settings` config path (currently + * `claude` and `droid` — see `CLI_TOOLS` in `cliRuntime.ts`). + * + * Only applies when the lookup's own reason is "not_found" — i.e. the binary + * genuinely couldn't be located on PATH/known install paths. Deliberate + * security rejections (unsafe/relative env override paths, symlink escapes, + * suspicious file sizes, etc.) must stay `installed:false` regardless of + * whether a settings file happens to exist. + */ +export interface NotInstalledResult { + installed: false; + runnable: boolean; + command: string | null; + commandPath: string | null; + reason: string; + runtimeMode: string; + requiresBinary: boolean; +} + +export interface SettingsFallbackResult { + installed: true; + runnable: false; + command: string | null; + commandPath: null; + reason: "settings_found_binary_unresolved"; + runtimeMode: string; + requiresBinary: boolean; +} + +/** + * Given the resolved settings-file path for a tool (or undefined if the tool + * has none) and the "not installed" result the binary lookup already + * produced, return a settings-fallback result when the settings file exists + * on disk, or the original "not installed" result unchanged otherwise. + */ +export const withSettingsFallback = ( + settingsPath: string | undefined, + notInstalledResult: NotInstalledResult +): NotInstalledResult | SettingsFallbackResult => { + if (notInstalledResult.reason !== "not_found") return notInstalledResult; + if (!settingsPath || !fsSync.existsSync(settingsPath)) return notInstalledResult; + + return { + installed: true, + runnable: false, + command: notInstalledResult.command, + commandPath: null, + reason: "settings_found_binary_unresolved", + runtimeMode: notInstalledResult.runtimeMode, + requiresBinary: notInstalledResult.requiresBinary, + }; +}; diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 5ccfd27221..c7e778a9a0 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -5,7 +5,7 @@ import path from "path"; import { spawn, execFileSync } from "child_process"; import { getHermesHome } from "@/lib/cli-helper/config-generator/hermesHome"; import { getCachedLoginShellPath, mergeShellPath } from "./loginShellPath"; - +import { withSettingsFallback } from "./cliInstallFallback"; const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]); const FALSE_VALUES = new Set(["0", "false", "no", "off"]); @@ -1085,7 +1085,7 @@ export const getCliRuntimeStatus = async (toolId: string) => { const command = located.command; if (!located.installed) { - return { + return withSettingsFallback(getCliConfigPaths(toolId)?.settings, { installed: false, runnable: false, command, @@ -1093,7 +1093,7 @@ export const getCliRuntimeStatus = async (toolId: string) => { reason: located.reason || "not_found", runtimeMode, requiresBinary, - }; + }); } if (located.reason === "not_executable") { diff --git a/tests/unit/repro-6701-claude-detect-fallback.test.ts b/tests/unit/repro-6701-claude-detect-fallback.test.ts new file mode 100644 index 0000000000..c17b6e1705 --- /dev/null +++ b/tests/unit/repro-6701-claude-detect-fallback.test.ts @@ -0,0 +1,74 @@ +/** + * Repro for #6701 — Claude Code CLI reported "not found" in OmniRoute's + * dashboard even though the user has used it before (settings.json present) + * and upstream 9router (same machine, same settings.json) reports it as + * "Connected". + * + * Root cause: `getCliRuntimeStatus()` in src/shared/services/cliRuntime.ts + * only ever answers `installed` from binary resolution (known install paths + * + PATH lookup via `where.exe`/`command -v`). If the CLI binary is not + * currently resolvable (stale PATH inherited by a long-running/background + * OmniRoute process, binary moved, etc.) it unconditionally reports + * installed:false — even when `~/.claude/settings.json` proves the tool was + * installed and used before. + * + * Upstream 9router's equivalent route (src/app/api/cli-tools/claude-settings/route.js) + * has a second-chance fallback: if `where`/`which` fails, it still reports + * installed:true when the settings file exists on disk. OmniRoute's rewrite + * into cliRuntime.ts dropped that fallback, which is the concrete regression + * relative to 9router this issue's screenshots capture. + * + * This test forces the binary lookup to fail deterministically (CLI_CLAUDE_BIN + * pointed at a path that does not exist) while a real settings.json sits under + * an isolated CLI_CONFIG_HOME. Expected (post-fix, 9router-parity) behavior: + * installed should stay true because the settings file is present. Current + * code returns installed:false / reason:"not_found" — this is the RED proof. + */ + +import { describe, it, before, after } 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 { getCliRuntimeStatus } = await import("../../src/shared/services/cliRuntime.ts"); + +describe("#6701 — claude detection should fall back to settings.json when binary is unresolvable", () => { + let configHome: string; + const prevBin = process.env.CLI_CLAUDE_BIN; + const prevConfigHome = process.env.CLI_CONFIG_HOME; + + before(() => { + // Isolated config home *within* os.homedir() (CLI_CONFIG_HOME validation + // requires this) so we never touch the real ~/.claude directory. + configHome = fs.mkdtempSync(path.join(os.homedir(), ".omniroute-test-6701-")); + const claudeDir = path.join(configHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeDir, "settings.json"), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: "http://localhost:20128" } }, null, 2) + ); + + // Force the binary lookup to fail deterministically regardless of host state. + process.env.CLI_CLAUDE_BIN = path.join(os.tmpdir(), "definitely-not-a-real-claude-binary-6701"); + process.env.CLI_CONFIG_HOME = configHome; + }); + + after(() => { + fs.rmSync(configHome, { recursive: true, force: true }); + if (prevBin === undefined) delete process.env.CLI_CLAUDE_BIN; + else process.env.CLI_CLAUDE_BIN = prevBin; + if (prevConfigHome === undefined) delete process.env.CLI_CONFIG_HOME; + else process.env.CLI_CONFIG_HOME = prevConfigHome; + }); + + it("reports installed:true when settings.json exists, even if the binary can't be resolved", async () => { + const result = await getCliRuntimeStatus("claude"); + + assert.equal( + result.installed, + true, + `Expected installed:true (9router-parity settings.json fallback), got installed:${result.installed} reason:${result.reason}` + ); + }); +}); From c837de6c98ee5d1e8c52315e6d61a7e10c3a2ea3 Mon Sep 17 00:00:00 2001 From: Ronaldo Davi Date: Fri, 10 Jul 2026 15:06:26 -0300 Subject: [PATCH 04/56] fix(providers): honor explicit thinking.budget_tokens 0 in openai->gemini transform (#6813) (#6821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transform forwarded the Claude-style thinking.budget_tokens into generationConfig.thinkingConfig.thinkingBudget, but the presence check was truthy (&& thinking.budget_tokens). An explicit budget_tokens: 0 — the natural way to disable thinking — is falsy, so it was dropped and the request fell through to the default thinkingConfig injection, making the model think despite an explicit request for zero. Use an explicit numeric check so 0 is honored as thinkingBudget 0; includeThoughts is only set for a non-zero budget. --- .../translator/request/openai-to-gemini.ts | 8 ++- .../gemini-thinking-budget-zero-6813.test.ts | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/unit/gemini-thinking-budget-zero-6813.test.ts diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 703f2cbe10..f113b45ee0 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -211,11 +211,15 @@ function openaiToGeminiBase( }; } // 2. Claude format: thinking (type: enabled, budget_tokens) + // Use an explicit numeric check (not truthy) so an explicit `budget_tokens: 0` — the + // natural way to disable thinking — is honored as thinkingBudget 0 instead of being + // dropped and falling through to the default injection below (#6813). A zero budget + // yields no thoughts, so includeThoughts is only set for a non-zero budget. const thinking = body.thinking as { type?: string; budget_tokens?: number } | undefined; - if (thinking?.type === "enabled" && thinking.budget_tokens) { + if (thinking?.type === "enabled" && typeof thinking.budget_tokens === "number") { result.generationConfig.thinkingConfig = { thinkingBudget: thinking.budget_tokens, - includeThoughts: true, + includeThoughts: thinking.budget_tokens !== 0, }; } diff --git a/tests/unit/gemini-thinking-budget-zero-6813.test.ts b/tests/unit/gemini-thinking-budget-zero-6813.test.ts new file mode 100644 index 0000000000..4402c7ed85 --- /dev/null +++ b/tests/unit/gemini-thinking-budget-zero-6813.test.ts @@ -0,0 +1,50 @@ +/** + * #6813 (defect 1) — the openai->gemini transform forwards the Claude-style + * `thinking.budget_tokens` into `generationConfig.thinkingConfig.thinkingBudget`, but the + * presence check was truthy (`&& thinking.budget_tokens`). An explicit `budget_tokens: 0` + * (the natural "disable thinking" request) is falsy, so it was dropped and the request fell + * through to the default thinkingConfig injection — the model thought despite an explicit + * request for zero. A `budget_tokens: 0` must be honored as thinkingBudget: 0. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToGeminiRequest } = await import( + "../../open-sse/translator/request/openai-to-gemini.ts" +); + +test("thinking.budget_tokens: 0 is honored as thinkingBudget 0 (not dropped) (#6813)", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled", budget_tokens: 0 }, + }, + false + ) as { generationConfig: { thinkingConfig?: { thinkingBudget: number; includeThoughts: boolean } } }; + + assert.equal( + result.generationConfig.thinkingConfig?.thinkingBudget, + 0, + "explicit budget_tokens: 0 must map to thinkingBudget: 0" + ); + assert.equal( + result.generationConfig.thinkingConfig?.includeThoughts, + false, + "with a zero budget there are no thoughts to include" + ); +}); + +test("thinking.budget_tokens: positive value still maps through with includeThoughts true (#6813 no-regression)", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled", budget_tokens: 2048 }, + }, + false + ) as { generationConfig: { thinkingConfig?: { thinkingBudget: number; includeThoughts: boolean } } }; + + assert.equal(result.generationConfig.thinkingConfig?.thinkingBudget, 2048); + assert.equal(result.generationConfig.thinkingConfig?.includeThoughts, true); +}); From 9cafb7eb7c6b0ba8fdcf1200c4405d9270ab7192 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:08:30 -0300 Subject: [PATCH 05/56] fix(compression): reconcile outer vs per-engine token counts (#6488) (#6741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compression): reconcile outer vs per-engine token counts on degenerate output (#6488) Outer originalTokens/compressedTokens (real tiktoken counter over extracted message text) diverged from engineBreakdown[0]'s counts (a crude JSON.stringify(requestBody).length/4 estimate), worst on small/degenerate inputs where JSON structural overhead dominates. A single-engine breakdown entry represents the exact same before/after transformation as the overall response, so reconcileSingleEngineTokens() now overwrites that one entry's counts with the outer, more accurate figures; multi-step pipeline breakdowns are left untouched. * chore(6741): resolve release sync — CHANGELOG.md restored to release tip, entry moved to changelog.d fragment (fragments-first) --- ...741-compression-preview-token-reconcile.md | 1 + .../services/compression/engineBreakdown.ts | 37 +++++++++ src/app/api/compression/preview/route.ts | 14 +++- ...-outer-engine-token-reconcile-6488.test.ts | 79 +++++++++++++++++++ 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/6741-compression-preview-token-reconcile.md create mode 100644 tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts diff --git a/changelog.d/fixes/6741-compression-preview-token-reconcile.md b/changelog.d/fixes/6741-compression-preview-token-reconcile.md new file mode 100644 index 0000000000..122a354265 --- /dev/null +++ b/changelog.d/fixes/6741-compression-preview-token-reconcile.md @@ -0,0 +1 @@ +- **fix(compression):** `/api/compression/preview`'s top-level `originalTokens`/`compressedTokens` diverged from `engineBreakdown[0]`'s counts for the same single-engine run (tiktoken outer counts vs the `JSON.stringify(...).length/4` estimate per engine), worst on small inputs. A new `reconcileSingleEngineTokens()` overwrites the single-engine breakdown entry with the outer, more accurate figures; multi-step pipeline breakdowns are left untouched ([#6488](https://github.com/diegosouzapw/OmniRoute/issues/6488)). Regression guard: `tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts`. diff --git a/open-sse/services/compression/engineBreakdown.ts b/open-sse/services/compression/engineBreakdown.ts index 99d3bfaf16..4f512ab05f 100644 --- a/open-sse/services/compression/engineBreakdown.ts +++ b/open-sse/services/compression/engineBreakdown.ts @@ -27,3 +27,40 @@ export function ensureEngineBreakdown(stats: CompressionStats): EngineBreakdownE }, ]; } + +/** + * #6488 — Reconcile the single-engine breakdown entry's token counts with the response's + * authoritative outer counts. + * + * The outer `originalTokens`/`compressedTokens` fields (computed by the API route with a real + * tiktoken-based counter over the extracted message text) and each `engineBreakdown[]` entry's + * `originalTokens`/`compressedTokens` (computed internally by `estimateCompressionTokens`, a + * crude `JSON.stringify(requestBody).length / 4` estimate over the whole request-body object) + * use two different, unreconciled token-counting methodologies. They diverge most on + * small/degenerate inputs where JSON structural overhead (braces, quotes, `role`/`content` + * keys) dominates the char count. + * + * When the breakdown has exactly one entry, that entry represents the *same* before/after + * transformation as the overall response (single-engine dispatch, or a 1-step pipeline) — so + * its counts are safe to overwrite with the outer, more accurate figures. Multi-step + * breakdowns are left untouched: each intermediate step legitimately operates on the previous + * step's (already-compressed) output, so its "before" state is not the overall original input + * and reconciling it against the overall counts would be incorrect. + */ +export function reconcileSingleEngineTokens( + breakdown: EngineBreakdownEntry[], + outerOriginalTokens: number, + outerCompressedTokens: number, + outerSavingsPercent: number +): EngineBreakdownEntry[] { + if (breakdown.length !== 1) return breakdown; + const [entry] = breakdown; + return [ + { + ...entry, + originalTokens: outerOriginalTokens, + compressedTokens: outerCompressedTokens, + savingsPercent: outerSavingsPercent, + }, + ]; +} diff --git a/src/app/api/compression/preview/route.ts b/src/app/api/compression/preview/route.ts index cc8fc7e360..c656f0ced3 100644 --- a/src/app/api/compression/preview/route.ts +++ b/src/app/api/compression/preview/route.ts @@ -16,7 +16,10 @@ import { } from "@omniroute/open-sse/services/compression/diffHelper"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { countTextTokens } from "@/shared/utils/tiktokenCounter"; -import { ensureEngineBreakdown } from "@omniroute/open-sse/services/compression/engineBreakdown"; +import { + ensureEngineBreakdown, + reconcileSingleEngineTokens, +} from "@omniroute/open-sse/services/compression/engineBreakdown"; import { summarizeEncoderCandidates } from "@omniroute/open-sse/services/compression/engines/headroom/encoderComparison"; import { DEFAULT_MIN_ROWS } from "@omniroute/open-sse/services/compression/engines/headroom/smartcrusher"; @@ -217,7 +220,14 @@ export async function POST(req: Request) { const tokensSaved = Math.max(0, originalTokens - compressedTokens); const savingsPct = originalTokens > 0 ? Math.round((tokensSaved / originalTokens) * 100) : 0; const techniquesUsed: string[] = result.stats?.techniquesUsed ?? []; - const engineBreakdown = result.stats ? ensureEngineBreakdown(result.stats) : []; + const engineBreakdown = result.stats + ? reconcileSingleEngineTokens( + ensureEngineBreakdown(result.stats), + originalTokens, + compressedTokens, + savingsPct + ) + : []; const diff = buildCompressionPreviewDiff( originalText, compressedText, diff --git a/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts b/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts new file mode 100644 index 0000000000..0d7d01da9b --- /dev/null +++ b/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "preview-reconcile-6488-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "test-secret-32-chars-min-aaaaaaaa"; +delete process.env.INITIAL_PASSWORD; +const core = await import("../../../src/lib/db/core.ts"); +const route = await import("../../../src/app/api/compression/preview/route.ts"); + +function makeReq(body: unknown) { + return new Request("http://localhost/api/compression/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(() => core.resetDbInstance()); +test.after(() => { + core.resetDbInstance(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// Regression for #6488: outer originalTokens/compressedTokens (real tiktoken counter over +// extracted message text) and engineBreakdown[].originalTokens/compressedTokens (internal +// JSON.stringify(body).length/4 estimate) used to diverge on small/degenerate input because +// they measured different things. For a single-engine breakdown, the entry represents the +// exact same before/after transformation as the overall response, so it must be reconciled +// to match the outer counts exactly. +test("degenerate input with pipeline=['lite']: engineBreakdown[0] matches outer token counts", async () => { + const res = await route.POST( + makeReq({ + messages: [{ role: "user", content: "user: " }], + pipeline: ["lite"], + }) + ); + const body = await res.json(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(body)}`); + + const engines = (body.engineBreakdown ?? []).map((e: { engine: string }) => e.engine); + assert.ok( + engines.every((e: string) => e === "lite"), + `expected engineBreakdown to only contain 'lite', got ${JSON.stringify(engines)}` + ); + + assert.equal(body.engineBreakdown.length, 1); + const [step] = body.engineBreakdown; + assert.equal( + step.originalTokens, + body.originalTokens, + `outer originalTokens=${body.originalTokens} vs engine ${step.engine} originalTokens=${step.originalTokens}` + ); + assert.equal( + step.compressedTokens, + body.compressedTokens, + `outer compressedTokens=${body.compressedTokens} vs engine ${step.engine} compressedTokens=${step.compressedTokens}` + ); +}); + +// Same reconciliation must hold for the single-engine (non-pipeline) dispatch path, where +// engineBreakdown is synthesized by ensureEngineBreakdown from the overall stats. +test("single-engine dispatch (engineId='rtk'): engineBreakdown[0] matches outer token counts", async () => { + const res = await route.POST( + makeReq({ + messages: [{ role: "user", content: "a" }], + engineId: "rtk", + }) + ); + const body = await res.json(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(body)}`); + assert.equal(body.engineBreakdown.length, 1); + const [step] = body.engineBreakdown; + assert.equal(step.originalTokens, body.originalTokens); + assert.equal(step.compressedTokens, body.compressedTokens); +}); From 2e2c03893492a5661ee569ac68b22e170d85c87f Mon Sep 17 00:00:00 2001 From: Ronaldo Davi Date: Fri, 10 Jul 2026 15:18:54 -0300 Subject: [PATCH 06/56] fix(api): accept enableRenderers in RTK compression config schema (#6703) (#6757) --- .../validation/compressionConfigSchemas.ts | 1 + tests/unit/rtk-enable-renderers-6703.test.ts | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/unit/rtk-enable-renderers-6703.test.ts diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index a9472c4227..bdebd8e63a 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -61,6 +61,7 @@ export const rtkConfigSchema = z groupingThreshold: z.number().int().min(2).max(100).optional(), stripCodeComments: z.boolean().optional(), preserveDocstrings: z.boolean().optional(), + enableRenderers: z.boolean().optional(), }) .strict(); diff --git a/tests/unit/rtk-enable-renderers-6703.test.ts b/tests/unit/rtk-enable-renderers-6703.test.ts new file mode 100644 index 0000000000..9674369a13 --- /dev/null +++ b/tests/unit/rtk-enable-renderers-6703.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + rtkConfigSchema, + compressionSettingsUpdateSchema, +} from "../../src/shared/validation/compressionConfigSchemas.ts"; +import { DEFAULT_RTK_CONFIG } from "../../open-sse/services/compression/types.ts"; + +// Regression for #6703: rtkConfigSchema uses Zod .strict() but was missing the +// `enableRenderers` field that DEFAULT_RTK_CONFIG (and the RTK engine's own +// configSchema) already define. The frontend reads DEFAULT_RTK_CONFIG (which +// carries enableRenderers), sends the full object back on save, and .strict() +// rejects the unknown key → HTTP 400 "Unrecognized key: enableRenderers". +// This broke PUT /api/settings/compression, POST /api/context/rtk/test, and +// PUT /api/context/rtk/config. + +describe("RTK config schema — enableRenderers (#6703)", () => { + it("accepts enableRenderers on the strict rtkConfigSchema", () => { + assert.equal(rtkConfigSchema.safeParse({ enableRenderers: false }).success, true); + assert.equal(rtkConfigSchema.safeParse({ enableRenderers: true }).success, true); + }); + + it("accepts the full DEFAULT_RTK_CONFIG without stripping fields", () => { + const result = rtkConfigSchema.safeParse(DEFAULT_RTK_CONFIG); + assert.equal(result.success, true, JSON.stringify(result.error?.issues)); + assert.equal(result.data.enableRenderers, false); + }); + + it("rejects a genuinely unknown key (strict mode still enforced)", () => { + assert.equal(rtkConfigSchema.safeParse({ totallyBogusKey: true }).success, false); + }); + + it("accepts rtkConfig with enableRenderers through the settings-update schema", () => { + // Mirrors the PUT /api/settings/compression payload from the bug report. + const result = compressionSettingsUpdateSchema.safeParse({ + rtkConfig: { ...DEFAULT_RTK_CONFIG, enableRenderers: false }, + }); + assert.equal(result.success, true, JSON.stringify(result.error?.issues)); + }); +}); From 7aa9e6bdec41c311c2a31206f491c4be9a9ac771 Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:39:13 -0700 Subject: [PATCH 07/56] fix(db): break probe-failed/restore loop on large storage.sqlite (#6632) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../6632-probe-restore-loop-cycle-breaker.md | 1 + src/lib/db/core.ts | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 changelog.d/fixes/6632-probe-restore-loop-cycle-breaker.md diff --git a/changelog.d/fixes/6632-probe-restore-loop-cycle-breaker.md b/changelog.d/fixes/6632-probe-restore-loop-cycle-breaker.md new file mode 100644 index 0000000000..45094d3742 --- /dev/null +++ b/changelog.d/fixes/6632-probe-restore-loop-cycle-breaker.md @@ -0,0 +1 @@ +- **fix(db): break probe-failed/restore loop on large storage.sqlite** (#6632 — thanks @KooshaPari). diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index dd5bbec334..0fa67ac01e 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -462,6 +462,10 @@ const SCHEMA_SQL = ` declare global { var __omnirouteDb: SqliteAdapter | undefined; + // Cycle-breaker counter for the probe-failed/restore cascade. Survives + // Next.js HMR re-evaluations so concurrent subsystems all see the same + // count and we abort with a clear error instead of looping forever. + var __omnirouteDbProbeRestoreCount: number | undefined; } function getDb(): SqliteDatabase | null { @@ -952,6 +956,26 @@ export function getDbInstance(): SqliteDatabase { const jsonDbFile = JSON_DB_FILE; const probeFailureBackups = listProbeFailureBackups(sqliteFile); if (!fs.existsSync(sqliteFile) && probeFailureBackups.length > 0) { + // Cycle-breaker: a previous probe failure renamed the DB to + // `storage.sqlite.probe-failed-` and the next caller auto-restored it. + // When the same DB continues to fail the probe (typically an OOM on a + // large sql.js WASM load), the rename/restore cascade loops forever + // because every subsystem (BATCH, HealthCheck, ProviderLimitsSync, ...) + // hits the same code path during boot. Track restoration attempts on + // globalThis; abort with a clear recovery message after 3 attempts so + // the user gets a real error instead of a hung "Starting server...". + if ( + (globalThis.__omnirouteDbProbeRestoreCount = + (globalThis.__omnirouteDbProbeRestoreCount || 0) + 1) > 3 + ) { + throw new Error( + `[DB] Aborting startup: probe-failed/restore loop detected after 3 attempts. ` + + `The preserved database at ${path.dirname(sqliteFile)} is unloadable under this runtime. ` + + `Remove the probe-failed backups (storage.sqlite.probe-failed-*) from ${path.dirname( + sqliteFile + )} and restart, or restore the database from a known-good backup.` + ); + } const latestBackup = probeFailureBackups[0]; try { fs.renameSync(latestBackup, sqliteFile); @@ -1047,6 +1071,19 @@ export function getDbInstance(): SqliteDatabase { ) { throw e; } + // OOM during probe = the DB is too large to load under the current + // V8 heap (sql.js loads the whole file into WASM memory). Throwing + // immediately gives the user a clear "increase --max-old-space-size" + // signal instead of silently renaming a perfectly good DB. + if (/out of memory|allocation failure|Array buffer allocation failed|allocation failed/i.test(message)) { + throw new Error( + `[DB] Out of memory while probing ${sqliteFile}. ` + + `The bundled sql.js driver loads the entire file into WASM memory; ` + + `increase the V8 heap with NODE_OPTIONS=--max-old-space-size=4096 (or higher) ` + + `and restart, or restore the database from a backup. ` + + `Original error: ${message}` + ); + } preservedCriticalState = captureCriticalDbState(sqliteFile); // SAFETY: Never delete the database — rename to backup so data can be recovered. From 5846e6af355a8008c8ad78c66417c162c79e399a Mon Sep 17 00:00:00 2001 From: Andrew Munsell Date: Fri, 10 Jul 2026 14:05:20 -0700 Subject: [PATCH 08/56] feat(cursor): add Opus 4.8, Fable 5, and Sonnet 5 model families (#6779) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's cursor registry + test changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../6779-cursor-opus48-fable5-sonnet5.md | 1 + .../config/providers/registry/cursor/index.ts | 43 +++++++++++++++++ tests/unit/cursor-agent-models.test.ts | 6 +++ .../cursor-registry-claude-families.test.ts | 47 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 changelog.d/features/6779-cursor-opus48-fable5-sonnet5.md create mode 100644 tests/unit/cursor-registry-claude-families.test.ts diff --git a/changelog.d/features/6779-cursor-opus48-fable5-sonnet5.md b/changelog.d/features/6779-cursor-opus48-fable5-sonnet5.md new file mode 100644 index 0000000000..f1b893ab09 --- /dev/null +++ b/changelog.d/features/6779-cursor-opus48-fable5-sonnet5.md @@ -0,0 +1 @@ +- **feat(cursor):** register the Opus 4.8, Fable 5, and Sonnet 5 model families for the Cursor Agent provider so the latest Claude/Fable model ids route correctly (#6779 — thanks @andrewmunsell). diff --git a/open-sse/config/providers/registry/cursor/index.ts b/open-sse/config/providers/registry/cursor/index.ts index 945838c20c..264ee9740e 100644 --- a/open-sse/config/providers/registry/cursor/index.ts +++ b/open-sse/config/providers/registry/cursor/index.ts @@ -75,6 +75,49 @@ export const cursorProvider: RegistryEntry = { { id: "gpt-5.2-xhigh", name: "GPT 5.2 XHigh" }, { id: "gpt-5.2-xhigh-fast", name: "GPT 5.2 XHigh Fast" }, // + { id: "claude-opus-4-8-low", name: "Claude Opus 4.8 Low" }, + { id: "claude-opus-4-8-low-fast", name: "Claude Opus 4.8 Low Fast" }, + { id: "claude-opus-4-8-medium", name: "Claude Opus 4.8 Medium" }, + { id: "claude-opus-4-8-medium-fast", name: "Claude Opus 4.8 Medium Fast" }, + { id: "claude-opus-4-8-high", name: "Claude Opus 4.8 High" }, + { id: "claude-opus-4-8-high-fast", name: "Claude Opus 4.8 High Fast" }, + { id: "claude-opus-4-8-xhigh", name: "Claude Opus 4.8 XHigh" }, + { id: "claude-opus-4-8-xhigh-fast", name: "Claude Opus 4.8 XHigh Fast" }, + { id: "claude-opus-4-8-max", name: "Claude Opus 4.8 Max" }, + { id: "claude-opus-4-8-max-fast", name: "Claude Opus 4.8 Max Fast" }, + { id: "claude-opus-4-8-thinking-low", name: "Claude Opus 4.8 Thinking Low" }, + { id: "claude-opus-4-8-thinking-low-fast", name: "Claude Opus 4.8 Thinking Low Fast" }, + { id: "claude-opus-4-8-thinking-medium", name: "Claude Opus 4.8 Thinking Medium" }, + { id: "claude-opus-4-8-thinking-medium-fast", name: "Claude Opus 4.8 Thinking Medium Fast" }, + { id: "claude-opus-4-8-thinking-high", name: "Claude Opus 4.8 Thinking High" }, + { id: "claude-opus-4-8-thinking-high-fast", name: "Claude Opus 4.8 Thinking High Fast" }, + { id: "claude-opus-4-8-thinking-xhigh", name: "Claude Opus 4.8 Thinking XHigh" }, + { id: "claude-opus-4-8-thinking-xhigh-fast", name: "Claude Opus 4.8 Thinking XHigh Fast" }, + { id: "claude-opus-4-8-thinking-max", name: "Claude Opus 4.8 Thinking Max" }, + { id: "claude-opus-4-8-thinking-max-fast", name: "Claude Opus 4.8 Thinking Max Fast" }, + // + { id: "claude-fable-5-low", name: "Claude Fable 5 Low" }, + { id: "claude-fable-5-medium", name: "Claude Fable 5 Medium" }, + { id: "claude-fable-5-high", name: "Claude Fable 5 High" }, + { id: "claude-fable-5-xhigh", name: "Claude Fable 5 XHigh" }, + { id: "claude-fable-5-max", name: "Claude Fable 5 Max" }, + { id: "claude-fable-5-thinking-low", name: "Claude Fable 5 Thinking Low" }, + { id: "claude-fable-5-thinking-medium", name: "Claude Fable 5 Thinking Medium" }, + { id: "claude-fable-5-thinking-high", name: "Claude Fable 5 Thinking High" }, + { id: "claude-fable-5-thinking-xhigh", name: "Claude Fable 5 Thinking XHigh" }, + { id: "claude-fable-5-thinking-max", name: "Claude Fable 5 Thinking Max" }, + // + { id: "claude-sonnet-5-low", name: "Claude Sonnet 5 Low" }, + { id: "claude-sonnet-5-medium", name: "Claude Sonnet 5 Medium" }, + { id: "claude-sonnet-5-high", name: "Claude Sonnet 5 High" }, + { id: "claude-sonnet-5-xhigh", name: "Claude Sonnet 5 XHigh" }, + { id: "claude-sonnet-5-max", name: "Claude Sonnet 5 Max" }, + { id: "claude-sonnet-5-thinking-low", name: "Claude Sonnet 5 Thinking Low" }, + { id: "claude-sonnet-5-thinking-medium", name: "Claude Sonnet 5 Thinking Medium" }, + { id: "claude-sonnet-5-thinking-high", name: "Claude Sonnet 5 Thinking High" }, + { id: "claude-sonnet-5-thinking-xhigh", name: "Claude Sonnet 5 Thinking XHigh" }, + { id: "claude-sonnet-5-thinking-max", name: "Claude Sonnet 5 Thinking Max" }, + // { id: "claude-opus-4-7-low", name: "Claude Opus 4.7 Low" }, { id: "claude-opus-4-7-medium", name: "Claude Opus 4.7 Medium" }, { id: "claude-opus-4-7-high", name: "Claude Opus 4.7 High" }, diff --git a/tests/unit/cursor-agent-models.test.ts b/tests/unit/cursor-agent-models.test.ts index 3893d2b160..fafb7dbd09 100644 --- a/tests/unit/cursor-agent-models.test.ts +++ b/tests/unit/cursor-agent-models.test.ts @@ -36,6 +36,12 @@ test("humanizeCursorModelId pretty-prints common patterns", () => { humanizeCursorModelId("claude-opus-4-7-thinking-high"), "Claude Opus 4.7 Thinking High" ); + assert.equal( + humanizeCursorModelId("claude-opus-4-8-thinking-high-fast"), + "Claude Opus 4.8 Thinking High Fast" + ); + assert.equal(humanizeCursorModelId("claude-fable-5-thinking-xhigh"), "Claude Fable 5 Thinking XHigh"); + assert.equal(humanizeCursorModelId("claude-sonnet-5-max"), "Claude Sonnet 5 Max"); assert.equal(humanizeCursorModelId("kimi-k2.5"), "Kimi K2.5"); assert.equal(humanizeCursorModelId("gemini-3.1-pro"), "Gemini 3.1 Pro"); assert.equal(humanizeCursorModelId("claude-4-sonnet-thinking"), "Claude 4 Sonnet Thinking"); diff --git a/tests/unit/cursor-registry-claude-families.test.ts b/tests/unit/cursor-registry-claude-families.test.ts new file mode 100644 index 0000000000..58df088b38 --- /dev/null +++ b/tests/unit/cursor-registry-claude-families.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { cursorProvider } from "../../open-sse/config/providers/registry/cursor/index.ts"; + +const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; + +function modelIds(): Set { + return new Set(cursorProvider.models.map((m) => m.id)); +} + +test("cursor registry includes Claude Opus 4.8 effort + thinking + fast variants", () => { + const ids = modelIds(); + for (const effort of EFFORTS) { + assert.ok(ids.has(`claude-opus-4-8-${effort}`), `missing claude-opus-4-8-${effort}`); + assert.ok(ids.has(`claude-opus-4-8-${effort}-fast`), `missing claude-opus-4-8-${effort}-fast`); + assert.ok( + ids.has(`claude-opus-4-8-thinking-${effort}`), + `missing claude-opus-4-8-thinking-${effort}` + ); + assert.ok( + ids.has(`claude-opus-4-8-thinking-${effort}-fast`), + `missing claude-opus-4-8-thinking-${effort}-fast` + ); + } +}); + +test("cursor registry includes Claude Fable 5 effort + thinking variants", () => { + const ids = modelIds(); + for (const effort of EFFORTS) { + assert.ok(ids.has(`claude-fable-5-${effort}`), `missing claude-fable-5-${effort}`); + assert.ok( + ids.has(`claude-fable-5-thinking-${effort}`), + `missing claude-fable-5-thinking-${effort}` + ); + } +}); + +test("cursor registry includes Claude Sonnet 5 effort + thinking variants", () => { + const ids = modelIds(); + for (const effort of EFFORTS) { + assert.ok(ids.has(`claude-sonnet-5-${effort}`), `missing claude-sonnet-5-${effort}`); + assert.ok( + ids.has(`claude-sonnet-5-thinking-${effort}`), + `missing claude-sonnet-5-thinking-${effort}` + ); + } +}); From 92d98705070fdc4ecdb79018793776b1edcccfbd Mon Sep 17 00:00:00 2001 From: WITALO ROCHA Date: Fri, 10 Jul 2026 18:05:26 -0300 Subject: [PATCH 09/56] fix(translator): read PDF/video file attachments for Gemini/Antigravity and Claude (#6790) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's translator + test changes. Co-authored-by: Wital Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../6790-gemini-pdf-video-attachments.md | 1 + open-sse/translator/helpers/geminiHelper.ts | 28 ++++++-- .../translator/request/openai-to-claude.ts | 37 +++++++++- tests/unit/gemini-helper.test.ts | 36 +++++++++- .../openai-to-claude-file-attachments.test.ts | 70 +++++++++++++++++++ 5 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/6790-gemini-pdf-video-attachments.md create mode 100644 tests/unit/openai-to-claude-file-attachments.test.ts diff --git a/changelog.d/fixes/6790-gemini-pdf-video-attachments.md b/changelog.d/fixes/6790-gemini-pdf-video-attachments.md new file mode 100644 index 0000000000..9875a41091 --- /dev/null +++ b/changelog.d/fixes/6790-gemini-pdf-video-attachments.md @@ -0,0 +1 @@ +- **fix(translator):** read PDF/video `file_data` attachments on the OpenAI→Gemini/Antigravity and OpenAI→Claude paths so multimodal documents (not just images) reach the upstream — PDFs map to `document`/`inlineData` and videos keep their `video/mp4` mime instead of being dropped (#6790 — thanks @Witroch4). diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 5fd500b2da..7f45cf329d 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -172,17 +172,35 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { // 3. Handle raw data strings (e.g. {"type": "file", "data": "JVBER...", "mime_type": "..."}). // Also accept the Responses-API shape {"type":"input_file","file_data":"JVBER...","filename":...} - // so PDFs sent as `input_file` reach Gemini instead of being silently dropped (#2515). + // AND the OpenAI Chat Completions shape + // {"type":"file","file":{"filename":...,"file_data":"data:;base64,..."}} so PDFs and + // videos reach Gemini instead of being silently dropped (#2515). Gemini reads + // application/pdf and video/* natively via inlineData, exactly like images. const file = toRecord(rec.file); const doc = toRecord(rec.document); - const rawDataStr = rec.data || rec.file_data || file?.data || doc?.data; - const mimeTypeFallback = - rec.mime_type || rec.media_type || file?.mime_type || doc?.mime_type || "application/pdf"; + const rawDataStr = + rec.data || rec.file_data || file?.data || file?.file_data || doc?.data || doc?.file_data; if (typeof rawDataStr === "string" && !rawDataStr.startsWith("http")) { + // Prefer the mime embedded in the data: URI (e.g. application/pdf, video/mp4) so + // documents and videos are not mislabeled as the fallback; the fallback applies + // only to bare base64 that carries no data: prefix. + let mimeType = + rec.mime_type || + rec.media_type || + file?.mime_type || + doc?.mime_type || + "application/pdf"; + if (rawDataStr.startsWith("data:")) { + const commaIndex = rawDataStr.indexOf(","); + if (commaIndex !== -1) { + const parsedMime = rawDataStr.substring(5, commaIndex).split(";")[0]; + if (parsedMime) mimeType = parsedMime; + } + } const rawData = rawDataStr.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""); parts.push({ inlineData: { - mimeType: String(mimeTypeFallback), + mimeType: String(mimeType), data: rawData, }, }); diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 1c380b5f33..ec75bb5834 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -545,6 +545,36 @@ function getContentBlocksFromMessage( } else if (url.trim()) { blocks.push({ type: "image", source: { type: "url", url } }); } + } else if (part.type === "file" && (part.file?.file_data || part.file?.data)) { + // OpenAI Chat Completions file block: + // {type:"file", file:{filename, file_data:"data:;base64,..."}}. + // Map PDFs to a Claude document block and image mimes to an image block so the + // attachment reaches the model instead of being silently dropped. Claude has no + // native video input, so non-pdf/non-image files are skipped here. + const fileData = part.file.file_data || part.file.data; + const fmatch = + typeof fileData === "string" ? fileData.match(/^data:([^;]+);base64,(.+)$/) : null; + if (fmatch) { + const mediaType = fmatch[1]; + if (mediaType === "application/pdf") { + blocks.push({ + type: "document", + source: { type: "base64", media_type: mediaType, data: fmatch[2] }, + ...(part.file.filename ? { title: part.file.filename } : {}), + }); + } else if (mediaType.startsWith("image/")) { + blocks.push({ + type: "image", + source: { type: "base64", media_type: mediaType, data: fmatch[2] }, + }); + } + } else if (typeof fileData === "string" && /^https?:\/\//i.test(fileData)) { + blocks.push({ + type: "document", + source: { type: "url", url: fileData }, + ...(part.file.filename ? { title: part.file.filename } : {}), + }); + } } } } @@ -622,7 +652,12 @@ function getContentBlocksFromMessage( (b) => b.type === "thinking" || b.type === "redacted_thinking" ); const hasToolUseBlock = blocks.some((b) => b.type === "tool_use"); - if (msg.reasoning_content && thinkingEnabledForRequest && hasToolUseBlock && !hasThinkingBlock) { + if ( + msg.reasoning_content && + thinkingEnabledForRequest && + hasToolUseBlock && + !hasThinkingBlock + ) { blocks.unshift({ type: "redacted_thinking", data: DEFAULT_THINKING_CLAUDE_SIGNATURE, diff --git a/tests/unit/gemini-helper.test.ts b/tests/unit/gemini-helper.test.ts index 479a9d3fa9..e7a14cd858 100644 --- a/tests/unit/gemini-helper.test.ts +++ b/tests/unit/gemini-helper.test.ts @@ -20,7 +20,7 @@ test("DEFAULT_SAFETY_SETTINGS is an array", () => { test("tryParseJSON parses valid JSON", () => { assert.deepEqual(gemini.tryParseJSON('{"a":1}'), { a: 1 }); - assert.deepEqual(gemini.tryParseJSON('[1,2,3]'), [1, 2, 3]); + assert.deepEqual(gemini.tryParseJSON("[1,2,3]"), [1, 2, 3]); assert.equal(gemini.tryParseJSON('"hello"'), "hello"); assert.equal(gemini.tryParseJSON("42"), 42); assert.equal(gemini.tryParseJSON("true"), true); @@ -130,3 +130,37 @@ test("cleanJSONSchemaForAntigravity handles nested schema", () => { const result = gemini.cleanJSONSchemaForAntigravity(schema); assert.ok(typeof result === "object"); }); + +test("convertOpenAIContentToParts maps OpenAI Chat Completions file (PDF) to inlineData", () => { + const content = [ + { type: "text", text: "read this" }, + { + type: "file", + file: { filename: "doc.pdf", file_data: "data:application/pdf;base64,JVBERiAtMQ==" }, + }, + ]; + const parts = gemini.convertOpenAIContentToParts(content); + const inline = parts.find((p) => p.inlineData); + assert.ok(inline, "PDF file part must be converted to inlineData, not dropped"); + assert.equal(inline.inlineData.mimeType, "application/pdf"); + assert.equal(inline.inlineData.data, "JVBERiAtMQ=="); +}); + +test("convertOpenAIContentToParts keeps the real mime for a video file_data", () => { + const content = [ + { type: "file", file: { filename: "clip.mp4", file_data: "data:video/mp4;base64,AAAAIGZ0" } }, + ]; + const parts = gemini.convertOpenAIContentToParts(content); + const inline = parts.find((p) => p.inlineData); + assert.ok(inline, "video file part must be converted to inlineData"); + assert.equal(inline.inlineData.mimeType, "video/mp4"); + assert.equal(inline.inlineData.data, "AAAAIGZ0"); +}); + +test("convertOpenAIContentToParts still maps image_url data URIs (regression)", () => { + const content = [{ type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } }]; + const parts = gemini.convertOpenAIContentToParts(content); + const inline = parts.find((p) => p.inlineData); + assert.ok(inline, "image_url must still convert to inlineData"); + assert.equal(inline.inlineData.mimeType, "image/png"); +}); diff --git a/tests/unit/openai-to-claude-file-attachments.test.ts b/tests/unit/openai-to-claude-file-attachments.test.ts new file mode 100644 index 0000000000..727044251c --- /dev/null +++ b/tests/unit/openai-to-claude-file-attachments.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeRequest } = + await import("../../open-sse/translator/request/openai-to-claude.ts"); + +function userBlocks(model: string, content: unknown) { + const translated = openaiToClaudeRequest(model, { messages: [{ role: "user", content }] }, false); + const userMsg = translated.messages.find((m) => m.role === "user"); + assert.ok(userMsg && Array.isArray(userMsg.content), "expected a translated user message"); + return userMsg.content; +} + +test("openaiToClaudeRequest maps an OpenAI file (PDF) block to a Claude document block", () => { + const blocks = userBlocks("claude-sonnet-4", [ + { type: "text", text: "summarize" }, + { + type: "file", + file: { filename: "edital.pdf", file_data: "data:application/pdf;base64,JVBERiAtMQ==" }, + }, + ]); + const doc = blocks.find((b) => b.type === "document"); + assert.ok(doc, "PDF file block must become a Claude document block, not be dropped"); + assert.equal(doc.source.type, "base64"); + assert.equal(doc.source.media_type, "application/pdf"); + assert.equal(doc.source.data, "JVBERiAtMQ=="); + assert.equal(doc.title, "edital.pdf"); +}); + +test("openaiToClaudeRequest maps an OpenAI file (image mime) block to a Claude image block", () => { + const blocks = userBlocks("claude-sonnet-4", [ + { + type: "file", + file: { filename: "shot.png", file_data: "data:image/png;base64,iVBORw0KGgo=" }, + }, + ]); + const img = blocks.find((b) => b.type === "image"); + assert.ok(img, "image-mime file block must become a Claude image block"); + assert.equal(img.source.type, "base64"); + assert.equal(img.source.media_type, "image/png"); + assert.equal(img.source.data, "iVBORw0KGgo="); +}); + +test("openaiToClaudeRequest maps a remote file (PDF url) block to a Claude document url block", () => { + const blocks = userBlocks("claude-sonnet-4", [ + { type: "file", file: { filename: "remote.pdf", file_data: "https://example.com/a.pdf" } }, + ]); + const doc = blocks.find((b) => b.type === "document"); + assert.ok(doc, "remote PDF file block must become a Claude document url block"); + assert.equal(doc.source.type, "url"); + assert.equal(doc.source.url, "https://example.com/a.pdf"); +}); + +test("openaiToClaudeRequest skips a video file block (Claude has no native video input)", () => { + const blocks = userBlocks("claude-sonnet-4", [ + { type: "text", text: "watch this" }, + { type: "file", file: { filename: "clip.mp4", file_data: "data:video/mp4;base64,AAAAIGZ0" } }, + ]); + const doc = blocks.find((b) => b.type === "document"); + const img = blocks.find((b) => b.type === "image"); + assert.ok( + !doc && !img, + "a video file must not be mislabeled as a Claude document or image block" + ); + // the text part is still forwarded + assert.ok( + blocks.some((b) => b.type === "text"), + "the accompanying text part must still be forwarded" + ); +}); From 4b7f4b1ee24a6e13e73b074213e01a01d2c78c54 Mon Sep 17 00:00:00 2001 From: Aoxiong Yin Date: Sat, 11 Jul 2026 05:05:31 +0800 Subject: [PATCH 10/56] fix(codex): strip include from compact responses requests (#6805) * fix(codex): strip include from compact responses requests Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(6805): move include-strip assertion to standalone test file to keep executor-codex.test.ts under frozen size cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../6805-strip-include-compact-responses.md | 1 + open-sse/executors/codex.ts | 12 ++++++-- .../codex-compact-strip-include-6805.test.ts | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/6805-strip-include-compact-responses.md create mode 100644 tests/unit/codex-compact-strip-include-6805.test.ts diff --git a/changelog.d/fixes/6805-strip-include-compact-responses.md b/changelog.d/fixes/6805-strip-include-compact-responses.md new file mode 100644 index 0000000000..6c46a717a4 --- /dev/null +++ b/changelog.d/fixes/6805-strip-include-compact-responses.md @@ -0,0 +1 @@ +- **fix(codex): strip include from compact responses requests** (#6805 — thanks @yinaoxiong). diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 6bca470eb5..ceaa8bda69 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -894,7 +894,9 @@ export class CodexExecutor extends BaseExecutor { headers["chatgpt-account-id"] = workspaceId; } const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as - CodexClientIdentity | null | undefined; + | CodexClientIdentity + | null + | undefined; // Originator header — identifies the client type to the Codex backend. // Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs" @@ -1001,6 +1003,7 @@ export class CodexExecutor extends BaseExecutor { delete body.stream; delete body.stream_options; delete body.client_metadata; + delete body.include; } else { body.stream = true; } @@ -1174,6 +1177,9 @@ export class CodexExecutor extends BaseExecutor { }; } ensureCodexReasoningSummary(body); + if (isCompactRequest) { + delete body.include; + } delete body.reasoning_effort; // Remove unsupported token limit parameters BEFORE the passthrough return. @@ -1214,7 +1220,9 @@ export class CodexExecutor extends BaseExecutor { applyCodexClientMetadata( body, credentials?.providerSpecificData?.codexClientIdentity as - CodexClientIdentity | null | undefined + | CodexClientIdentity + | null + | undefined ); } diff --git a/tests/unit/codex-compact-strip-include-6805.test.ts b/tests/unit/codex-compact-strip-include-6805.test.ts new file mode 100644 index 0000000000..91e0d080c3 --- /dev/null +++ b/tests/unit/codex-compact-strip-include-6805.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { CodexExecutor } from "../../open-sse/executors/codex.ts"; + +// #6805: compact Codex requests must not forward `include` (e.g. +// "reasoning.encrypted_content") — the compact endpoint rejects it. Kept in a +// standalone file so the frozen executor-codex.test.ts does not grow past its cap. +test("CodexExecutor.transformRequest strips include from compact requests (#6805)", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.3-codex", + { + _nativeCodexPassthrough: true, + include: ["reasoning.encrypted_content"], + instructions: "keep this", + stream: false, + }, + false, + { + requestEndpointPath: "/responses/compact", + providerSpecificData: { requestDefaults: { serviceTier: "priority" } }, + } + ); + assert.equal(result.include, undefined); + assert.equal(result._nativeCodexPassthrough, undefined); + assert.equal(result.instructions, "keep this"); +}); From dd057f590e31732df37cee68ad40bc2f70d14caa Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:35:36 +0530 Subject: [PATCH 11/56] fix(i18n): translate hardcoded Portuguese dashboard strings to English (#6769) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: Chirag Singhal Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../fixes/6769-i18n-translate-pt-dashboard.md | 1 + .../compression/studio/CompareView.tsx | 2 +- .../compression/studio/PlaygroundInput.tsx | 2 +- .../context/combos/CompressionHub.tsx | 11 ++--- .../advanced/CompressionPreviewAccordion.tsx | 2 +- .../i18n-hardcoded-pt-dashboard-6761.test.ts | 44 +++++++++++++++++++ 6 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/6769-i18n-translate-pt-dashboard.md create mode 100644 tests/unit/i18n-hardcoded-pt-dashboard-6761.test.ts diff --git a/changelog.d/fixes/6769-i18n-translate-pt-dashboard.md b/changelog.d/fixes/6769-i18n-translate-pt-dashboard.md new file mode 100644 index 0000000000..590224dc79 --- /dev/null +++ b/changelog.d/fixes/6769-i18n-translate-pt-dashboard.md @@ -0,0 +1 @@ +- **fix(i18n): translate hardcoded Portuguese dashboard strings to English (#6761, #6768)** (#6769 — thanks @chirag127). diff --git a/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx b/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx index df4bf66169..985e1800ef 100644 --- a/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx +++ b/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx @@ -115,7 +115,7 @@ export function CompareView({ text }: CompareViewProps) { - + {rows.map((r) => { diff --git a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx index 9c3977a7b1..0cadf7a356 100644 --- a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx +++ b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx @@ -20,7 +20,7 @@ export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, l
EngineSavingsRetençãoOut tokFidelidade
EngineSavingsRetentionOut tokFidelity
+ + + + + + + + + + {rows.map((row, i) => ( + + + + + + + ))} + +
onToggleSort("date")} + > + {dateLabel} + onToggleSort("provider")} + > + {providerLabel} + onToggleSort("requests")} + > + {requestsLabel} + onToggleSort("totalTokens")} + > + {totalLabel} +
{row.date} +
+ + {row.provider} +
+
+ {fmtFull(row.requests)} + + {fmt(row.totalTokens)} +
+ + ); +} diff --git a/src/shared/components/analytics/index.tsx b/src/shared/components/analytics/index.tsx index 41e82ca084..a9eb9d34ee 100644 --- a/src/shared/components/analytics/index.tsx +++ b/src/shared/components/analytics/index.tsx @@ -26,3 +26,4 @@ export { DailyTrendChart, ModelOverTimeChart } from "./rechartsUsageCharts"; export { default as ApiKeyFilterDropdown } from "./ApiKeyFilterDropdown"; export { default as CustomRangePicker } from "./CustomRangePicker"; +export { default as RequestCountByProviderDateTable } from "./RequestCountByProviderDateTable"; diff --git a/src/shared/components/analytics/requestCountSort.ts b/src/shared/components/analytics/requestCountSort.ts new file mode 100644 index 0000000000..fa83588f9d --- /dev/null +++ b/src/shared/components/analytics/requestCountSort.ts @@ -0,0 +1,24 @@ +/** + * requestCountSort — pure sort helper for #4009's request-count-by-provider-date table. + * Split out of RequestCountByProviderDateTable to keep that container under the + * max-lines-per-function complexity gate. + */ + +import type { ProviderDailyUsageRow, RequestCountSortField } from "./RequestCountTable"; + +export function sortProviderDailyUsageRows( + rows: ProviderDailyUsageRow[], + sortBy: RequestCountSortField, + sortOrder: "asc" | "desc" +): ProviderDailyUsageRow[] { + const arr = [...rows]; + arr.sort((a, b) => { + const va = a[sortBy]; + const vb = b[sortBy]; + if (typeof va === "string" && typeof vb === "string") { + return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); + } + return sortOrder === "asc" ? Number(va) - Number(vb) : Number(vb) - Number(va); + }); + return arr; +} diff --git a/src/shared/components/analytics/useProviderDailyUsage.ts b/src/shared/components/analytics/useProviderDailyUsage.ts new file mode 100644 index 0000000000..da2a5265f3 --- /dev/null +++ b/src/shared/components/analytics/useProviderDailyUsage.ts @@ -0,0 +1,46 @@ +"use client"; + +/** + * useProviderDailyUsage — data hook for #4009's request-count-by-provider-date table. + * Split out of RequestCountByProviderDateTable to keep that container under the + * max-lines-per-function complexity gate. + */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { readFetchErrorMessage } from "@/shared/utils/fetchError"; +import type { ProviderDailyUsageRow } from "./RequestCountTable"; + +export function useProviderDailyUsage(range: string, dateFilter: string) { + const tCommon = useTranslations("common"); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchRows = useCallback(async () => { + try { + setLoading(true); + const params = new URLSearchParams(); + if (dateFilter) { + params.set("date", dateFilter); + } else { + params.set("range", range); + } + const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`); + if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error"))); + const data = await res.json(); + setRows(Array.isArray(data.rows) ? data.rows : []); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [range, dateFilter, tCommon]); + + useEffect(() => { + fetchRows(); + }, [fetchRows]); + + return { rows, loading, error }; +} diff --git a/tests/unit/db-provider-daily-usage-4009.test.ts b/tests/unit/db-provider-daily-usage-4009.test.ts new file mode 100644 index 0000000000..9c19f747a3 --- /dev/null +++ b/tests/unit/db-provider-daily-usage-4009.test.ts @@ -0,0 +1,137 @@ +/** + * #4009 — Request count log per provider, per date. + * + * Some providers bill by request rather than by token, so operators need a + * plain per-provider, per-date request count breakdown. Verifies + * `getProviderDailyUsageRows` (src/lib/db/usageAnalytics.ts) groups + * `usage_history` rows correctly by DATE(timestamp) + provider. + * + * Seeds an in-memory temp SQLite DB and releases the handle in test.after + * (CLAUDE.md PII/Stream Learnings #3 — otherwise node:test hangs). + */ +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(), "omni-db-provider-daily-4009-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const mod = await import("../../src/lib/db/usageAnalytics.ts"); + +function insertUsageHistory(row: Record) { + const db = core.getDbInstance(); + const full = { + provider: "openai", + model: "gpt-4.1", + tokens_input: 10, + tokens_output: 20, + tokens_cache_read: 0, + tokens_cache_creation: 0, + tokens_reasoning: 0, + service_tier: "standard", + success: 1, + latency_ms: 100, + connection_id: null, + api_key_id: null, + api_key_name: null, + ...row, + timestamp: row.timestamp ?? new Date().toISOString(), + }; + db.prepare( + `INSERT INTO usage_history ( + timestamp, provider, model, + tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, + service_tier, success, latency_ms, connection_id, api_key_id, api_key_name + ) VALUES ( + @timestamp, @provider, @model, + @tokens_input, @tokens_output, @tokens_cache_read, @tokens_cache_creation, @tokens_reasoning, + @service_tier, @success, @latency_ms, @connection_id, @api_key_id, @api_key_name + )` + ).run(full); +} + +test.before(() => { + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#4009 getProviderDailyUsageRows is exported as a function", () => { + assert.equal(typeof mod.getProviderDailyUsageRows, "function"); +}); + +test("#4009 getProviderDailyUsageRows — groups requests by date + provider", () => { + const rawCutoffDate = "2020-01-01"; + const day1 = "2026-02-10T09:00:00.000Z"; + const day2 = "2026-02-11T09:00:00.000Z"; + + // 3 requests for openai on day1, 1 for anthropic on day1, 2 for openai on day2 + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 10, tokens_output: 20 }); + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 5, tokens_output: 15 }); + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 8, tokens_output: 12 }); + insertUsageHistory({ + timestamp: day1, + provider: "anthropic", + tokens_input: 100, + tokens_output: 50, + }); + insertUsageHistory({ timestamp: day2, provider: "openai", tokens_input: 1, tokens_output: 1 }); + insertUsageHistory({ timestamp: day2, provider: "openai", tokens_input: 2, tokens_output: 2 }); + + const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({ + sinceIso: "2026-02-10T00:00:00.000Z", + untilIso: "2026-02-11T23:59:59.000Z", + rawCutoffDate, + apiKeyWhere: "", + apiKeyParams: {}, + }); + + const rows = mod.getProviderDailyUsageRows(unifiedSource, unifiedParams); + + const openaiDay1 = rows.find((r) => r.date === "2026-02-10" && r.provider === "openai"); + const anthropicDay1 = rows.find((r) => r.date === "2026-02-10" && r.provider === "anthropic"); + const openaiDay2 = rows.find((r) => r.date === "2026-02-11" && r.provider === "openai"); + + assert.ok(openaiDay1, "openai/day1 row present"); + assert.equal(openaiDay1!.requests, 3, "3 openai requests on day1"); + assert.equal(openaiDay1!.promptTokens, 23, "10+5+8 input tokens summed"); + assert.equal(openaiDay1!.completionTokens, 47, "20+15+12 output tokens summed"); + assert.equal(openaiDay1!.totalTokens, 70, "23+47 total tokens"); + + assert.ok(anthropicDay1, "anthropic/day1 row present"); + assert.equal(anthropicDay1!.requests, 1, "1 anthropic request on day1"); + + assert.ok(openaiDay2, "openai/day2 row present"); + assert.equal(openaiDay2!.requests, 2, "2 openai requests on day2 (separate from day1)"); + + // provider+date pairing must not conflate different dates for the same provider + assert.notEqual(openaiDay1!.requests, openaiDay2!.requests); +}); + +test("#4009 getProviderDailyUsageRows — lowercases provider for consistent grouping", () => { + const rawCutoffDate = "2020-01-01"; + const ts = "2026-03-01T09:00:00.000Z"; + + insertUsageHistory({ timestamp: ts, provider: "OpenAI", tokens_input: 1, tokens_output: 1 }); + insertUsageHistory({ timestamp: ts, provider: "openai", tokens_input: 1, tokens_output: 1 }); + + const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({ + sinceIso: "2026-03-01T00:00:00.000Z", + untilIso: "2026-03-01T23:59:59.000Z", + rawCutoffDate, + apiKeyWhere: "", + apiKeyParams: {}, + }); + + const rows = mod.getProviderDailyUsageRows(unifiedSource, unifiedParams); + const openaiRows = rows.filter((r) => r.date === "2026-03-01" && r.provider === "openai"); + + assert.equal(openaiRows.length, 1, "mixed-case provider values fold into one group"); + assert.equal(openaiRows[0].requests, 2, "both rows counted in the single lowercase group"); +}); From 2ef88763e4aefe8feb35b471344908621be1ae4d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:44:06 -0300 Subject: [PATCH 39/56] feat(xai): route xAI clients to Grok native /v1/responses endpoint (#6709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(xai): route xAI clients to Grok native /v1/responses endpoint xAI ships a native /v1/responses endpoint (https://api.x.ai/v1/responses) alongside /v1/chat/completions, but XaiExecutor extended BaseExecutor without overriding buildUrl(), so every request always resolved to the static chat-completions baseUrl regardless of target format — the last genuinely-missing slice of decolua/9router#2439 (grok-build-0.1, the reasoning-effort suffix routing, and bare grok-* routing were already ported in prior cycles). Add responsesBaseUrl to the xai registry entry and tag grok-4.20-multi-agent-0309 (upstream's own Responses-only id) with targetFormat: "openai-responses", mirroring the existing model-tag-driven routing pattern already used by the gh executor (9router#102) and the "openai" -pro heuristic in open-sse/executors/default.ts — the per-model registry tag is the single source of truth that also drives chatCore's body translation, so URL and body stay in lockstep. XaiExecutor.buildUrl now checks getModelTargetFormat("xai", model) and resolves to the native Responses endpoint only for tagged models, leaving every other grok-* model on the existing chat-completions bridge. TDD: tests/unit/executor-xai.test.ts adds a RED-then-GREEN case asserting grok-4.20-multi-agent-0309 resolves to https://api.x.ai/v1/responses and a control case asserting grok-4.3 still resolves to https://api.x.ai/v1/chat/completions. Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> Inspired-by: https://github.com/decolua/9router/pull/2439 * chore(6709): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> --- .../features/6709-xai-responses-endpoint.md | 1 + .../config/providers/registry/xai/index.ts | 13 ++++++++++++- open-sse/executors/xai.ts | 19 +++++++++++++++++++ tests/unit/executor-xai.test.ts | 18 ++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/6709-xai-responses-endpoint.md diff --git a/changelog.d/features/6709-xai-responses-endpoint.md b/changelog.d/features/6709-xai-responses-endpoint.md new file mode 100644 index 0000000000..d828e6a7b8 --- /dev/null +++ b/changelog.d/features/6709-xai-responses-endpoint.md @@ -0,0 +1 @@ +- **feat(xai):** route xAI clients to Grok's native `/v1/responses` endpoint instead of the chat-completions bridge. (thanks @ryanngit) diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index 501fa4fcbf..f33e247080 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -6,12 +6,23 @@ export const xaiProvider: RegistryEntry = { format: "openai", executor: "xai", baseUrl: "https://api.x.ai/v1/chat/completions", + // Port of decolua/9router#2439 (author: @ryanngit): xAI ships a native + // `/v1/responses` endpoint alongside `/v1/chat/completions`. Consumed by + // XaiExecutor.buildUrl (open-sse/executors/xai.ts) for models tagged + // targetFormat: "openai-responses" below. + responsesBaseUrl: "https://api.x.ai/v1/responses", authType: "apikey", authHeader: "bearer", models: [ { id: "grok-4.3", name: "Grok 4.3" }, { id: "grok-build-0.1", name: "Grok Build 0.1", contextLength: 256000 }, - { id: "grok-4.20-multi-agent-0309", name: "Grok 4.20 Multi Agent" }, + // Responses-only per upstream 9router#2439: xAI serves this id exclusively + // over its native /v1/responses endpoint. + { + id: "grok-4.20-multi-agent-0309", + name: "Grok 4.20 Multi Agent", + targetFormat: "openai-responses", + }, { id: "grok-4.20-0309-reasoning", name: "Grok 4.20 Reasoning" }, { id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20" }, ], diff --git a/open-sse/executors/xai.ts b/open-sse/executors/xai.ts index 9f806e2c5a..e5de5c037a 100644 --- a/open-sse/executors/xai.ts +++ b/open-sse/executors/xai.ts @@ -1,5 +1,6 @@ import { BaseExecutor, type ProviderCredentials } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; +import { getModelTargetFormat } from "../config/providerModels.ts"; type JsonRecord = Record; @@ -51,6 +52,24 @@ export class XaiExecutor extends BaseExecutor { super("xai", PROVIDERS.xai); } + /** + * Port of decolua/9router#2439 (author: @ryanngit): xAI ships a native + * `/v1/responses` endpoint alongside `/v1/chat/completions`. Models tagged + * `targetFormat: "openai-responses"` in the registry (currently + * grok-4.20-multi-agent-0309, per upstream) resolve to that endpoint instead + * of the default chat-completions bridge. The per-model registry tag is the + * single source of truth — it also drives chatCore's body translation — so + * the URL stays in lockstep with the translated body, mirroring the gh + * executor's targetFormat-driven routing (9router#102) and the "openai" + * -pro heuristic in open-sse/executors/default.ts. + */ + buildUrl(model: string, _stream: boolean, _urlIndex = 0) { + if (getModelTargetFormat("xai", model) === "openai-responses") { + return this.config.responsesBaseUrl || this.config.baseUrl; + } + return this.config.baseUrl; + } + transformRequest( model: string, body: unknown, diff --git a/tests/unit/executor-xai.test.ts b/tests/unit/executor-xai.test.ts index df5d096ecd..f8a6031e5e 100644 --- a/tests/unit/executor-xai.test.ts +++ b/tests/unit/executor-xai.test.ts @@ -92,3 +92,21 @@ test("leaves a plain, unlisted model id and body unchanged (no suffix, not allow assert.equal(out.reasoning_effort, undefined); assert.deepEqual(out.messages, body.messages); }); + +// Port of decolua/9router#2439 (author: @ryanngit): xAI ships a native +// `/v1/responses` endpoint. grok-4.20-multi-agent-0309 is tagged +// targetFormat: "openai-responses" in the registry (upstream's own tag) — it +// must resolve to xAI's native Responses URL, not the chat-completions +// bridge, mirroring the gh executor's targetFormat-driven routing (9router#102) +// and the "openai" -pro heuristic in open-sse/executors/default.ts. +test("XaiExecutor.buildUrl routes the Responses-tagged model (grok-4.20-multi-agent-0309) to xAI's native /v1/responses endpoint", () => { + const executor = new XaiExecutor(); + const url = executor.buildUrl("grok-4.20-multi-agent-0309", true); + assert.equal(url, "https://api.x.ai/v1/responses"); +}); + +test("XaiExecutor.buildUrl keeps a plain chat model (grok-4.3) on /v1/chat/completions", () => { + const executor = new XaiExecutor(); + const url = executor.buildUrl("grok-4.3", true); + assert.equal(url, "https://api.x.ai/v1/chat/completions"); +}); From 249462d1ffc581a7af16a2a257587ccc1b4d3ff7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:45:06 -0300 Subject: [PATCH 40/56] fix(resilience): route remaining credential-selection call sites through quota preflight (#6686) (#6742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(resilience): route remaining credential-selection call sites through quota preflight (#6686) * chore(6742): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) --- .../fixes/6742-quota-preflight-coverage.md | 1 + src/app/api/v1/audio/speech/route.ts | 7 +- src/app/api/v1/audio/transcriptions/route.ts | 7 +- src/app/api/v1/audio/translations/route.ts | 7 +- src/app/api/v1/images/edits/route.ts | 9 +- src/app/api/v1/images/generations/route.ts | 6 +- src/app/api/v1/moderations/route.ts | 7 +- src/app/api/v1/music/generations/route.ts | 7 +- src/app/api/v1/ocr/route.ts | 7 +- .../providers/[provider]/embeddings/route.ts | 4 +- .../[provider]/images/generations/route.ts | 4 +- src/app/api/v1/rerank/route.ts | 9 +- src/app/api/v1/search/route.ts | 20 ++- src/app/api/v1/videos/generations/route.ts | 9 +- src/app/api/v1/web/fetch/route.ts | 8 +- ...ssue-6686-quota-preflight-coverage.test.ts | 140 ++++++++++++++++++ 16 files changed, 218 insertions(+), 34 deletions(-) create mode 100644 changelog.d/fixes/6742-quota-preflight-coverage.md create mode 100644 tests/unit/issue-6686-quota-preflight-coverage.test.ts diff --git a/changelog.d/fixes/6742-quota-preflight-coverage.md b/changelog.d/fixes/6742-quota-preflight-coverage.md new file mode 100644 index 0000000000..45178354e4 --- /dev/null +++ b/changelog.d/fixes/6742-quota-preflight-coverage.md @@ -0,0 +1 @@ +- **fix(resilience):** account selection could pick an account already out of quota upstream on every credentialed route except `chat`/`codex` ([#6686](https://github.com/diegosouzapw/OmniRoute/issues/6686)) — `getProviderCredentials()` (`src/sse/services/auth.ts`) only skips a connection when a *local cache* already flags it exhausted (`isQuotaExhaustedForRequest`/`src/domain/quotaCache.ts`); it never itself calls the registered upstream `QuotaFetcher`. Only `getProviderCredentialsWithQuotaPreflight()` performs that live upstream check, and it was wired into exactly 2 call sites (`src/sse/handlers/chat.ts`, `src/app/api/internal/codex-responses-ws/route.ts`) — every other credentialed route (`rerank`, `images/generations`, `images/edits`, `audio/transcriptions|speech|translations`, `videos/generations`, `music/generations`, `ocr`, `providers/[provider]/embeddings`, `providers/[provider]/images/generations`, `web/fetch`, `moderations`, `search`) called the plain, cache-only selector, so an account whose cache entry was never populated (e.g. its first request landed on one of these routes) could be selected even at 0% quota remaining. Those 14 call sites now go through `getProviderCredentialsWithQuotaPreflight()` instead, matching chat/codex coverage. Regression guard: `tests/unit/issue-6686-quota-preflight-coverage.test.ts` (static check that none of the routes call the plain selector anymore + a behavioral check that the preflight-aware selector blocks a 100%-used account). diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 2b3ed2173c..576721f522 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -1,6 +1,9 @@ import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { parseSpeechModel, getSpeechProvider, @@ -95,7 +98,7 @@ async function postHandler(request, context) { // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentials(provider); + credentials = await getProviderCredentialsWithQuotaPreflight(provider); if (!credentials) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } diff --git a/src/app/api/v1/audio/transcriptions/route.ts b/src/app/api/v1/audio/transcriptions/route.ts index 7f55b374a7..3087d260d5 100644 --- a/src/app/api/v1/audio/transcriptions/route.ts +++ b/src/app/api/v1/audio/transcriptions/route.ts @@ -1,7 +1,10 @@ // Allow large audio/video file uploads — 5min for processing large files (up to 2GB) export const maxDuration = 300; import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.ts"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { parseTranscriptionModel, getTranscriptionProvider, @@ -96,7 +99,7 @@ export async function POST(request) { // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentials(provider); + credentials = await getProviderCredentialsWithQuotaPreflight(provider); if (!credentials) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } diff --git a/src/app/api/v1/audio/translations/route.ts b/src/app/api/v1/audio/translations/route.ts index 11dca26ca4..622afcfe29 100644 --- a/src/app/api/v1/audio/translations/route.ts +++ b/src/app/api/v1/audio/translations/route.ts @@ -1,7 +1,10 @@ // Allow large audio/video file uploads — 5min for processing large files (up to 2GB) export const maxDuration = 300; import { handleAudioTranslation } from "@omniroute/open-sse/handlers/audioTranslation.ts"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { parseTranslationModel, getTranslationProvider, @@ -98,7 +101,7 @@ export async function POST(request) { // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentials(provider); + credentials = await getProviderCredentialsWithQuotaPreflight(provider); if (!credentials) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 089282f8f7..0843173656 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -3,7 +3,10 @@ import { handleOpenAIImageEdit, } from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { parseImageModel, getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts"; import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -172,7 +175,7 @@ async function postHandler(request: Request, context) { // chatgpt-web keeps its conversation-continuation edit flow unchanged. if (providerConfig?.format === "chatgpt-web") { - const credentials = await getProviderCredentials( + const credentials = await getProviderCredentialsWithQuotaPreflight( parsed.provider, null, allowedConnections, @@ -241,7 +244,7 @@ async function postHandler(request: Request, context) { ); } - const credentials = await getProviderCredentials( + const credentials = await getProviderCredentialsWithQuotaPreflight( customProviderId, null, allowedConnections, diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index f1b517f4c9..8c5d090349 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -1,7 +1,7 @@ import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; import { - getProviderCredentials, + getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, extractApiKey, isValidApiKey, @@ -171,7 +171,7 @@ async function postHandler(request, context) { // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentials(provider); + credentials = await getProviderCredentialsWithQuotaPreflight(provider); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -187,7 +187,7 @@ async function postHandler(request, context) { ); } } else if (isCustomModel) { - credentials = await getProviderCredentials(provider); + credentials = await getProviderCredentialsWithQuotaPreflight(provider); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/src/app/api/v1/moderations/route.ts b/src/app/api/v1/moderations/route.ts index 359e8f76f8..36fb4aa75a 100644 --- a/src/app/api/v1/moderations/route.ts +++ b/src/app/api/v1/moderations/route.ts @@ -1,5 +1,8 @@ import { handleModeration } from "@omniroute/open-sse/handlers/moderations.ts"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; @@ -52,7 +55,7 @@ async function postHandler(request, context) { // Default to openai if no provider prefix const resolvedProvider = provider || "openai"; - const credentials = await getProviderCredentials(resolvedProvider); + const credentials = await getProviderCredentialsWithQuotaPreflight(resolvedProvider); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/src/app/api/v1/music/generations/route.ts b/src/app/api/v1/music/generations/route.ts index 2d4b278c93..c4ce53077d 100644 --- a/src/app/api/v1/music/generations/route.ts +++ b/src/app/api/v1/music/generations/route.ts @@ -1,6 +1,9 @@ import { handleMusicGeneration } from "@omniroute/open-sse/handlers/musicGeneration.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { parseMusicModel, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -72,7 +75,7 @@ async function postHandler(request, context) { // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentials(provider); + credentials = await getProviderCredentialsWithQuotaPreflight(provider); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/src/app/api/v1/ocr/route.ts b/src/app/api/v1/ocr/route.ts index 8b717c7a58..1304792722 100644 --- a/src/app/api/v1/ocr/route.ts +++ b/src/app/api/v1/ocr/route.ts @@ -1,5 +1,8 @@ import { handleOcr } from "@omniroute/open-sse/handlers/ocr.ts"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; import { parseOcrModel } from "@omniroute/open-sse/config/ocrRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; @@ -52,7 +55,7 @@ async function postHandler(request, context) { // Default to mistral if no provider prefix const resolvedProvider = provider || "mistral"; - const credentials = await getProviderCredentials(resolvedProvider); + const credentials = await getProviderCredentialsWithQuotaPreflight(resolvedProvider); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/src/app/api/v1/providers/[provider]/embeddings/route.ts b/src/app/api/v1/providers/[provider]/embeddings/route.ts index f5329addb6..01dbe5bc84 100644 --- a/src/app/api/v1/providers/[provider]/embeddings/route.ts +++ b/src/app/api/v1/providers/[provider]/embeddings/route.ts @@ -2,7 +2,7 @@ import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/er import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; import { - getProviderCredentials, + getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, extractApiKey, isValidApiKey, @@ -71,7 +71,7 @@ export async function POST(request, { params }) { } } - const credentials = await getProviderCredentials(providerEntry.id); + const credentials = await getProviderCredentialsWithQuotaPreflight(providerEntry.id); if (!credentials) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${rawProvider}`); } diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.ts b/src/app/api/v1/providers/[provider]/images/generations/route.ts index 66555796e0..f80f580b78 100644 --- a/src/app/api/v1/providers/[provider]/images/generations/route.ts +++ b/src/app/api/v1/providers/[provider]/images/generations/route.ts @@ -2,7 +2,7 @@ import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGenerat import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { - getProviderCredentials, + getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, extractApiKey, isValidApiKey, @@ -68,7 +68,7 @@ export async function POST(request, { params }) { ); } - const credentials = await getProviderCredentials(rawProvider); + const credentials = await getProviderCredentialsWithQuotaPreflight(rawProvider); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index 13283a5ca1..a2f60bb623 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -1,5 +1,8 @@ import { handleRerank } from "@omniroute/open-sse/handlers/rerank.ts"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; import { parseRerankModel, getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; @@ -102,7 +105,7 @@ async function postHandler(request, context) { if (provider) { // Cloud provider matched - const credentials = await getProviderCredentials(provider); + const credentials = await getProviderCredentialsWithQuotaPreflight(provider); if (!credentials) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } @@ -132,7 +135,7 @@ async function postHandler(request, context) { const localProvider = localProviders.find((p) => p.id === prefix); if (localProvider) { - const credentials = await getProviderCredentials(localProvider.providerId); + const credentials = await getProviderCredentialsWithQuotaPreflight(localProvider.providerId); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index 40f5fa5694..3290f26d29 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -1,5 +1,9 @@ import { handleSearch } from "@omniroute/open-sse/handlers/search.ts"; -import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + extractApiKey, + isValidApiKey, +} from "@/sse/services/auth"; import { getAllSearchProviders, getSearchProvider, @@ -64,13 +68,15 @@ type SearchCredentials = Record; type SearchCredentialLookup = SearchCredentials | RateLimitedCredentials | null; async function resolveSearchCredentials(providerId: string): Promise { - const credentials = await getProviderCredentials(providerId).catch(() => null); + const credentials = await getProviderCredentialsWithQuotaPreflight(providerId).catch(() => null); if (credentials && !isAllRateLimitedCredentials(credentials)) return credentials; const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[providerId]; if (!fallbackId) return credentials; - const fallbackCredentials = await getProviderCredentials(fallbackId).catch(() => null); + const fallbackCredentials = await getProviderCredentialsWithQuotaPreflight(fallbackId).catch( + () => null + ); if (fallbackCredentials && !isAllRateLimitedCredentials(fallbackCredentials)) { return fallbackCredentials; } @@ -180,7 +186,9 @@ async function postHandler(request: Request, context: unknown) { // Sort by cost to find cheapest with credentials (fallback-only providers // are reached via the last-resort step below, never the primary pick). const sortedIds = Object.values(SEARCH_PROVIDERS) - .filter((provider) => !provider.fallbackOnly && supportsSearchType(provider, body.search_type)) + .filter( + (provider) => !provider.fallbackOnly && supportsSearchType(provider, body.search_type) + ) .sort((a, b) => a.costPerQuery - b.costPerQuery) .map((p) => p.id); @@ -216,7 +224,9 @@ async function postHandler(request: Request, context: unknown) { // Find alternate for failover — must bind credentials to the matched provider. // Exclude fallback-only providers; they are only used by the last-resort step. const otherIds = Object.values(SEARCH_PROVIDERS) - .filter((provider) => !provider.fallbackOnly && supportsSearchType(provider, body.search_type)) + .filter( + (provider) => !provider.fallbackOnly && supportsSearchType(provider, body.search_type) + ) .sort((a, b) => a.costPerQuery - b.costPerQuery) .map((p) => p.id) .filter((id) => id !== providerConfig.id); diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index cf482df797..f1c3693d56 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -1,7 +1,10 @@ import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts"; import { resolveVideoCredentialProvider } from "@omniroute/open-sse/handlers/videoGeneration/googleFlow.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; -import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; import { parseVideoModel, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -75,7 +78,9 @@ async function postHandler(request, context) { // OAuth credential (resolveVideoCredentialProvider maps googleflow → antigravity). let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentials(resolveVideoCredentialProvider(provider)); + credentials = await getProviderCredentialsWithQuotaPreflight( + resolveVideoCredentialProvider(provider) + ); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/src/app/api/v1/web/fetch/route.ts b/src/app/api/v1/web/fetch/route.ts index 77ffabe2a9..c32466ab9b 100644 --- a/src/app/api/v1/web/fetch/route.ts +++ b/src/app/api/v1/web/fetch/route.ts @@ -12,7 +12,11 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { handleWebFetch } from "@omniroute/open-sse/handlers/webFetch.ts"; import * as log from "@/sse/utils/logger"; -import { extractApiKey, isValidApiKey, getProviderCredentials } from "@/sse/services/auth"; +import { + extractApiKey, + isValidApiKey, + getProviderCredentialsWithQuotaPreflight, +} from "@/sse/services/auth"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; import { v1WebFetchSchema } from "@/shared/validation/schemas"; @@ -38,7 +42,7 @@ async function resolveCredentials( providerId: WebFetchProviderId ): Promise<{ apiKey?: string } | null> { try { - const creds = await getProviderCredentials(providerId); + const creds = await getProviderCredentialsWithQuotaPreflight(providerId); return creds ?? null; } catch { return null; diff --git a/tests/unit/issue-6686-quota-preflight-coverage.test.ts b/tests/unit/issue-6686-quota-preflight-coverage.test.ts new file mode 100644 index 0000000000..488ee55f58 --- /dev/null +++ b/tests/unit/issue-6686-quota-preflight-coverage.test.ts @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import os from "node:os"; + +// Regression guard for issue #6686: +// "Account selection can pick accounts already out of quota (no live quota +// preflight outside chat/codex)." +// +// Root cause: getProviderCredentials() (src/sse/services/auth.ts) only skips +// a connection when a LOCAL CACHE already flags it exhausted +// (isQuotaExhaustedForRequest / src/domain/quotaCache.ts). It never itself +// calls the registered upstream QuotaFetcher. Only +// getProviderCredentialsWithQuotaPreflight() performs that live upstream +// check, and before this fix it was wired into exactly 2 call sites +// (src/sse/handlers/chat.ts, src/app/api/internal/codex-responses-ws). +// Every other credentialed route called the plain selector, so an account +// whose local cache entry was never populated (e.g. its first request landed +// on a non-chat/codex route) could be selected even at 0% quota remaining. +// +// The fix routes those remaining call sites through +// getProviderCredentialsWithQuotaPreflight() instead. This test has two +// parts: +// 1) A static, file-content check that every affected route no longer +// calls the plain, cache-only selector. +// 2) A behavioral check that the preflight-aware selector — now used by +// every credentialed route — genuinely blocks an account reported +// 100% used by a registered upstream quota fetcher. + +const repoRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); + +const ROUTES_REQUIRING_QUOTA_PREFLIGHT = [ + "src/app/api/v1/rerank/route.ts", + "src/app/api/v1/images/generations/route.ts", + "src/app/api/v1/images/edits/route.ts", + "src/app/api/v1/audio/transcriptions/route.ts", + "src/app/api/v1/audio/speech/route.ts", + "src/app/api/v1/audio/translations/route.ts", + "src/app/api/v1/videos/generations/route.ts", + "src/app/api/v1/music/generations/route.ts", + "src/app/api/v1/ocr/route.ts", + "src/app/api/v1/providers/[provider]/embeddings/route.ts", + "src/app/api/v1/providers/[provider]/images/generations/route.ts", + "src/app/api/v1/web/fetch/route.ts", + "src/app/api/v1/moderations/route.ts", + "src/app/api/v1/search/route.ts", +]; + +test("#6686: previously-plain-selector routes must call the quota-preflight-aware selector, not the plain cache-only one", () => { + for (const relPath of ROUTES_REQUIRING_QUOTA_PREFLIGHT) { + const filePath = path.join(repoRoot, relPath); + const source = fs.readFileSync(filePath, "utf8"); + + // A bare `getProviderCredentials(` call (not immediately followed by + // `WithQuotaPreflight`) means live upstream quota is never checked before + // the account is used for this route — the exact #6686 gap. + const bareCalls = source.match(/getProviderCredentials(?!WithQuotaPreflight)\(/g) || []; + assert.equal( + bareCalls.length, + 0, + `${relPath} must not call the plain getProviderCredentials() — use ` + + `getProviderCredentialsWithQuotaPreflight() instead (issue #6686)` + ); + + assert.match( + source, + /getProviderCredentialsWithQuotaPreflight/, + `${relPath} is expected to select credentials via ` + + `getProviderCredentialsWithQuotaPreflight (issue #6686)` + ); + } +}); + +test("#6686: getProviderCredentialsWithQuotaPreflight (now used by every credentialed route) blocks an account already 100% out of quota upstream", async () => { + const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-issue-6686-")); + process.env.DATA_DIR = TEST_DATA_DIR; + process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "issue-6686-secret"; + + const core = await import("../../src/lib/db/core.ts"); + const providersDb = await import("../../src/lib/db/providers.ts"); + const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + const auth = await import("../../src/sse/services/auth.ts"); + const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); + + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + + try { + const provider = "issue6686"; + + const account = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: "issue-6686-exhausted", + apiKey: "sk-issue-6686-exhausted", + isActive: true, + testStatus: "active", + // Same shape used by every affected route's selection call — no + // cooldown, no rate-limit, nothing that would trip the reactive + // filters. Only a live upstream check can catch this account. + providerSpecificData: { + quotaPreflightEnabled: true, + }, + }); + + // A registered upstream quota fetcher — what + // getProviderCredentialsWithQuotaPreflight() calls to discover the + // account is exhausted before the request is sent. + quotaPreflight.registerQuotaFetcher(provider, async () => ({ + used: 100, + total: 100, + percentUsed: 1.0, + resetAt: new Date(Date.now() + 60_000).toISOString(), + })); + + const preflightSelection = await auth.getProviderCredentialsWithQuotaPreflight( + provider, + null, + null, + null + ); + const preflightResult = preflightSelection as { + allRateLimited?: boolean; + connectionId?: string; + } | null; + + assert.ok( + preflightResult?.allRateLimited === true || preflightResult?.connectionId !== account.id, + "getProviderCredentialsWithQuotaPreflight should correctly block the exhausted account" + ); + } finally { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); From 33ca6caef3b085ce67ba21ec90a05eb3a89c32ed Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:47:05 -0300 Subject: [PATCH 41/56] fix(resilience): apikey-provider 429s honor explicit quota-exhausted text (#6638) (#6731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(resilience): apikey-provider 429s honor explicit quota-exhausted text (#6638) Ollama Cloud (and any other apikey-category provider) 429s skipped body-text quota classification entirely; a genuine multi-day quota exhaustion was misclassified as a plain rate_limit_exceeded with a few seconds of cooldown, so combo routing retried the account immediately. shouldPreserveQuotaSignals() now lets an explicit quota-exhausted signal (looksLikeQuotaExhausted) override the apikey-category default, and parseDayGranularityResetMs() adds day- granularity reset-hint parsing ("...reset in 3 days.") alongside the existing Xh/Ym/Zs parsing. Regression guard: tests/unit/issue-6638-ollama-quota.test.ts (RED before the fix, GREEN after). Aligned two tests/unit/account-fallback-service.test.ts cases that had codified the old buggy behavior for apikey-provider quota text. * chore(6731): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) --- .../fixes/6731-apikey-429-quota-exhausted.md | 1 + open-sse/services/accountFallback.ts | 12 ++--- open-sse/services/quotaResetParsing.ts | 44 +++++++++++++++++++ tests/unit/account-fallback-service.test.ts | 10 ++--- tests/unit/issue-6638-ollama-quota.test.ts | 33 ++++++++++++++ 5 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixes/6731-apikey-429-quota-exhausted.md create mode 100644 open-sse/services/quotaResetParsing.ts create mode 100644 tests/unit/issue-6638-ollama-quota.test.ts diff --git a/changelog.d/fixes/6731-apikey-429-quota-exhausted.md b/changelog.d/fixes/6731-apikey-429-quota-exhausted.md new file mode 100644 index 0000000000..bdfabc45ef --- /dev/null +++ b/changelog.d/fixes/6731-apikey-429-quota-exhausted.md @@ -0,0 +1 @@ +- **fix(resilience):** OmniRoute didn't respect an exhausted Ollama Cloud (or any other apikey-category provider) quota — it retried the account seconds later instead of waiting out the real reset window ([#6638](https://github.com/diegosouzapw/OmniRoute/issues/6638)) — `shouldPreserveQuotaSignalsFor429()`/`checkFallbackError()` (`open-sse/services/accountFallback.ts`) only applied body-text quota classification (daily/monthly/weekly quota-exhausted detection) to OAuth-category providers; apikey-category 429s (Ollama Cloud, OpenAI, etc.) always fell through to the generic short rate-limit cooldown regardless of what the error body said, and `parseRetryFromErrorText()` also had no support for day-granularity reset hints ("Your quota will reset in 3 days.") — only Xh/Ym/Zs combos. An explicit quota-exhausted signal in the body (`looksLikeQuotaExhausted()`) now overrides the apikey-category default via the new `shouldPreserveQuotaSignals()` (`open-sse/services/quotaResetParsing.ts`), and `parseDayGranularityResetMs()` parses whole-day reset countdowns so the real multi-day window is honored instead of a few seconds of backoff. Regression guard: `tests/unit/issue-6638-ollama-quota.test.ts` + 2 aligned `tests/unit/account-fallback-service.test.ts` cases that previously asserted the buggy rate_limit_exceeded/undefined-dailyQuotaExhausted behavior for apikey-provider quota text. diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index d37acc6752..f0289340cb 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -35,6 +35,7 @@ import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts"; import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { parseRetryHintFromJsonBody } from "./retryAfterJson.ts"; +import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts"; export type ProviderProfile = { baseCooldownMs: number; @@ -364,11 +365,6 @@ export function getProviderProfile(provider: string): ProviderProfile { return buildProviderProfile(category); } -function shouldPreserveQuotaSignalsFor429(provider: string | null | undefined): boolean { - if (!provider) return true; - return getProviderCategory(provider) === "oauth"; -} - export async function getRuntimeProviderProfile(provider: string | null | undefined) { try { const { getCachedSettings } = await import("@/lib/db/readCache"); @@ -676,7 +672,7 @@ export function shouldMarkAccountExhaustedFrom429( // without making this one look quota-depleted for 5 minutes. if (failureKind === "rate_limit" || failureKind === "transient") return false; return ( - shouldPreserveQuotaSignalsFor429(provider) && + shouldPreserveQuotaSignals(provider) && !hasPerModelQuota(provider, model, connectionPassthroughModels) ); } @@ -1071,7 +1067,7 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { return computeDurationMs(resetsInMatch); } - return null; + return parseDayGranularityResetMs(msg, MAX_PROVIDER_COOLDOWN_MS); } /** @@ -1417,7 +1413,7 @@ export function checkFallbackError( } const isRateLimitStatus = status === HTTP_STATUS.RATE_LIMITED; - const preserveQuota429 = shouldPreserveQuotaSignalsFor429(provider); + const preserveQuota429 = shouldPreserveQuotaSignals(provider, errorText); const shouldUseQuotaSignal = !isRateLimitStatus || preserveQuota429; // Check error message FIRST - specific patterns take priority over status codes diff --git a/open-sse/services/quotaResetParsing.ts b/open-sse/services/quotaResetParsing.ts new file mode 100644 index 0000000000..b3606b02a2 --- /dev/null +++ b/open-sse/services/quotaResetParsing.ts @@ -0,0 +1,44 @@ +import { looksLikeQuotaExhausted } from "../../src/shared/utils/classify429"; +import { getProviderCategory } from "../config/providerRegistry.ts"; + +/** + * Issue #6638 — Ollama Cloud (and any other apikey-category provider) 429s + * skip body-text quota classification by default: a bare 429 usually just + * means "too many requests/min" for these providers, so a short exponential + * backoff applies instead of the long cooldown reserved for genuine + * daily/monthly/weekly quota exhaustion. + * + * That default is correct for plain rate limiting, but it must not swallow + * an EXPLICIT quota-exhausted signal in the body (see `looksLikeQuotaExhausted` + * / QUOTA_PATTERNS) — otherwise the account looks "available" again seconds + * after a multi-day quota was exhausted, and combo routing retries it right + * away (the reported symptom). OAuth-category providers always preserve + * quota signals; apikey-category providers only do when the body explicitly + * says a long-period cap was hit. + */ +export function shouldPreserveQuotaSignals( + provider: string | null | undefined, + errorText?: string | null +): boolean { + if (!provider) return true; + if (getProviderCategory(provider) === "oauth") return true; + return Boolean(errorText) && looksLikeQuotaExhausted(errorText); +} + +/** + * Parse a day-granularity quota reset countdown ("Your quota will reset in + * 3 days.", "Resets in 13 days") out of an upstream 429 body. + * + * Companion to the Xh/Ym/Zs countdown parsing already handled inline by + * `parseRetryFromErrorText` — none of those patterns match when the upstream + * expresses the reset window in whole days rather than hours/minutes/seconds, + * so a multi-day quota reset previously parsed to `null` and fell back to the + * engine's ~seconds-scale default cooldown. + */ +export function parseDayGranularityResetMs(msg: string, maxMs: number): number | null { + const dayMatch = /reset(?:s)?\s+in\s+(\d+)\s*day(?:s)?/i.exec(msg); + if (!dayMatch) return null; + const days = Number.parseInt(dayMatch[1], 10); + if (!Number.isFinite(days) || days <= 0) return null; + return Math.min(days * 24 * 3600 * 1000, maxMs); +} diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 02fd6ed006..768f21f231 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -202,11 +202,11 @@ test("checkFallbackError preserves OAuth 429 exhausted-credit semantics", () => assert.equal(result.cooldownMs, COOLDOWN_MS.paymentRequired ?? 3600 * 1000); }); -test("checkFallbackError keeps API-key 429 quota text on the status-based resilience path", () => { +test("#6638: checkFallbackError classifies API-key 429 explicit quota text as quota_exhausted", () => { const result = checkFallbackError(429, "quota exceeded", 0, null, "openai", null, makeProfile()); assert.equal(result.shouldFallback, true); - assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); assert.equal(result.cooldownMs, 125); }); @@ -844,7 +844,7 @@ test("checkFallbackError routes API-key 429 'try again tomorrow' through resilie assert.equal(result.cooldownMs, 125); }); -test("checkFallbackError routes API-key 429 'daily quota' text through resilience cooldown", () => { +test("#6638: checkFallbackError routes API-key 429 'daily quota' text as quota_exhausted", () => { const result = checkFallbackError( 429, "You have exceeded your daily quota", @@ -855,8 +855,8 @@ test("checkFallbackError routes API-key 429 'daily quota' text through resilienc makeProfile() ); assert.equal(result.shouldFallback, true); - assert.equal(result.dailyQuotaExhausted, undefined); - assert.equal(result.cooldownMs, 125); + assert.equal(result.dailyQuotaExhausted, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); }); test("checkFallbackError preserves OAuth 429 daily quota semantics", () => { diff --git a/tests/unit/issue-6638-ollama-quota.test.ts b/tests/unit/issue-6638-ollama-quota.test.ts new file mode 100644 index 0000000000..79eb8fc4ca --- /dev/null +++ b/tests/unit/issue-6638-ollama-quota.test.ts @@ -0,0 +1,33 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { checkFallbackError } from "../../open-sse/services/accountFallback.ts"; + +// Repro for GitHub issue #6638: "OmniRoute doesn't respect exhausted quotas" +test("#6638: Ollama Cloud weekly-quota-exhausted 429 must NOT get a short generic rate-limit cooldown", () => { + const errorText = JSON.stringify({ + error: "You have exceeded your weekly usage quota. Your quota will reset in 3 days.", + }); + + const result = checkFallbackError( + 429, + errorText, + 0, + "deepseek-v4-pro", + "ollama-cloud", + null, + null, + undefined + ); + + console.log("checkFallbackError result:", result); + + assert.equal( + result.reason, + "quota_exhausted", + `expected reason "quota_exhausted" but got "${result.reason}" — quota text is being ignored for apikey-category 429s` + ); + assert.ok( + result.cooldownMs > 60 * 60 * 1000, + `expected a long (>1h) cooldown reflecting the weekly quota reset, got ${result.cooldownMs}ms` + ); +}); From 2e3508186aade916b3a56679b5224bed02a34e27 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:50:38 -0300 Subject: [PATCH 42/56] feat(resilience): weekly-429 cooldown for fetcher-less providers (#3709) (#6817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(resilience): weekly-429 cooldown for fetcher-less providers (#3709) Ollama Cloud free-tier accounts have a hard WEEKLY request cap. On cap the upstream returns 429 "you () have reached your weekly usage limit", but ollama-cloud is an apikey-category provider, so the existing oauth-only shouldUseQuotaSignal gate in checkFallbackError skips the subscription-quota-text classifier (Issue #2321) for its 429s -- the account fell through to the generic exponential backoff (~1s, capped at 2min) and got retried every few minutes for the rest of the week (one account took 285x429 in 48h). Adds a new, ungated weekly-usage-limit text classifier that applies a 24h QUOTA_EXHAUSTED cooldown regardless of provider category. Extracted the new classifier -- together with the existing #2321 subscription-quota logic -- into a new open-sse/services/quotaTextCooldowns.ts module so the frozen accountFallback.ts (file-size-baseline cap) didn't have to grow; net effect shrinks accountFallback.ts by 20 lines. This is Phase A of the plan (open-sse/services/accountFallback.ts:1038-1045 "weekly-429 cooldown"); Phase B (generic local request-counter preflight for manual provider_plans dimensions) is a separate, larger follow-up per the plan's own phasing. * chore(6817): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) --- .../fixes/6817-ollama-cloud-weekly-quota.md | 1 + open-sse/services/accountFallback.ts | 61 ++++------ open-sse/services/quotaTextCooldowns.ts | 105 ++++++++++++++++++ ...a-cloud-weekly-quota-cooldown-3709.test.ts | 103 +++++++++++++++++ 4 files changed, 230 insertions(+), 40 deletions(-) create mode 100644 changelog.d/fixes/6817-ollama-cloud-weekly-quota.md create mode 100644 open-sse/services/quotaTextCooldowns.ts create mode 100644 tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts diff --git a/changelog.d/fixes/6817-ollama-cloud-weekly-quota.md b/changelog.d/fixes/6817-ollama-cloud-weekly-quota.md new file mode 100644 index 0000000000..9ff2857cf5 --- /dev/null +++ b/changelog.d/fixes/6817-ollama-cloud-weekly-quota.md @@ -0,0 +1 @@ +- **fix(resilience):** an Ollama Cloud (or any apikey-category provider) account that hit a weekly usage cap kept getting retried every few minutes instead of backing off ([#3709](https://github.com/diegosouzapw/OmniRoute/issues/3709)) — the upstream 429 body ("you (\) have reached your weekly usage limit") was invisible to `checkFallbackError`'s existing subscription-quota-text classifier (Issue #2321) because that branch is gated by `shouldUseQuotaSignal`, which is oauth-only, so apikey providers like `ollama-cloud` fell through to the generic exponential backoff (~1s, capped at 2min) — one account took 285x429 in 48h. A new `isWeeklyUsageLimitText`/`buildWeeklyQuotaFallback` classifier (extracted, with the existing subscription-quota logic, into a new `open-sse/services/quotaTextCooldowns.ts` module so the frozen `accountFallback.ts` didn't have to grow) runs unconditionally and applies a 24h `QUOTA_EXHAUSTED` cooldown regardless of provider category. Regression guard: `tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts`. diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index f0289340cb..92e7fb65b9 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -35,6 +35,11 @@ import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts"; import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { parseRetryHintFromJsonBody } from "./retryAfterJson.ts"; +import { + isSubscriptionQuotaText, + buildSubscriptionQuotaFallback, + buildWeeklyQuotaFallback, +} from "./quotaTextCooldowns.ts"; import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts"; export type ProviderProfile = { @@ -1084,16 +1089,6 @@ function computeDurationMs(match: RegExpMatchArray): number | null { return totalMs > 0 ? Math.min(totalMs, MAX_PROVIDER_COOLDOWN_MS) : null; } -function isSubscriptionQuotaText(lower: string): boolean { - return ( - lower.includes("usage limit reached") || - lower.includes("usage limit has been") || - lower.includes("claude pro usage limit") || - lower.includes("you've reached your usage limit") || - lower.includes("you have reached your usage limit") - ); -} - // ─── Error Classification ─────────────────────────────────────────────────── /** @@ -1451,37 +1446,23 @@ export function checkFallbackError( }; } - // Issue #2321: Anthropic OAuth (Claude Pro/Team) returns 429 with - // "Usage Limit Reached" for the 5-hour subscription quota. The - // pattern-based classifier now flags these as QUOTA_EXHAUSTED, but - // without a dedicated branch the request would still fall through to - // the generic 429 retry path (~5s base cooldown). Honor upstream - // Retry-After / reset hints only when the profile enables them; - // otherwise apply a local 1h cooldown so all Pro accounts on the same - // subscription tier stop cycling through tight retries without letting - // upstream-provided windows bypass the operator setting. (We - // deliberately do not use COOLDOWN_MS.paymentRequired here — that - // constant is 2 minutes, which is shorter than the recovery time of a - // subscription quota.) - if ( - shouldUseQuotaSignal && - !isCreditsExhausted(errorStr) && - !isDailyQuotaExhausted(errorStr) && - isSubscriptionQuotaText(errorStr.toLowerCase()) - ) { - // getUpstreamRetryHintMs() gates both headers and body reset text on - // profile.useUpstreamRetryHints. - const hintMs = getUpstreamRetryHintMs(); - const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour - const bodyHint = parseRetryFromErrorText(errorStr); - return { - shouldFallback: true, - cooldownMs: hintMs ?? SUBSCRIPTION_QUOTA_COOLDOWN_MS, - reason: RateLimitReason.QUOTA_EXHAUSTED, - usedUpstreamRetryHint: Boolean(hintMs), - quotaResetHintMs: bodyHint ?? undefined, - }; + // Issue #2321 (5h subscription quota) + Issue #3709 (ollama-cloud weekly + // cap): both classifiers live in quotaTextCooldowns.ts (this file is + // frozen at its file-size-baseline cap). The weekly check runs + // UNCONDITIONALLY (not gated by shouldUseQuotaSignal) because it targets + // apikey-category providers like ollama-cloud, which the oauth-only + // shouldUseQuotaSignal gate deliberately excludes from the subscription + // check above. + if (shouldUseQuotaSignal && !isCreditsExhausted(errorStr) && !isDailyQuotaExhausted(errorStr)) { + const subResult = buildSubscriptionQuotaFallback( + errorStr, + getUpstreamRetryHintMs, + parseRetryFromErrorText + ); + if (subResult) return subResult; } + const weeklyResult = buildWeeklyQuotaFallback(errorStr); + if (weeklyResult) return weeklyResult; const quotaResetHintMs = parseRetryFromErrorText(errorStr); if ( diff --git a/open-sse/services/quotaTextCooldowns.ts b/open-sse/services/quotaTextCooldowns.ts new file mode 100644 index 0000000000..0146d72ea5 --- /dev/null +++ b/open-sse/services/quotaTextCooldowns.ts @@ -0,0 +1,105 @@ +/** + * Text-based quota-exhaustion classifiers for the account-fallback engine. + * + * Extracted out of `accountFallback.ts` (frozen at its file-size-baseline + * cap — see `config/quality/file-size-baseline.json`) so a new quota-text + * signal (Issue #3709) could be added without growing that file. These are + * pure functions with no dependency back on `accountFallback.ts`, so there + * is no circular import (`npm run check:cycles`). + * + * @module services/quotaTextCooldowns + */ + +import { RateLimitReason } from "../config/constants.ts"; + +type RateLimitReasonValue = (typeof RateLimitReason)[keyof typeof RateLimitReason]; + +export interface QuotaTextFallback { + shouldFallback: true; + cooldownMs: number; + reason: RateLimitReasonValue; + usedUpstreamRetryHint?: boolean; + quotaResetHintMs?: number; +} + +// ─── Issue #2321 — Subscription (5h) usage-limit text ────────────────────── +// +// Anthropic OAuth (Claude Pro/Team) returns 429 with "Usage Limit Reached" +// for the 5-hour subscription quota. Without a dedicated branch the request +// falls through to the generic 429 retry path (~5s base cooldown). + +export function isSubscriptionQuotaText(lower: string): boolean { + return ( + lower.includes("usage limit reached") || + lower.includes("usage limit has been") || + lower.includes("claude pro usage limit") || + lower.includes("you've reached your usage limit") || + lower.includes("you have reached your usage limit") + ); +} + +const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour + +/** + * Builds the QUOTA_EXHAUSTED fallback for the subscription-quota text above. + * Honor upstream Retry-After / reset hints only when the caller's profile + * enables them (via `getUpstreamRetryHintMs`); otherwise apply a local 1h + * cooldown so all Pro accounts on the same subscription tier stop cycling + * through tight retries. (We deliberately do not use COOLDOWN_MS.paymentRequired + * — that constant is 2 minutes, shorter than the recovery time of a + * subscription quota.) + * + * `getUpstreamRetryHintMs`/`parseRetryFromErrorText` are injected by the + * caller (accountFallback.ts) to avoid importing back into that file. + */ +export function buildSubscriptionQuotaFallback( + errorStr: string, + getUpstreamRetryHintMs: () => number | null, + parseRetryFromErrorText: (text: string) => number | null +): QuotaTextFallback | null { + if (!isSubscriptionQuotaText(errorStr.toLowerCase())) return null; + const hintMs = getUpstreamRetryHintMs(); + const bodyHint = parseRetryFromErrorText(errorStr); + return { + shouldFallback: true, + cooldownMs: hintMs ?? SUBSCRIPTION_QUOTA_COOLDOWN_MS, + reason: RateLimitReason.QUOTA_EXHAUSTED, + usedUpstreamRetryHint: Boolean(hintMs), + quotaResetHintMs: bodyHint ?? undefined, + }; +} + +// ─── Issue #3709 — Ollama Cloud weekly usage cap ─────────────────────────── +// +// Ollama Cloud free-tier accounts have a hard WEEKLY request cap. On cap the +// upstream returns 429 "you () have reached your weekly usage +// limit". ollama-cloud is an apikey-category provider (not oauth), so the +// `shouldUseQuotaSignal` gate in `checkFallbackError` (oauth-only) skips the +// subscription-quota-text branch above for its 429s — without a dedicated, +// ungated check the account fell through to the generic 429 backoff +// (~1s, capped at 2min) and got retried every few minutes for the rest of +// the week (one account took 285x429 in 48h — issue #3709). +// +// The exact weekly reset anchor (UTC Monday? rolling 7d from first request?) +// is not publicly documented by Ollama, so this uses a fixed 24h cooldown — +// short enough to recover promptly once the real window resets, long enough +// to stop the every-5-minute retry storm. The phrase match is generic (not +// ollama-specific), so any other provider using the same wording benefits. +const WEEKLY_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours + +export function isWeeklyUsageLimitText(lower: string): boolean { + return ( + lower.includes("weekly usage limit") || + lower.includes("weekly limit reached") || + lower.includes("reached your weekly") + ); +} + +export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback | null { + if (!isWeeklyUsageLimitText(errorStr.toLowerCase())) return null; + return { + shouldFallback: true, + cooldownMs: WEEKLY_QUOTA_COOLDOWN_MS, + reason: RateLimitReason.QUOTA_EXHAUSTED, + }; +} diff --git a/tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts b/tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts new file mode 100644 index 0000000000..cd95d85ea1 --- /dev/null +++ b/tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts @@ -0,0 +1,103 @@ +/** + * Issue #3709 — Ollama Cloud free-tier accounts have a hard WEEKLY request + * cap. On cap the upstream returns 429 with a body like: + * "you () have reached your weekly usage limit" + * + * ollama-cloud is an apikey-category provider (not oauth), so the existing + * oauth-only `shouldUseQuotaSignal` gate in checkFallbackError skips the + * generic subscription-quota-text branch (Issue #2321) for its 429s. Without + * a dedicated, ungated weekly check the account fell through to the generic + * 429 backoff (starts ~1s, caps at 2min) and got retried every few minutes + * for the rest of the week — one account took 285x429 in 48h. + * + * This test proves: (1) the weekly-usage-limit text is classified as + * QUOTA_EXHAUSTED with a cooldown far longer than the generic backoff cap, + * for BOTH oauth and apikey provider categories, and (2) a sibling + * under-quota connection is unaffected (multi-account: only the exhausted + * connection's checkFallbackError call is affected — selection filtering + * lives in auth.ts and is exercised by other suites). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts"); +const { isWeeklyUsageLimitText, buildWeeklyQuotaFallback } = await import( + "../../open-sse/services/quotaTextCooldowns.ts" +); +const { RateLimitReason, BACKOFF_CONFIG } = await import("../../open-sse/config/constants.ts"); +const { BACKOFF_CONFIG: ERROR_BACKOFF_CONFIG } = await import("../../open-sse/config/errorConfig.ts"); + +const WEEKLY_BODY = "you (acme-corp) have reached your weekly usage limit"; + +test("#3709 isWeeklyUsageLimitText matches the ollama-cloud 429 body", () => { + assert.equal(isWeeklyUsageLimitText(WEEKLY_BODY.toLowerCase()), true); + assert.equal(isWeeklyUsageLimitText("weekly limit reached, try later"), true); + assert.equal(isWeeklyUsageLimitText("rate_limit_exceeded: too many requests"), false); +}); + +test("#3709 buildWeeklyQuotaFallback returns a 24h QUOTA_EXHAUSTED cooldown, far above the generic backoff cap", () => { + const result = buildWeeklyQuotaFallback(WEEKLY_BODY); + assert.ok(result, "expected a non-null fallback for weekly-usage-limit text"); + assert.equal(result!.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result!.cooldownMs, 24 * 60 * 60 * 1000); + // The generic 429 backoff caps at 2 minutes — the weekly cooldown must be + // far longer, otherwise the account keeps getting retried every few + // minutes for the rest of the week (the exact bug reported in #3709). + assert.ok(result!.cooldownMs > (ERROR_BACKOFF_CONFIG.max ?? BACKOFF_CONFIG.max)); +}); + +test("#3709 buildWeeklyQuotaFallback returns null for unrelated error text", () => { + assert.equal(buildWeeklyQuotaFallback("rate_limit_exceeded: too many requests"), null); + assert.equal(buildWeeklyQuotaFallback("Usage Limit Reached"), null); +}); + +test("#3709 checkFallbackError: apikey-category provider (ollama-cloud) 429 weekly-limit body → QUOTA_EXHAUSTED, 24h cooldown", () => { + // Regression guard for the actual bug: without the fix, ollama-cloud (an + // apikey-category provider) 429s skip quota-text classification entirely + // (shouldUseQuotaSignal is oauth-only) and fall through to the generic + // ~1s->2min exponential backoff. + const out = checkFallbackError( + 429, + WEEKLY_BODY, + 0, // backoffLevel + null, // model + "ollama-cloud", // provider (apikey category) + null, // headers + null, // profileOverride + null // structuredError + ); + + assert.equal(out.shouldFallback, true); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(out.cooldownMs, 24 * 60 * 60 * 1000); + assert.ok( + out.cooldownMs > 5 * 60 * 1000, + `expected cooldown far longer than the old 5-minute retry storm window, got ${out.cooldownMs}ms` + ); +}); + +test("#3709 checkFallbackError: oauth-category provider with weekly-limit text also gets the long cooldown", () => { + // The weekly check is generic (not ollama-specific) and runs unconditionally, + // so an oauth provider using the same wording is covered too. + const out = checkFallbackError(429, WEEKLY_BODY, 0, null, "claude", null, null, null); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(out.cooldownMs, 24 * 60 * 60 * 1000); +}); + +test("#3709 checkFallbackError: ollama-cloud generic rate-limit body is unaffected (no false positive)", () => { + const out = checkFallbackError( + 429, + "rate_limit_exceeded: too many requests", + 0, + null, + "ollama-cloud", + null, + null, + null + ); + assert.equal(out.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.ok( + out.cooldownMs <= 2 * 60 * 1000, + "generic rate limit text must keep the normal short backoff, not the 24h weekly cooldown" + ); +}); From 039acabad9900a6ffa144f005e407becad22598f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:54:17 -0300 Subject: [PATCH 43/56] fix(providers): Kiro adaptive-thinking allowlist excludes sonnet-4.5/haiku-4.5 (#6576) (#6726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): Kiro adaptive-thinking allowlist excludes sonnet-4.5/haiku-4.5 (#6576) * chore(6726): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * test(kiro): migrate selector-strip test to claude-sonnet-5 (only Kiro adaptive-thinking model, #6576) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../6726-kiro-adaptive-thinking-allowlist.md | 1 + open-sse/translator/request/openai-to-kiro.ts | 14 ++-- .../openai-to-kiro/adaptiveThinking.ts | 19 ++++++ ...76-kiro-thinking-unsupported-model.test.ts | 64 +++++++++++++++++++ tests/unit/translator-openai-to-kiro.test.ts | 16 ++--- 5 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 changelog.d/fixes/6726-kiro-adaptive-thinking-allowlist.md create mode 100644 open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts create mode 100644 tests/unit/repro-6576-kiro-thinking-unsupported-model.test.ts diff --git a/changelog.d/fixes/6726-kiro-adaptive-thinking-allowlist.md b/changelog.d/fixes/6726-kiro-adaptive-thinking-allowlist.md new file mode 100644 index 0000000000..e31c103661 --- /dev/null +++ b/changelog.d/fixes/6726-kiro-adaptive-thinking-allowlist.md @@ -0,0 +1 @@ +- **fix(providers):** Kiro sent the adaptive-thinking `additionalModelRequestFields` envelope for `claude-sonnet-4.5`/`claude-haiku-4.5`, which Kiro/CodeWhisperer rejects upstream with a raw `[400]: additionalModelRequestFields is not supported for this model` ([#6576](https://github.com/diegosouzapw/OmniRoute/issues/6576)) — `buildKiroPayload()` (`open-sse/translator/request/openai-to-kiro.ts`) gated the field on the generic Anthropic-API `supportsReasoning()` capability flag, which is `true` for both models on Anthropic's direct API but does not reflect what Kiro's CodeWhisperer backend actually accepts; only `claude-sonnet-5` is confirmed adaptive-thinking-capable there. A new Kiro-specific allowlist (`supportsKiroAdaptiveThinking()` in `open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts`) now gates the envelope instead. Regression guard: `tests/unit/repro-6576-kiro-thinking-unsupported-model.test.ts`. diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index d5d6d7f825..3da0a59340 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -5,12 +5,13 @@ 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 { capMaxOutputTokens, capThinkingBudget } from "@/lib/modelCapabilities"; import { parseToolInput, normalizeKiroToolSchema, serializeToolResultContent, } from "./openai-to-kiro/messageHelpers.ts"; +import { supportsKiroAdaptiveThinking } from "./openai-to-kiro/adaptiveThinking.ts"; /** * Anthropic's direct-provider `[1m]` context-1m beta suffix. Kiro is AWS @@ -858,15 +859,14 @@ export function buildKiroPayload(model, body, stream, credentials) { // 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); + // directive prepended to the user message — makes Claude emit reasoning + // INLINE, split back into `reasoning_content` by the executor (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. + // the Kiro executor's transformRequest allowlist — the graded effort lever, + // gated on Kiro's adaptive-thinking allowlist (#6576), not supportsReasoning(). const requestedEffort = resolveKiroEffort(body) || (modelRequestsThinking ? "high" : ""); - const kiroEffort = supportsReasoning(normalizedModel) ? requestedEffort : ""; + const kiroEffort = supportsKiroAdaptiveThinking(normalizedModel) ? requestedEffort : ""; if (kiroEffort) { // `` / `` are Kiro/CodeWhisperer prompt // conventions (NOT Anthropic API params); the length is a soft hint (the hard diff --git a/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts b/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts new file mode 100644 index 0000000000..ba8fefdc97 --- /dev/null +++ b/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts @@ -0,0 +1,19 @@ +/** + * Kiro/AWS CodeWhisperer only accepts the adaptive-thinking + * `additionalModelRequestFields` envelope for a narrow allowlist of models — + * NOT the same set the generic Anthropic-API capability table + * (`supportsReasoning()` in `@/lib/modelCapabilities`) marks as + * thinking-capable. That table is correct for Anthropic's own API, but Kiro + * rejects the field for `claude-sonnet-4.5` and `claude-haiku-4.5` with a raw + * upstream 400 (`additionalModelRequestFields is not supported for this + * model`, issue #6576) even though both ARE thinking-capable on Anthropic's + * direct API. Only `claude-sonnet-5` is confirmed to accept the adaptive + * envelope on Kiro today — keep this allowlist in sync with + * `open-sse/config/providers/registry/kiro/index.ts` if Kiro's catalog or + * upstream behavior changes. + */ +const KIRO_ADAPTIVE_THINKING_MODELS = new Set(["claude-sonnet-5"]); + +export function supportsKiroAdaptiveThinking(normalizedModel: string): boolean { + return KIRO_ADAPTIVE_THINKING_MODELS.has(normalizedModel); +} diff --git a/tests/unit/repro-6576-kiro-thinking-unsupported-model.test.ts b/tests/unit/repro-6576-kiro-thinking-unsupported-model.test.ts new file mode 100644 index 0000000000..8c56090b33 --- /dev/null +++ b/tests/unit/repro-6576-kiro-thinking-unsupported-model.test.ts @@ -0,0 +1,64 @@ +// Repro probe for GitHub issue #6576. +// +// Kiro/CodeWhisperer rejects `additionalModelRequestFields` for +// claude-sonnet-4.5 / claude-haiku-4.5 with a raw upstream 400: +// "[400]: additionalModelRequestFields is not supported for this model" +// +// buildKiroPayload() gates thinking injection on supportsReasoning(model), +// which resolves from the GENERIC Anthropic-API capability data +// (MODEL_SPECS["claude-sonnet-4-5-..."].supportsThinking === true, +// MODEL_SPECS["claude-haiku-4-5-20251001"].supportsThinking === true). +// That flag says nothing about whether the *Kiro/AWS CodeWhisperer* +// backend accepts the adaptive-thinking additionalModelRequestFields +// envelope for these specific models — only the newer adaptive-only +// models (Opus 4.7/4.8, Sonnet 5, Fable 5) are proven to accept it there +// (see the existing "drops temperature when thinking is enabled" tests). +// +// This test asserts the payload for claude-sonnet-4.5 (the reporter's own +// model) must NOT carry additionalModelRequestFields when reasoning is +// requested, matching what Kiro's upstream actually accepts. It currently +// FAILS because buildKiroPayload has no Kiro-specific allowlist/exclusion +// and blindly forwards the field whenever the generic capability flag says +// supportsThinking:true. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildKiroPayload } = await import( + "../../open-sse/translator/request/openai-to-kiro.ts" +); + +test("[repro #6576] buildKiroPayload must not attach additionalModelRequestFields for claude-sonnet-4.5 (Kiro rejects it)", () => { + const body = { + messages: [{ role: "user", content: "Calculate 51818+62218, and reply with result only." }], + reasoning_effort: "medium", + max_tokens: 2048, + stream: false, + }; + + const result = buildKiroPayload("claude-sonnet-4.5", body, false, null); + + assert.equal( + result.additionalModelRequestFields, + undefined, + "additionalModelRequestFields must not be sent for claude-sonnet-4.5 — " + + "Kiro/CodeWhisperer rejects it upstream with " + + "'[400]: additionalModelRequestFields is not supported for this model' (issue #6576)" + ); +}); + +test("[repro #6576] buildKiroPayload must not attach additionalModelRequestFields for claude-haiku-4.5 (Kiro rejects it)", () => { + const body = { + messages: [{ role: "user", content: "hi" }], + thinking: { type: "adaptive" }, + }; + + const result = buildKiroPayload("claude-haiku-4.5", body, false, null); + + assert.equal( + result.additionalModelRequestFields, + undefined, + "additionalModelRequestFields must not be sent for claude-haiku-4.5 — " + + "Kiro/CodeWhisperer rejects it upstream (issue #6576 comment by fenix007: " + + "9/9 production requests with reasoning params 400'd for this exact model)" + ); +}); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 2b34de746b..4cf19efebb 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -1049,10 +1049,10 @@ test("buildKiroPayload accepts kr/* model ids without the [1m] suffix", () => { test("buildKiroPayload strips local Kiro selector suffixes before upstream", () => { const body = { messages: [{ role: "user", content: "Hello" }] }; - const result = buildKiroPayload("claude-opus-4.8-thinking-agentic", body, true, {}); + const result = buildKiroPayload("claude-sonnet-5-thinking-agentic", body, true, {}); assert.equal( result.conversationState.currentMessage.userInputMessage.modelId, - "claude-opus-4.8", + "claude-sonnet-5", "local -thinking/-agentic aliases must not be forwarded to Kiro" ); assert.equal( @@ -1121,7 +1121,7 @@ test("buildKiroPayload enables thinking mode for Claude models via reasoning_eff max_tokens: 64000, }; - const result = buildKiroPayload("claude-opus-4.8", body, false, null); + const result = buildKiroPayload("claude-sonnet-5", body, false, null); // only Kiro model accepting adaptive thinking (#6576) assert.ok(result.additionalModelRequestFields, "additionalModelRequestFields must be set"); assert.deepEqual(result.additionalModelRequestFields.thinking, { @@ -1149,7 +1149,7 @@ test("buildKiroPayload drops temperature when thinking is enabled", () => { temperature: 0.5, }; - const result = buildKiroPayload("claude-opus-4.8", body, false, null); + const result = buildKiroPayload("claude-sonnet-5", body, false, null); assert.ok(result.additionalModelRequestFields, "thinking must be enabled"); assert.equal( @@ -1180,7 +1180,7 @@ test("buildKiroPayload maps body.thinking budget_tokens to effort level", () => thinking: { type: "enabled", budget_tokens: 50000 }, }; - const result = buildKiroPayload("claude-opus-4.7", body, false, null); + const result = buildKiroPayload("claude-sonnet-5", body, false, null); assert.ok(result.additionalModelRequestFields, "thinking must be enabled from budget_tokens"); assert.equal(result.additionalModelRequestFields.output_config.effort, "high"); @@ -1212,7 +1212,7 @@ test("buildKiroPayload maps reasoning_effort to the same Kiro effort level (no + test("buildKiroPayload reads effort from Anthropic output_config.effort", () => { const result = buildKiroPayload( - "claude-opus-4.8", + "claude-sonnet-5", { messages: [{ role: "user", content: "hard" }], output_config: { effort: "xhigh" } }, false, null @@ -1224,7 +1224,7 @@ test("buildKiroPayload reads effort from Anthropic output_config.effort", () => test("buildKiroPayload defaults adaptive thinking (no effort) to high", () => { const result = buildKiroPayload( - "claude-opus-4.8", + "claude-sonnet-5", { messages: [{ role: "user", content: "hard" }], thinking: { type: "adaptive" } }, false, null @@ -1239,7 +1239,7 @@ test("buildKiroPayload defaults adaptive thinking (no effort) to high", () => { test("buildKiroPayload drops both temperature and top_p when thinking is enabled", () => { const result = buildKiroPayload( - "claude-opus-4.8", + "claude-sonnet-5", { messages: [{ role: "user", content: "hard" }], reasoning_effort: "high", From a25175b608e04466ecbad690618e777eb21e5d0b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 10 Jul 2026 21:49:02 -0300 Subject: [PATCH 44/56] chore(quality): rebaseline complexity 2053->2054 (merge-burst drift, v3.8.47) Inherited drift from today's /implement-prs merge burst (~36 PRs). check:complexity does not run on the PR->release fast-path, so the branch accrued +1 unmeasured. No orphan/feature PR introduces a NEW violation (complexity-net-zero); the only flagged function is the pre-existing getResolvedModelCapabilities. Owner-approved rebaseline to unblock the FQG of ~7 green-except-complexity orphans. --- config/quality/complexity-baseline.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index ed24860b31..3c02ee514b 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 2053, + "count": 2054, + "_rebaseline_2026_07_10_v3847_merge_burst": "2053->2054 (+1). Drift herdado do merge burst do dia em release/v3.8.47 (campanha /implement-prs: ~36 PRs mergeados — órfãos, features do dono, ports). O check:complexity NÃO roda no fast-path PR->release, então o ramo acumulou o +1 sem rebaselinar (mesma família de todos os rebaselines abaixo). Trust-but-verify: medido 2054 no tip da release pós-burst; a única função flagada nova é pré-existente (getResolvedModelCapabilities em modelCapabilities.ts, já >teto antes de #6714). Nenhum PR órfão/feature introduz violação NOVA — os fixes deste ciclo são complexity-net-zero. Rebaseline aprovado pelo dono (2026-07-10) para destravar o FQG dos ~7 órfãos verdes-exceto-complexity. Tighten via --update next cycle.", "_rebaseline_2026_07_08_6556_inherited_drift": "2052->2053 (+1). PR #6556 (omniglyph engine): drift herdado do merge burst da base (a catraca nao roda no fast-path PR->release, mesmo padrao dos rebaselines v3.8.44/46). Trust-but-verify: o proprio codigo do PR e complexity-net-zero — as 2 violacoes que ele introduzia (runCompressionAsync complexity 17 apos o branch do modo omniglyph; OmniglyphContextPageClient 161 linhas) foram CORRIGIDAS por extracao real (engines/omniglyphSingleMode.ts + split do componente em section components), medido: 2055->2053 local; base pura origin/release/v3.8.47 mede 2053 identico. Tighten via --update next cycle.", "_rebaseline_2026_07_07_v3846_release_close": "2035->2050 (+15). v3.8.46 release close (generate-release Phase 0 pre-flight): drift herdado do merge burst do ciclo (39 commits do dia + campanha /review-*). Trust-but-verify: os fixes de base-red do captain (agentSkills path.resolve #6366, catalogo cache #6408, tipagem de teste no-explicit-any, MitmProxyTab suppression) sao complexity-net-zero — check:complexity mede 2050 identico com e sem os fixes (a catraca NAO roda no fast-path PR->release, entao o ramo acumulou sem rebaselinar). Tighten via --update next cycle.", "_rebaseline_2026_07_04_v3844_release_close": "2026->2028 (+2). v3.8.44 release close (generate-release Phase 0/1): drift residual do fim do ciclo medido no tip pos-#6155 (merge burst final: #6155 cooling-panel + #6104 Kenari + #6139/#6128 provider-limits). Trust-but-verify: os 2 fixes de codigo do release-captain (model.ts alias boundary, auggie.ts stdin error handlers) adicionam 0 violacoes NOVAS — eslint.complexity direto nos 2 arquivos flagra apenas funcoes que ja estouravam o limite antes (runStreaming/start ja >80 linhas; resolveModelByProviderInference/getModelInfoCore pre-existentes de #5918), e resolveProviderAlias segue abaixo de 15. Logo o +2 e drift herdado do burst. Tighten via --update next cycle.", From a42f993645498e89546a234888a97d94a57267f8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 10 Jul 2026 23:16:28 -0300 Subject: [PATCH 45/56] chore(stryker): register ollama-quota covering tests (merge-burst drift, v3.8.47) The 3 covering unit tests from #6731/#6817/#6742 (issue-6638-ollama-quota, ollama-cloud-weekly-quota-cooldown-3709, issue-6686-quota-preflight-coverage) exist on release but were never added to tap.testFiles when those PRs merged. Completes the registration so mutant kills count; unblocks every PR touching a mutated module. Part of the owner-approved merge-burst drift cleanup. --- stryker.conf.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stryker.conf.json b/stryker.conf.json index dcdcb45189..949fb9b5ed 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -174,6 +174,8 @@ "tests/unit/headroom-codex-quota-snapshot-6379.test.ts", "tests/unit/headroom-proxy-lifecycle.test.ts", "tests/unit/idempotency-fusion-collision.test.ts", + "tests/unit/issue-6638-ollama-quota.test.ts", + "tests/unit/issue-6686-quota-preflight-coverage.test.ts", "tests/unit/livews-forward-backoff-4604.test.ts", "tests/unit/management-auth-hardening.test.ts", "tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts", @@ -192,6 +194,7 @@ "tests/unit/oauth-redirect-uri-mismatch.test.ts", "tests/unit/observability-fase04.test.ts", "tests/unit/observability-payloads.test.ts", + "tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts", "tests/unit/openapi-security-tiers.test.ts", "tests/unit/persist-429-cooldown-account-fallback.test.ts", "tests/unit/plan3-p0.test.ts", From 45310f202d3cd3bcf216cd54d4b887f578a60d05 Mon Sep 17 00:00:00 2001 From: Septianata Rizky Pratama <19322988+ianriizky@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:01:27 +0700 Subject: [PATCH 46/56] fix: auto-start WS server in-process and change default port to 20132 (#6072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: change default LIVE_WS_PORT from 20129 to 20132 Update the default WebSocket port for the live dashboard server from 20129 to 20132 across all configuration files, documentation, code comments, and tests. Also consolidate OMNIROUTE_DISABLE_LIVE_WS and OMNIROUTE_ENABLE_LIVE_WS into a single OMNIROUTE_ENABLE_LIVE_WS flag. Wire the live WebSocket server to start in-process via instrumentation-node.ts. * feat: clarify NEXT_PUBLIC_LIVE_WS_PUBLIC_URL path usage and derive upgrade path from URL Update .env.example and ENVIRONMENT.md to document that the pathname portion of NEXT_PUBLIC_LIVE_WS_PUBLIC_URL (e.g. /live-ws) is used as the WebSocket upgrade path by the dev proxy, handshake response, and client connection logic. Extract deriveLiveWsPath() into shared/utils/wsPath.ts and wire it through: - src/app/api/v1/ws/route.ts — handshake response path field - src/hooks/useLiveDashboard.ts — build * fix: use the standard URL API to safely parse and update the effectiveWsUrl * build(docker): expose live WebSocket server port and configure CORS origins Add LIVE_WS_PORT (20132), LIVE_WS_HOST (0.0.0.0), and LIVE_WS_ALLOWED_ORIGINS environment variables to all Docker Compose profiles and expose the WebSocket port mapping. Prevent infinite self-loop in standalone-server-ws.mjs by skipping proxy when the server itself is running on the LiveWS port. * docs(env): fix comment formatting for HOST and HOSTNAME variables --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .env.example | 26 +++++---- config/quality/eslint-suppressions.json | 5 -- docker-compose.prod.yml | 4 ++ docker-compose.yml | 10 ++++ docs/reference/ENVIRONMENT.md | 53 +++++++++---------- .../handlers/chatCore/telemetryHelpers.ts | 4 +- scripts/dev/standalone-server-ws.mjs | 31 +++++++++-- scripts/start-ws-server.mjs | 12 ++--- src/app/api/v1/ws/route.ts | 9 ++-- src/app/docs/lib/openapi.generated.ts | 6 ++- src/hooks/useLiveDashboard.ts | 31 ++++++++--- src/instrumentation-node.ts | 7 ++- src/lib/services/embedWsProxy.ts | 2 +- src/server/ws/liveServer.ts | 4 +- .../constants/featureFlagDefinitions.ts | 2 +- src/shared/utils/wsPath.ts | 29 ++++++++++ tests/unit/chatcore-telemetry-helpers.test.ts | 9 ++-- tests/unit/instrumentation-live-ws.test.ts | 12 +++++ tests/unit/live-ws-public-url.test.ts | 42 ++++++++++++--- .../unit/livews-forward-backoff-4604.test.ts | 4 +- 20 files changed, 209 insertions(+), 93 deletions(-) create mode 100644 src/shared/utils/wsPath.ts create mode 100644 tests/unit/instrumentation-live-ws.test.ts diff --git a/.env.example b/.env.example index 8f3a700dde..d06f164c34 100644 --- a/.env.example +++ b/.env.example @@ -86,8 +86,8 @@ PORT=20128 # Port for the real-time WebSocket live monitoring server. # Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts -# Default: 20129 -# LIVE_WS_PORT=20129 +# Default: 20132 +# LIVE_WS_PORT=20132 # Bind address for the live WebSocket server. # Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN — @@ -112,16 +112,14 @@ PORT=20128 # Public URL for the live dashboard WebSocket (client-side, browser only). # Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel. -# The browser will connect to this URL instead of ws://hostname:20129. -# The /live-ws path is already proxied from the main app (port 20128) to the -# live WS server (port 20129) by scripts/dev/standalone-server-ws.mjs. -# Used by: src/hooks/useLiveDashboard.ts +# The browser will connect to this URL instead of ws://hostname:20132. +# The path portion of this URL (e.g. ws://localhost:20132/live-ws -> /live-ws) is also used by the dev proxy +# (scripts/dev/standalone-server-ws.mjs) and the handshake response to route +# WebSocket upgrades. Default path: /live-ws. +# Used by: src/hooks/useLiveDashboard.ts, src/app/api/v1/ws/route.ts, +# scripts/dev/standalone-server-ws.mjs, and scripts/start-ws-server.mjs. # Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws -# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL= - -# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs. -# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle). -# OMNIROUTE_DISABLE_LIVE_WS=0 +# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=ws://localhost:20132/live-ws # Enable the real-time dashboard WebSocket server. # Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs @@ -197,9 +195,9 @@ OMNIROUTE_USE_TURBOPACK=1 # 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 +# 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/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 9d3c854412..a3006bcdf3 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1502,11 +1502,6 @@ "count": 1 } }, - "tests/unit/live-ws-public-url.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, "tests/unit/lmarena-provider.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 9 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b442de5f10..3c995fa15e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -58,12 +58,16 @@ services: - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} - API_PORT=${API_PORT:-20129} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:${PROD_DASHBOARD_PORT:-20130},http://127.0.0.1:${PROD_DASHBOARD_PORT:-20130}} - API_HOST=${API_HOST:-0.0.0.0} - HOSTNAME=0.0.0.0 - DATA_DIR=/app/data ports: - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" + - "${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - omniroute-prod-data:/app/data healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 2560644527..9b3add8ee3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,9 @@ x-common: &common - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - REDIS_URL=${REDIS_URL:-redis://redis:6379} volumes: - ./data:/app/data @@ -75,6 +78,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - base @@ -92,6 +96,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - web @@ -106,6 +111,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - ./data:/app/data - /var/run/docker.sock:/var/run/docker.sock @@ -125,12 +131,16 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" environment: - DATA_DIR=/app/data - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - CLI_MODE=host - CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin - CLI_CONFIG_HOME=/host-home diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 1963c24302..f4ba64fdbf 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -115,33 +115,32 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari ## 3. Network & Ports -| Variable | Default | Source File | Description | -| ------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | -| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | -| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | -| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | -| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | -| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | -| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | -| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | -| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | -| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | -| `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | -| `LIVE_WS_ALLOWED_HOSTS` | _(unset)_ | `src/server/ws/liveServerAllowList.ts` | Comma-separated extra hostnames allowed for live WebSocket origins. Unlike `LIVE_WS_ALLOWED_ORIGINS` (full origin URLs), matches only the host portion — useful for LAN/Tailscale setups. | -| `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` | _(unset)_ | `src/hooks/useLiveDashboard.ts` | Public URL for the live dashboard WebSocket (browser-side). Set when fronting the WS server with a reverse proxy or Cloudflare Tunnel (e.g. `wss://ws.my-ai.com/live-ws`); the browser connects there instead of `ws://hostname:20129`. | -| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). | -| `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/harness toggle that disables the standalone live WebSocket helper script. | -| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | -| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | -| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | -| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | -| `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. **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) | +| Variable | Default | Source File | Description | +| ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | +| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | +| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | +| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | +| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | +| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | +| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | +| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | +| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | +| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | +| `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | +| `LIVE_WS_ALLOWED_HOSTS` | _(unset)_ | `src/server/ws/liveServerAllowList.ts` | Comma-separated extra hostnames allowed for live WebSocket origins. Unlike `LIVE_WS_ALLOWED_ORIGINS` (full origin URLs), matches only the host portion — useful for LAN/Tailscale setups. | +| `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` | _(unset)_ | `src/hooks/useLiveDashboard.ts` | Public URL for the live dashboard WebSocket (browser-side). Set when fronting the WS server with a reverse proxy or Cloudflare Tunnel (e.g. `wss://ws.my-ai.com/live-ws`); the browser connects there instead of `ws://hostname:20132`. The pathname portion is also used as the WebSocket upgrade path (default: `/live-ws`). | +| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` and `scripts/start-ws-server.mjs` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). CI/harness toggle that disables the standalone live WebSocket helper script. | +| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | +| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | +| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | +| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | +| `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. **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/open-sse/handlers/chatCore/telemetryHelpers.ts b/open-sse/handlers/chatCore/telemetryHelpers.ts index c6b92e683c..805acbbd8c 100644 --- a/open-sse/handlers/chatCore/telemetryHelpers.ts +++ b/open-sse/handlers/chatCore/telemetryHelpers.ts @@ -2,7 +2,7 @@ import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits"; import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; // #4604 — Lazy backoff for the best-effort live-WS sidecar bridge. In single-port -// deployments the sidecar (port 20129) is not running, so every compression event +// deployments the sidecar (port 20132) is not running, so every compression event // POST failed with ECONNREFUSED; because the global fetch is proxyFetch, each // failure logged a "[ProxyFetch] Undici dispatcher failed" warning (272× in 42min). // After a few consecutive failures we stop attempting for a cooldown window (then @@ -28,7 +28,7 @@ export async function forwardDashboardEventToLiveWs( // Skip while the bridge is in a cooldown window after repeated failures. if (liveWsDisabledUntil > now()) return; - const port = process.env.LIVE_WS_PORT || "20129"; + const port = process.env.LIVE_WS_PORT || "20132"; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1_500); try { diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index eb59d9d50a..c96c9624ef 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -19,9 +19,7 @@ const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; // TLS). Absent or misconfigured → null → identical plain-HTTP behavior as before. const tlsOptions = resolveTlsOptions(process.env); if (tlsOptions) { - console.log( - `[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}` - ); + console.log(`[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}`); } process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); @@ -51,8 +49,23 @@ function getProxy(server) { return proxy; } +function deriveLiveWsPath() { + const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + if (!publicUrl) return "/live-ws"; + if (!publicUrl.startsWith("ws://") && !publicUrl.startsWith("wss://")) return "/live-ws"; + try { + const parsed = new URL(publicUrl); + const pathname = parsed.pathname; + return pathname && pathname !== "/" ? pathname : "/live-ws"; + } catch { + return "/live-ws"; + } +} + +const LIVE_WS_PATH = deriveLiveWsPath(); + function proxyLiveWs(req, socket, head) { - const targetPort = parseInt(process.env.LIVE_WS_PORT || "20129", 10); + const targetPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); const targetSocket = net.connect(targetPort, "127.0.0.1", () => { let rawRequest = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; for (const [key, val] of Object.entries(req.headers)) { @@ -76,8 +89,16 @@ function proxyLiveWs(req, socket, head) { function wrapUpgradeListener(server, listener) { return async function responsesWsAwareUpgrade(req, socket, head) { try { + // If this server IS the LiveWS server (port 20132), the ws library's + // own upgrade handler should process the request directly — proxying + // /live-ws back to 127.0.0.1:20132 would create an infinite self-loop. + const liveWsPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); + if (getPort(server) === liveWsPort) { + return listener.call(this, req, socket, head); + } + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); - if (url.pathname === "/live-ws" || url.pathname.startsWith("/live-ws")) { + if (url.pathname === LIVE_WS_PATH || url.pathname.startsWith(LIVE_WS_PATH + "/")) { proxyLiveWs(req, socket, head); return; } diff --git a/scripts/start-ws-server.mjs b/scripts/start-ws-server.mjs index 9b0d1068c3..00b4a62377 100644 --- a/scripts/start-ws-server.mjs +++ b/scripts/start-ws-server.mjs @@ -7,9 +7,9 @@ * node scripts/start-ws-server.mjs * * Environment variables: - * LIVE_WS_PORT — WebSocket server port (default: 20129) + * LIVE_WS_PORT — WebSocket server port (default: 20132) * LIVE_WS_HOST — WebSocket server host (default: 127.0.0.1) - * OMNIROUTE_DISABLE_LIVE_WS — Set to "1" or "true" to disable + * OMNIROUTE_ENABLE_LIVE_WS — Set to "0" or "false" to disable */ import { spawnSync } from "node:child_process"; @@ -60,10 +60,10 @@ export function buildSidecarSpawn(scriptUrl, env = process.env) { async function main() { if ( - process.env.OMNIROUTE_DISABLE_LIVE_WS === "1" || - process.env.OMNIROUTE_DISABLE_LIVE_WS === "true" + process.env.OMNIROUTE_ENABLE_LIVE_WS === "0" || + process.env.OMNIROUTE_ENABLE_LIVE_WS?.toLowerCase() === "false" ) { - console.log("[LiveWS] Disabled via OMNIROUTE_DISABLE_LIVE_WS"); + console.log("[LiveWS] Disabled via OMNIROUTE_ENABLE_LIVE_WS"); process.exit(0); } @@ -80,7 +80,7 @@ async function main() { const { startLiveDashboardServer } = await import("../src/server/ws/liveServer.ts"); - const port = parseInt(process.env.LIVE_WS_PORT || "20129", 10); + const port = parseInt(process.env.LIVE_WS_PORT || "20132", 10); const host = process.env.LIVE_WS_HOST || "127.0.0.1"; console.log(`[LiveWS] Starting dashboard WebSocket server on ${host}:${port}...`); diff --git a/src/app/api/v1/ws/route.ts b/src/app/api/v1/ws/route.ts index bdd629578d..fb85cc50fc 100644 --- a/src/app/api/v1/ws/route.ts +++ b/src/app/api/v1/ws/route.ts @@ -1,4 +1,5 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; +import { getLiveWsPath } from "@/shared/utils/wsPath"; import { authorizeWebSocketHandshake } from "@/lib/ws/handshake"; const WS_HANDSHAKE_HEADERS = { @@ -26,9 +27,9 @@ function getWsProtocol() { }, cancel: { type: "cancel", id: "req-1" }, live: { - port: parseInt(process.env.LIVE_WS_PORT || "20129", 10), + port: parseInt(process.env.LIVE_WS_PORT || "20132", 10), publicUrl: getLivePublicUrl(), - path: "/live", + path: getLiveWsPath(), protocol: "json", channels: ["requests", "combo", "credentials"], auth: "api-key", @@ -82,9 +83,9 @@ export async function GET(request: Request) { authType: auth.authType, protocol: getWsProtocol(), live: { - port: parseInt(process.env.LIVE_WS_PORT || "20129", 10), + port: parseInt(process.env.LIVE_WS_PORT || "20132", 10), publicUrl: getLivePublicUrl(), - path: "/live", + path: getLiveWsPath(), protocol: "json", channels: ["requests", "combo", "credentials"], auth: "api-key", diff --git a/src/app/docs/lib/openapi.generated.ts b/src/app/docs/lib/openapi.generated.ts index 7f0053095e..53876ec347 100644 --- a/src/app/docs/lib/openapi.generated.ts +++ b/src/app/docs/lib/openapi.generated.ts @@ -173,7 +173,8 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [ path: "/api/v1/providers/{provider}/models", method: "GET", summary: "List models for a specific provider", - description: "Returns only models for the selected provider with provider prefix removed from each model id.", + description: + "Returns only models for the selected provider with provider prefix removed from each model id.", tag: "Models", tags: ["Models"], requiresAuth: true, @@ -203,7 +204,8 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [ path: "/api/v1/ws", method: "GET", summary: "Chat completion over WebSocket (handshake + upgrade)", - description: "OpenAI-compatible chat over a WebSocket connection. `GET` with `?handshake=1` returns the connection descriptor (auth path, message protocol and live-event channels) as JSON; a plain `GET` without an Upgrade returns `426 Upgrade Required`. After upgrading, the client exchanges JSON frames — `{type:\"request\", id, payload:{model, messages}}` to start a completion and `{type:\"cancel\", id}` to abort it. A separate live channel (default port `LIVE_WS_PORT=20129`, path `/live`) streams dashboard events on the `requests`, `combo` and `credentials` topics with a 15s heartbeat. Requires an API key.", + description: + 'OpenAI-compatible chat over a WebSocket connection. `GET` with `?handshake=1` returns the connection descriptor (auth path, message protocol and live-event channels) as JSON; a plain `GET` without an Upgrade returns `426 Upgrade Required`. After upgrading, the client exchanges JSON frames — `{type:"request", id, payload:{model, messages}}` to start a completion and `{type:"cancel", id}` to abort it. A separate live channel (default port `LIVE_WS_PORT=20132`, path `/live`) streams dashboard events on the `requests`, `combo` and `credentials` topics with a 15s heartbeat. Requires an API key.', tag: "Chat", tags: ["Chat"], requiresAuth: true, diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index b31c4be047..05c991a31f 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -13,6 +13,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import type { DashboardChannel, DashboardEventName } from "@/lib/events/types"; +import { deriveLiveWsPath } from "@/shared/utils/wsPath"; // ── Config ──────────────────────────────────────────────────────────────── @@ -27,21 +28,22 @@ function sanitizeWsPublicUrl(url: unknown): string | null { // Build-time inlined value (Docker/npm prebuilt images won't have this — the // runtime value is discovered via the /api/v1/ws?handshake=1 handshake below). const BUILD_TIME_PUBLIC_WS_URL = sanitizeWsPublicUrl(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL); +const BUILD_TIME_WS_PATH = deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL); function getDefaultWsUrl(): string { if (BUILD_TIME_PUBLIC_WS_URL) return BUILD_TIME_PUBLIC_WS_URL; - if (typeof window === "undefined") return "ws://localhost:20129"; + if (typeof window === "undefined") return `ws://localhost:20132${BUILD_TIME_WS_PATH}`; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const { hostname } = window.location; - // Bug #1 fix: Use the WS server's actual port (20129) for both loopback + // Bug #1 fix: Use the WS server's actual port (20132) for both loopback // and non-loopback clients. Previously the non-loopback branch tried to // upgrade the HTTP port (window.location.host) which has no upgrade // handler in src/proxy.ts. If the user wants the upgrade to go through // Next.js (same-origin), they should explicitly pass `wsUrl`. if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") { - return `${protocol}//${hostname}:20129`; + return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`; } - return `${protocol}//${hostname}:20129`; + return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`; } const DEFAULT_WS_URL = getDefaultWsUrl(); @@ -65,7 +67,7 @@ export interface DashboardConnectionState { // ── Core Hook ───────────────────────────────────────────────────────────── export interface UseLiveDashboardOptions { - /** WebSocket URL (default: ws://hostname:20129) */ + /** WebSocket URL (default: ws://hostname:20132) */ wsUrl?: string; /** Whether the WebSocket connection should be active (default: true) */ enabled?: boolean; @@ -105,6 +107,7 @@ export function useLiveDashboard({ // Skipped when the caller passes an explicit wsUrl or the env was inlined. const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined"; const [handshakeUrl, setHandshakeUrl] = useState(null); + const [handshakePath, setHandshakePath] = useState(null); const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake); useEffect(() => { @@ -116,6 +119,9 @@ export function useLiveDashboard({ if (cancelled) return; const publicUrl = sanitizeWsPublicUrl(body?.live?.publicUrl); if (publicUrl) setHandshakeUrl(publicUrl); + if (typeof body?.live?.path === "string" && body.live.path.startsWith("/")) { + setHandshakePath(body.live.path); + } }) .catch(() => { // Handshake unavailable — fall back to the default URL. @@ -128,7 +134,20 @@ export function useLiveDashboard({ }; }, [needsHandshake, wsUrlResolved]); - const effectiveWsUrl = wsUrl ?? handshakeUrl ?? DEFAULT_WS_URL; + const effectiveWsUrl = (() => { + if (wsUrl) return wsUrl; + if (handshakeUrl) return handshakeUrl; + if (handshakePath && handshakePath !== BUILD_TIME_WS_PATH) { + try { + const url = new URL(DEFAULT_WS_URL); + url.pathname = handshakePath; + return url.toString(); + } catch { + return DEFAULT_WS_URL; + } + } + return DEFAULT_WS_URL; + })(); const [events, setEvents] = useState([]); const wsRef = useRef(null); diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 20d96240a4..4cc92e1d7f 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -276,9 +276,8 @@ export async function registerNodejs(): Promise { // without this the dashboard mode (auto/custom/adaptive) silently reverts to // the passthrough default on every restart. Previously this was only wired into // the unused `server-init.ts`, so it never ran in production. - const { hydrateThinkingBudgetConfig } = await import( - "@omniroute/open-sse/services/thinkingBudget.ts" - ); + const { hydrateThinkingBudgetConfig } = + await import("@omniroute/open-sse/services/thinkingBudget.ts"); if (hydrateThinkingBudgetConfig(settings)) { console.log("[STARTUP] Thinking-Budget config restored from settings"); } @@ -441,7 +440,7 @@ export async function registerNodejs(): Promise { console.warn("[STARTUP] memory decay sweep failed to start (non-fatal):", msg); } - // Real-time dashboard WebSocket daemon (port 20129): powers Combo Studio Live, + // Real-time dashboard WebSocket daemon (port 20132): 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 diff --git a/src/lib/services/embedWsProxy.ts b/src/lib/services/embedWsProxy.ts index 8ab9052545..b2b1e10370 100644 --- a/src/lib/services/embedWsProxy.ts +++ b/src/lib/services/embedWsProxy.ts @@ -227,7 +227,7 @@ async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buff * * `EMBED_WS_PROXY_HOST` takes precedence, but we fall back to `LIVE_WS_HOST` * so a single env var exposes BOTH WebSocket sockets (the Live dashboard server - * on :20129 and this embed proxy on :20131) in Docker / behind a reverse proxy + * on :20132 and this embed proxy on :20131) in Docker / behind a reverse proxy * or tunnel. Without this fallback the embed proxy stayed bound to 127.0.0.1 * even when the operator set `LIVE_WS_HOST=0.0.0.0`, so the Live view was * permanently "disconnected" in headless deployments (#5110). Defaults to diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index c3bbccdc89..a4a910a8fb 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -1,7 +1,7 @@ /** * Live Dashboard WebSocket Server * - * Separate process (runs alongside Next.js on port 20129). + * Separate process (runs alongside Next.js on port 20132). * Forwards EventBus events to subscribed dashboard clients. * * Protocol: @@ -36,7 +36,7 @@ import { // ── Config ──────────────────────────────────────────────────────────────── -const DEFAULT_PORT = 20129; +const DEFAULT_PORT = 20132; // Loopback by default. Opt-in to LAN exposure via LIVE_WS_HOST=0.0.0.0 — the // caller is then responsible for fronting it with a TLS terminator + origin // allow-list. Mirrors the route guard "local-only by default" posture. diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index b42c26506a..166338f321 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -306,7 +306,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ key: "OMNIROUTE_ENABLE_LIVE_WS", label: "Live Dashboard WebSocket", description: - "Start the real-time dashboard WebSocket server on import (port 20129, loopback-bound by default). Default: enabled. Set to '0' or 'false' to disable. LAN exposure requires LIVE_WS_HOST=0.0.0.0 + LIVE_WS_ALLOWED_ORIGINS.", + "Start the real-time dashboard WebSocket server on import (port 20132, loopback-bound by default). Default: enabled. Set to '0' or 'false' to disable. LAN exposure requires LIVE_WS_HOST=0.0.0.0 + LIVE_WS_ALLOWED_ORIGINS.", descriptionI18nKey: "featureFlagOmnirouteEnableLiveWsDescription", category: "runtime", defaultValue: "true", diff --git a/src/shared/utils/wsPath.ts b/src/shared/utils/wsPath.ts new file mode 100644 index 0000000000..b1a47d47db --- /dev/null +++ b/src/shared/utils/wsPath.ts @@ -0,0 +1,29 @@ +/** + * Derive the live WebSocket path from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`. + * + * Only `ws://` or `wss://` URLs are accepted (mirrors the scheme guard in + * `getLivePublicUrl()`). The pathname is extracted and used as the WS upgrade + * path; if the URL has no pathname (or is `/`), falls back to `/live-ws`. + * + * Used by: + * - `src/app/api/v1/ws/route.ts` — handshake response `path` field + * - `src/hooks/useLiveDashboard.ts` — build-time path constant + runtime discovery + * + * No env var is introduced — this reads the existing `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`. + */ +export function deriveLiveWsPath(publicUrl?: string): string { + if (!publicUrl) return "/live-ws"; + if (!publicUrl.startsWith("ws://") && !publicUrl.startsWith("wss://")) return "/live-ws"; + try { + const parsed = new URL(publicUrl); + const pathname = parsed.pathname; + return pathname && pathname !== "/" ? pathname : "/live-ws"; + } catch { + return "/live-ws"; + } +} + +/** Convenience: read the env var at call time and derive the path. */ +export function getLiveWsPath(): string { + return deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL); +} diff --git a/tests/unit/chatcore-telemetry-helpers.test.ts b/tests/unit/chatcore-telemetry-helpers.test.ts index a155da8f7c..e7bc72debc 100644 --- a/tests/unit/chatcore-telemetry-helpers.test.ts +++ b/tests/unit/chatcore-telemetry-helpers.test.ts @@ -9,9 +9,8 @@ import path from "node:path"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-telemetry-")); process.env.DATA_DIR = TEST_DATA_DIR; -const { forwardDashboardEventToLiveWs, maybeSyncClaudeExtraUsageState } = await import( - "../../open-sse/handlers/chatCore/telemetryHelpers.ts" -); +const { forwardDashboardEventToLiveWs, maybeSyncClaudeExtraUsageState } = + await import("../../open-sse/handlers/chatCore/telemetryHelpers.ts"); const core = await import("../../src/lib/db/core.ts"); const originalFetch = globalThis.fetch; @@ -48,8 +47,8 @@ test("forwardDashboardEventToLiveWs POSTs event+payload+timestamp as JSON to the await forwardDashboardEventToLiveWs("my-event", { foo: "bar" }); const after = Date.now(); - // Default port is 20129 when LIVE_WS_PORT is unset. - assert.equal(capturedUrl, "http://127.0.0.1:20129/__omniroute_event"); + // Default port is 20132 when LIVE_WS_PORT is unset. + assert.equal(capturedUrl, "http://127.0.0.1:20132/__omniroute_event"); assert.equal(capturedInit?.method, "POST"); assert.equal( (capturedInit?.headers as Record)["content-type"], diff --git a/tests/unit/instrumentation-live-ws.test.ts b/tests/unit/instrumentation-live-ws.test.ts new file mode 100644 index 0000000000..ef8cb8462b --- /dev/null +++ b/tests/unit/instrumentation-live-ws.test.ts @@ -0,0 +1,12 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +test("instrumentation-node.ts imports liveServer for in-process WS auto-start", () => { + const source = readFileSync(resolve("src/instrumentation-node.ts"), "utf8"); + assert.ok( + source.includes("server/ws/liveServer"), + "instrumentation-node.ts should import @/server/ws/liveServer" + ); +}); diff --git a/tests/unit/live-ws-public-url.test.ts b/tests/unit/live-ws-public-url.test.ts index 3595e43370..deff8cb926 100644 --- a/tests/unit/live-ws-public-url.test.ts +++ b/tests/unit/live-ws-public-url.test.ts @@ -68,7 +68,7 @@ test("handshake response includes publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, "wss://ws.my-ai.com/live-ws"); }); @@ -82,7 +82,7 @@ test("handshake response includes null publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, null); }); @@ -92,7 +92,7 @@ test("protocol.live.publicUrl reflects env set after module import (lazy read)", const response = await wsRoute.GET(new Request("http://localhost/api/v1/ws")); assert.equal(response.status, 426); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.protocol.live.publicUrl, "wss://custom.example.com/ws"); }); @@ -106,13 +106,13 @@ test("publicUrl with non-WebSocket scheme is rejected (null)", async () => { ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, null); assert.equal(body.protocol.live.publicUrl, null); }); test("publicUrl with ws:// scheme is accepted", async () => { - process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20129/live-ws"; + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20132/live-ws"; const response = await wsRoute.GET( new Request("http://localhost/api/v1/ws?handshake=1", { @@ -121,6 +121,34 @@ test("publicUrl with ws:// scheme is accepted", async () => { ); assert.equal(response.status, 200); - const body = (await response.json()) as any; - assert.equal(body.live.publicUrl, "ws://lan-host:20129/live-ws"); + const body = await response.json(); + assert.equal(body.live.publicUrl, "ws://lan-host:20132/live-ws"); +}); + +test("handshake path is derived from NEXT_PUBLIC_LIVE_WS_PUBLIC_URL pathname", async () => { + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "wss://ws.my-ai.com/my-custom-ws"; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.live.path, "/my-custom-ws"); +}); + +test("handshake path defaults to /live-ws when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL is unset", async () => { + delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.live.path, "/live-ws"); }); diff --git a/tests/unit/livews-forward-backoff-4604.test.ts b/tests/unit/livews-forward-backoff-4604.test.ts index d7fe6a2026..9b2e100585 100644 --- a/tests/unit/livews-forward-backoff-4604.test.ts +++ b/tests/unit/livews-forward-backoff-4604.test.ts @@ -6,7 +6,7 @@ import { __resetLiveWsForwardingState, } from "../../open-sse/handlers/chatCore/telemetryHelpers.ts"; -// #4604 — In single-port Docker deployments the live-WS sidecar (port 20129) is +// #4604 — In single-port Docker deployments the live-WS sidecar (port 20132) is // not running, but forwardDashboardEventToLiveWs POSTed to it on every compression // event. Because the global fetch is proxyFetch, each ECONNREFUSED logged a // "[ProxyFetch] Undici dispatcher failed" warning — 272 times in 42 minutes. The @@ -35,7 +35,7 @@ test("backs off after consecutive failures and stops calling fetch", async () => let calls = 0; const fail = async () => { calls++; - throw new Error("connect ECONNREFUSED 127.0.0.1:20129"); + throw new Error("connect ECONNREFUSED 127.0.0.1:20132"); }; const clock = makeClock(); // First N attempts go through (and fail); after the threshold the forwarder From 65890d4aab24e66691af09487388680ec1d255c4 Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:03:17 +0800 Subject: [PATCH 47/56] fix(logs): prevent stale detail refresh reopening modal (#6323) * fix(logs): prevent stale detail refresh reopening modal * chore(stryker): register ollama-quota covering tests (release drift from merge burst) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- src/shared/components/RequestLoggerV2.tsx | 20 +-- ...ogger-autorefresh-visibility-3972.test.tsx | 114 ++++++++++++++++-- 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index 3c7b266e96..89de367a30 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -179,23 +179,15 @@ const RequestLoggerV2 = forwardRef