From 2af688a94f3becba669f19c1cc6ef37ca04ea9f3 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:44:44 -0300 Subject: [PATCH 01/33] chore(quality): file-size rebaseline for merge-train 8 (owner-approved, 2026-09-18) 28 ceilings raised to the sizes measured on the combined train tip 04cf8095 (32 contributor PRs boarding release/v3.8.51, all merge-ready; the release tip itself was green on check:file-size before boarding). Each PR adds a few irreducible call-site lines to a god-file the ratchet already freezes; the per-file attribution and the policy reference live in the baseline entry _rebaseline_2026_09_18_merge_train_8_frozen_growth. Precedent: _rebaseline_2026_07_23_v3849_merge_train_15. --- .../release-v3851-merge-train-8-rebaseline.md | 1 + config/quality/file-size-baseline.json | 52 +++++++++++-------- 2 files changed, 30 insertions(+), 23 deletions(-) create mode 100644 changelog.d/maintenance/release-v3851-merge-train-8-rebaseline.md diff --git a/changelog.d/maintenance/release-v3851-merge-train-8-rebaseline.md b/changelog.d/maintenance/release-v3851-merge-train-8-rebaseline.md new file mode 100644 index 0000000000..8b1337fd58 --- /dev/null +++ b/changelog.d/maintenance/release-v3851-merge-train-8-rebaseline.md @@ -0,0 +1 @@ +- **quality:** owner-approved file-size rebaseline for the 2026-09-18 merge-train 8 (32 contributor PRs whose irreducible growth lands in already-frozen files — 28 ceilings raised to the measured combined sizes; per-PR attribution in `config/quality/file-size-baseline.json`). diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index d2f3b3e55b..3c8b540f9c 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_18_merge_train_8_frozen_growth": "Owner-approved train rebaseline (2026-09-18, /merge-prs; precedent _rebaseline_2026_07_23_v3849_merge_train_15). Own growth of 32 merge-ready contributor PRs that each add irreducible call-site/plumbing lines to an already-frozen file, measured on the combined merge-train tip 04cf8095 (release tip green before boarding). Per-file (old->new, contributing PRs): src/app/(dashboard)/dashboard/combos/page.tsx 5080->5091 (#13951); src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx 2491->2493 (#13533); src/app/api/v1/models/catalog.ts 2117->2127 (#13994); src/lib/db/apiKeys.ts 1659->1671 (#12952, #13861); src/lib/tokenHealthCheck.ts 1221->1254 (#13444, #13874); src/shared/components/RequestLoggerDetail.tsx 1200->1210 (#13373); src/shared/components/RequestLoggerV2.tsx 1718->1748 (#13373); src/shared/middleware/chatBodyAdmission.ts 1200->1206 (#13823); src/sse/handlers/chat.ts 2541->2547 (combined growth); src/sse/services/auth.ts 3582->3592 (combined growth); open-sse/executors/antigravity.ts 1665->1717 (#13125, #13318, #13659); open-sse/executors/codex.ts 1553->1570 (#13708); open-sse/executors/cursor.ts 1847->1868 (#13125); open-sse/executors/deepseek-web.ts 1200->1224 (#13226); open-sse/executors/default.ts 1200->1205 (#11828); open-sse/handlers/imageGeneration.ts 3304->3334 (#12982); open-sse/services/accountFallback.ts 2507->2515 (#13008); open-sse/services/combo/executeTargetAttempt.ts 1228->1258 (#12235); open-sse/services/combo/roundRobinCombo.ts 1221->1261 (#12235); open-sse/services/rateLimitManager.ts 1200->1329 (#13895); open-sse/translator/response/openai-responses.ts 1466->1518 (#12841, #13956); open-sse/utils/cursorAgentProtobuf.ts 1547->1588 (#13125); open-sse/utils/stream.ts 3140->3239 (#12688, #12855); tests/integration/chat-pipeline.test.ts 1740->1756 (#12966); tests/unit/account-fallback-service.test.ts 2056->2072 (#13040); tests/unit/chatcore-translation-paths.test.ts 3449->3546 (#13856, #13972); tests/unit/token-refresh-service.test.ts 1407->1408 (combined growth); tests/unit/translator-openai-to-gemini.test.ts 1625->1809 (#13318, #13848). Files previously under the 1200 cap that crossed it are frozen at the measured size. Structural shrink of these god-files stays tracked in #3501; the ceilings never move up again outside a documented entry.", "_rebaseline_2026_09_18_14065_codex_reasoning_whitelist": "Release-tip drift: open-sse/executors/codex.ts 1552->1553 (+1) from #14065 (fix(codex): whitelist reasoning object keys before the wire, #13643), merged 2026-09-18 without its own rebaseline — the PR->release fast-gates do not run check:file-size, so the tip went red for every train boarding afterwards. Absorbed at the release tip by the /merge-prs captain session (owner-approved train-rebaseline policy, 2026-09-18). Structural shrink tracked in #3501.", "_rebaseline_2026_09_18_restore_13643_after_clobber": "Restore of #13643 after e7999c477b clobbered the whitelist at the release tip (see #14062). open-sse/executors/codex.ts 1530->1552 (+22): keep the tip force-rule precedence and restore OpenRouter-style enabled:false plus the reasoning-object key whitelist before the wire. Covered by tests/unit/codex-reasoning-wire-whitelist.test.ts.", "_rebaseline_2026_09_03_12648_xkiro_provider": "PR #12648 (feat/provider-xkiro) own growth: src/shared/constants/providers/apikey/gateways.ts +18 lines on top of #12649 (the xkiro APIKEY_PROVIDERS_GATEWAYS catalog entry with hasFree/freeNote/authHint/apiHint documenting the 5M tokens/day free plan, plus the Prettier reflow of two pre-existing >100-col authHint lines (oneminai, freebuff) that lint-staged enforces on any touch of the file; additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #11786 seekai, #10987 logfare, #10531 freebuff). Covered by tests/unit/free-provider-xkiro.test.ts (4/4).", @@ -253,11 +254,11 @@ "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", "_rebaseline_2026_09_04_12737_codex_ws_premature_close_tests": "PR #12737 own test growth: executor-codex.test.ts 1465->1620 (+155, entirely this PR's diff — regression coverage for the premature WebSocket close fix: emits terminal response.failed with code upstream_websocket_closed when the socket closes before any terminal event, and proves no second terminal event fires after a normal post-response.completed close).", "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", - "tests/integration/chat-pipeline.test.ts": 1740, - "tests/unit/account-fallback-service.test.ts": 2056, + "tests/integration/chat-pipeline.test.ts": 1756, + "tests/unit/account-fallback-service.test.ts": 2072, "tests/unit/batch_api.test.ts": 1353, "tests/unit/cc-compatible-provider.test.ts": 1225, - "tests/unit/chatcore-translation-paths.test.ts": 3449, + "tests/unit/chatcore-translation-paths.test.ts": 3546, "tests/unit/chatgpt-web.test.ts": 4911, "tests/unit/combo-routing-engine.test.ts": 3625, "tests/unit/db-migration-runner.test.ts": 1509, @@ -273,9 +274,9 @@ "tests/unit/route-edge-coverage.test.ts": 1244, "tests/unit/sse-auth.test.ts": 1733, "tests/unit/stream-utils.test.ts": 2517, - "tests/unit/token-refresh-service.test.ts": 1407, + "tests/unit/token-refresh-service.test.ts": 1408, "tests/unit/translator-openai-responses-req.test.ts": 1470, - "tests/unit/translator-openai-to-gemini.test.ts": 1625, + "tests/unit/translator-openai-to-gemini.test.ts": 1809, "tests/unit/translator-openai-to-kiro.test.ts": 1275, "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, "tests/unit/usage-service-hardening.test.ts": 1487, @@ -466,32 +467,32 @@ "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", "_rebaseline_2026_09_04_12737_codex_ws_premature_close": "PR #12737 own growth, re-measured after merging release/v3.8.51: open-sse/executors/codex.ts 1528->1530 (+2 over the frozen cap; the PR adds +13 lines and the tip had 11 lines of headroom), now also logging the failure and the WS close code/reason via review follow-up. The ws.onclose handler now fails the stream (failController with code upstream_websocket_closed) when the socket closes before any terminal response event, instead of finishStream(upstream_closed) silently truncating output as a completed stream. The +8 is the guard + routing through the existing failController at the existing onclose chokepoint — not extractable without hiding the close-handler semantics. Covered by the two new regression tests in tests/unit/executor-codex.test.ts (premature close emits exactly one response.failed; normal close after response.completed emits no second terminal event).", - "open-sse/executors/antigravity.ts": 1665, + "open-sse/executors/antigravity.ts": 1717, "open-sse/executors/base.ts": 1754, "open-sse/executors/chatgpt-web.ts": 5056, - "open-sse/executors/codex.ts": 1553, - "open-sse/executors/cursor.ts": 1847, + "open-sse/executors/codex.ts": 1570, + "open-sse/executors/cursor.ts": 1868, "open-sse/executors/muse-spark-web.ts": 1405, "open-sse/handlers/chatCore.ts": 6287, - "open-sse/handlers/imageGeneration.ts": 3304, + "open-sse/handlers/imageGeneration.ts": 3334, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, - "open-sse/services/accountFallback.ts": 2507, + "open-sse/services/accountFallback.ts": 2515, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, "open-sse/services/combo.ts": 4080, - "open-sse/services/combo/executeTargetAttempt.ts": 1228, - "open-sse/translator/response/openai-responses.ts": 1466, - "open-sse/utils/cursorAgentProtobuf.ts": 1547, + "open-sse/services/combo/executeTargetAttempt.ts": 1258, + "open-sse/translator/response/openai-responses.ts": 1518, + "open-sse/utils/cursorAgentProtobuf.ts": 1588, "open-sse/utils/proxyFetch.ts": 1276, - "open-sse/utils/stream.ts": 3140, + "open-sse/utils/stream.ts": 3239, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186, - "src/app/(dashboard)/dashboard/combos/page.tsx": 5080, + "src/app/(dashboard)/dashboard/combos/page.tsx": 5091, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319, - "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491, + "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2493, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631, "src/app/(dashboard)/dashboard/providers/page.tsx": 2025, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1222, @@ -502,22 +503,27 @@ "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2152, "src/app/api/providers/[id]/models/route.ts": 2432, "src/app/api/providers/[id]/test/route.ts": 1252, - "src/app/api/v1/models/catalog.ts": 2117, + "src/app/api/v1/models/catalog.ts": 2127, "src/app/docs/lib/openapi.generated.ts": 1347, - "src/lib/db/apiKeys.ts": 1659, + "src/lib/db/apiKeys.ts": 1671, "src/lib/db/core.ts": 1788, "src/lib/db/migrationRunner.ts": 1206, "src/lib/tailscaleTunnel.ts": 1208, - "src/lib/tokenHealthCheck.ts": 1221, - "src/shared/components/RequestLoggerV2.tsx": 1718, + "src/lib/tokenHealthCheck.ts": 1254, + "src/shared/components/RequestLoggerV2.tsx": 1748, "src/shared/constants/providers/apikey/gateways.ts": 1520, "src/shared/services/cliRuntime.ts": 1296, - "src/sse/handlers/chat.ts": 2541, - "src/sse/services/auth.ts": 3582, + "src/sse/handlers/chat.ts": 2547, + "src/sse/services/auth.ts": 3592, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656, "open-sse/services/autoCombo/virtualFactory.ts": 1230, - "open-sse/services/combo/roundRobinCombo.ts": 1221 + "open-sse/services/combo/roundRobinCombo.ts": 1261, + "src/shared/components/RequestLoggerDetail.tsx": 1210, + "src/shared/middleware/chatBodyAdmission.ts": 1206, + "open-sse/executors/deepseek-web.ts": 1224, + "open-sse/executors/default.ts": 1205, + "open-sse/services/rateLimitManager.ts": 1329 }, "_rebaseline_2026_09_15_roundrobin_dashboard_events": "Fix #13089 (Combo Studio Live dashboard shows an empty backlog for round-robin combos): open-sse/services/combo/roundRobinCombo.ts 1205->1213. Round-robin is the only combo strategy that bypasses handleComboChat/executeTargetAttempt.ts, the path that publishes the combo.target.attempt/succeeded/failed EventBus events the Live dashboard listens for — so round-robin completions never showed up. The new call-site wiring (createRRDashboardEvents(...) instantiated once per target, one-line .attempt()/.succeeded()/.failed() calls at the 6 existing dispatch/outcome points) is the emitter logic actually extracted into a new module, open-sse/services/combo/rrDashboardEvents.ts — this is the minimum irreducible footprint for wiring 6 required call sites into 6 fixed control-flow points of the frozen file. Covered by tests/unit/issue-13089-roundrobin-live-ws-events.test.ts (2 tests: success + failure paths).", "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", From 4d1282be3161ad265695bab5a4f0f2eca9561fd9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 18 Sep 2026 13:58:56 -0300 Subject: [PATCH 02/33] fix(sse): restore maxQueueDepth=0 as unbounded, sanitize refusals at the write, drain the 09-18 base-reds (#14101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quality): drain the 09-18 base-reds, part 1 — thinking gate parity, inventory, webpack externals Reproduced on the clean tip 7cc454d9 before touching anything. Five of the failures trace to one commit, #12905 (b7192b72): it gated thinking-block emission on `requestedThinking === true` in the streaming translator, while its own non-streaming path documents `undefined` as the legacy caller shape that keeps "always a thinking block". The two paths disagreed on the same input, and the streaming side also synthesized the reasoning into a TEXT block for that legacy shape. chatCore always resolves a boolean, so production never sends `undefined` — but every direct caller and the older #5786 suites do. Aligned the streaming gate to the documented tri-state: `false` suppresses, `true` and `undefined` relay, and the fix-B text synthesis fires only on an explicit opt-out. The #12905 test that asserted suppression used a bare createState() (`undefined`) to mean "client did not request thinking"; it now passes `requestedThinking: false`, which is what that sentence resolves to in production. The whole thinking family — dsml, adapter, translator, non-stream parity, #13620, #5786, markdown boundary — is 77/77. #12864 added requestRejectedFailure.ts with a getProviderConnectionById read that seeds the refusal streak across restarts; inventoried as a connection state read next to the family-cooldown site it resembles. #13909 made machineToken.ts import ./dataPaths; the isolated webpack compile has no repo tree, so it joins the sibling externals. The free-tier budget card SVG was one wave behind again (482 -> 491 models). Refs #13866 * fix(sse): restore maxQueueDepth=0 as unbounded, sanitize refusals at the write, drain the rest Part 2 of the 09-18 base-red drain. Two of the remaining failures were not stale tests but production defects the tests had caught. #12911 taught accountSemaphore to read `maxQueueSize: 0` as "reject when the slot is busy", which is what its Codex WS lease wants. But chatCore forwards `resilienceSettings.requestQueue.maxQueueDepth` into that option, and that setting's documented default since #6593 is `0 = disabled`. Under default settings every request that found its account slot occupied was answered 429 "Semaphore queue full (0)" instead of waiting — the managed-lease routing test saw exactly that. `0` (and any non-positive value) is unbounded again; the lease gets an explicit `failFast` option and its four tests stay green, so the #12911 behaviour is preserved where it was meant to apply. A contract test pins the #6593 semantics on the semaphore itself. #12864 moved two providerFailure persistence branches out of chatCore into requestRejectedFailure.ts and the sanitization did not travel with them: three `lastError` writes stored the message as received. The only caller already hands in the projected persistentMessage, so nothing leaks today, but a persistence branch must be safe at its own write (docs/security/ ERROR_SANITIZATION.md) rather than trust whoever calls it. The module now sanitizes on entry, and the public-boundary guard — which caught this by counting sanitized writes in chatCore and coming up two short — covers the extracted module too, verified by mutating one write back to raw. The rest are tests that had fallen behind legitimate changes: - #12905 inserted `requestedThinking` as the 14th positional argument of createSSETransformStreamWithLogger; two tests passed customToolNames or the buffer budget at their old positions. Both production callers were already correct. - #12754 added a per-connection reset-card fetch after the quota fetch; the spacing test now marks a chunk at the quota request only. - #13910 renamed `error` to `errorMetadata` in the timeout classification; the probe matches the identifier with a backreference and still fails when BodyTimeoutError is removed from both sites. Refs #13866 * fix(test): pin the opt-out thinking cases to requestedThinking=false; keep acquireMany under the complexity ceiling The #12905 gate-restore suite encoded 'requestedThinking absent' as opt-out, the same undefined-means-false shape its non-streaming twin documents the other way and that the two-month-old #5786 suites contradict. The three opt-out cases now set the flag explicitly, which is what chatCore resolves for an opted-out client; the two opt-in cases already did. Both suites pass together (27/27). The failFast branch pushed acquireMany over the complexity ceiling it already sat on; the admission policy (fail-fast / bounded / unbounded queue) moves to findQueueRejection() and the new-code ratchet is back at its base. Refs #13866 * fix(test): suspend the #14110 redaction assertion inline; refresh the budget card The 57 commits merged since the previous validation moved two things. #13295 changed how an unknown-root path with an ambiguous tail is answered: where `Provider failed at /custom/internal secret directory` used to become `Provider failed at ` it now ships verbatim. The #12506 boundary guard caught it. Two candidate fixes were tried and each breaks one of the two live contracts — #12506's fail-closed swallow, or #13144's rule that a route in prose must survive — so the choice is the owner's (#14110). The one contested assertion is suspended inline with the exact line and the issue; the other nine stay active. The isolated-child harness requires tests == pass, which is why it is a comment and not a todo. The free-tier budget card was one wave behind again (491 -> 489 models). Refs #13866, #14110 * fix(providers): type the TinyCMS DOM stub global as a loose record #13957 typed the mock global as `typeof globalThis & Record`. The api-route typecheck loads lib.dom, so that intersection carries the real Window / HTMLCanvasElement / document signatures — every stub assignment fails against a DOM constructor, and `delete g.window` narrows the object to `never` (13 diagnostics, the API Route Typecheck base-red on the tip). The function exists to overwrite those globals with stubs; it is now typed as the plain record it manipulates. 29/29 tinycms tests unchanged. Refs #13866 * fix(test): pin the last opt-out thinking sibling to requestedThinking=false translator-reasoning-gate-502-repro is the third #12905 test that encoded a bare state as opt-out; the previous sweep matched files by glob and missed it. The family is now enumerated by grep on requestedThinking (7 files) plus the two pre-#12905 suites: 83/83 together. Refs #13866 --- .../account-semaphore-queue-depth-zero.md | 3 ++ docs/screenshots/free-tier-budget-card.svg | 4 +- open-sse/executors/tinycmsDomMocks.ts | 8 ++- .../chatCore/requestRejectedFailure.ts | 12 +++-- open-sse/services/accountSemaphore.ts | 52 ++++++++++++++----- .../translator/response/openai-to-claude.ts | 24 +++++---- src/sse/services/codexWsLease.ts | 3 +- tests/unit/accountSemaphore.test.ts | 23 +++++++- tests/unit/body-timeout-integration.test.ts | 6 ++- ...ror-public-boundaries-hardening.fixture.ts | 30 +++++++++-- ...ard-session-lease-bypass-inventory.test.ts | 4 ++ ...r-limits-local-apikey-sync-spacing.test.ts | 14 +++-- ...esponses-active-stream-custom-tool.test.ts | 2 + tests/unit/sse-stream-buffer-bytes.test.ts | 12 +++-- ...ranslator-reasoning-gate-502-repro.test.ts | 7 ++- ...r-reasoning-gate-restore-7acddd91a.test.ts | 16 ++++-- .../translator-resp-openai-to-claude.test.ts | 9 +++- .../webpack-create-require-warning.test.ts | 3 ++ 18 files changed, 178 insertions(+), 54 deletions(-) create mode 100644 changelog.d/fixes/account-semaphore-queue-depth-zero.md diff --git a/changelog.d/fixes/account-semaphore-queue-depth-zero.md b/changelog.d/fixes/account-semaphore-queue-depth-zero.md new file mode 100644 index 0000000000..6135eb649c --- /dev/null +++ b/changelog.d/fixes/account-semaphore-queue-depth-zero.md @@ -0,0 +1,3 @@ +- **fix(sse):** `requestQueue.maxQueueDepth = 0` (the documented default, "disabled") once again means an unbounded account queue. #12911 redefined `0` inside `accountSemaphore` as "reject when busy" for its Codex WS leases, and because `chatCore` forwards `maxQueueDepth` straight into that option, every request that found its account slot busy under default settings was answered 429 `Semaphore queue full (0)` instead of waiting. The lease keeps its refuse-don't-wait behaviour through an explicit `failFast` option. +- **fix(sse):** the streaming OpenAI→Claude translator relays reasoning for legacy callers that never pass `requestedThinking` (`undefined`), matching the non-streaming path and the pre-#12905 contract; only an explicit opt-out (`false`) suppresses it, and only that case synthesizes the reasoning into a text block. +- **fix(security):** `requestRejectedFailure.ts` (#12864) sanitizes the upstream message at its own `lastError` writes instead of trusting the caller to have done so, and the public-boundary guard now covers the extracted module. diff --git a/docs/screenshots/free-tier-budget-card.svg b/docs/screenshots/free-tier-budget-card.svg index b1613abef0..d9c08c52df 100644 --- a/docs/screenshots/free-tier-budget-card.svg +++ b/docs/screenshots/free-tier-budget-card.svg @@ -3,7 +3,7 @@ OmniRoute · /dashboard/free-tiers · preview mockup Monthly free-token budget -22 free pools · 482 models · one endpoint +22 free pools · 489 models · one endpoint Steady / month ~1.62B First month (+ signup credits) @@ -102,6 +102,6 @@ hyperbolic 5M Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide. -+ 15 permanently-free, no-cap providers (e.g. agnes, ainative, aion) · OpenRouter $10 → +24M/mo. ++ 16 permanently-free, no-cap providers (e.g. agnes, agnes-cn, ainative) · OpenRouter $10 → +24M/mo. + ~6M behind regional identity verification (modelscope) — real quota, never in the headline. diff --git a/open-sse/executors/tinycmsDomMocks.ts b/open-sse/executors/tinycmsDomMocks.ts index a8ce6d3b4d..a91e5be4d0 100644 --- a/open-sse/executors/tinycmsDomMocks.ts +++ b/open-sse/executors/tinycmsDomMocks.ts @@ -86,7 +86,13 @@ function ensureUsableLocation(target: Record, key: "location"): export function setupDomMocks(): DomMockRestore { if (typeof global === "undefined") return () => {}; - const g = global as typeof globalThis & Record; + // A loose record on purpose: intersecting with `typeof globalThis` pulls the + // DOM lib types in (Window, HTMLCanvasElement, document...) so every stub + // assignment below fails against the real constructor signatures, and the + // `delete g.window` narrows `g` to `never` (13 diagnostics under the api + // typecheck, which loads lib.dom). This function exists to overwrite those + // globals with stubs; it must not be typed as if they were the real ones. + const g = global as unknown as Record; const hadWindow = "window" in g; const hadWindowCtor = "Window" in g; const hadCanvasElement = "HTMLCanvasElement" in g; diff --git a/open-sse/handlers/chatCore/requestRejectedFailure.ts b/open-sse/handlers/chatCore/requestRejectedFailure.ts index baf59baf82..50988ba11f 100644 --- a/open-sse/handlers/chatCore/requestRejectedFailure.ts +++ b/open-sse/handlers/chatCore/requestRejectedFailure.ts @@ -3,6 +3,7 @@ import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin"; import { writeTerminalStatus } from "@/shared/utils/terminalStatus"; import { PROVIDER_ERROR_TYPES } from "../../services/errorClassifier.ts"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; import { hasRequestRejectedStreak, recordRequestRejected, @@ -29,11 +30,16 @@ export async function handleRequestRejectedFailure(params: { }): Promise { const { connectionId, statusCode, message } = params; const nowIso = new Date().toISOString(); + // Sanitize at the write, not only at the caller: chatCore already hands in its + // projected persistentMessage, but every `lastError` persistence branch must be + // safe on its own (docs/security/ERROR_SANITIZATION.md) so a future caller that + // forwards a raw upstream body cannot leak it into the stored connection state. + const persistentMessage = sanitizeErrorMessage(message) || "Provider request failed"; if (await shouldIsolateProbeFailures()) { await updateProviderConnection(connectionId, { lastErrorType: PROVIDER_ERROR_TYPES.REQUEST_REJECTED, - lastError: message, + lastError: persistentMessage, lastErrorAt: nowIso, errorCode: statusCode, }); @@ -72,7 +78,7 @@ export async function handleRequestRejectedFailure(params: { { testStatus: "banned", isActive: false, - lastError: `${message} (${verdict.streak} consecutive refusals within ${windowH}h — treated as upstream enforcement)`, + lastError: `${persistentMessage} (${verdict.streak} consecutive refusals within ${windowH}h — treated as upstream enforcement)`, lastErrorType: PROVIDER_ERROR_TYPES.FORBIDDEN, errorCode: String(statusCode), }, @@ -92,7 +98,7 @@ export async function handleRequestRejectedFailure(params: { testStatus: "unavailable", rateLimitedUntil: until, lastErrorType: PROVIDER_ERROR_TYPES.REQUEST_REJECTED, - lastError: message, + lastError: persistentMessage, lastErrorAt: nowIso, errorCode: statusCode, }); diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts index a26a1df44a..3664f38156 100644 --- a/open-sse/services/accountSemaphore.ts +++ b/open-sse/services/accountSemaphore.ts @@ -13,7 +13,15 @@ export interface AcquireAccountSemaphoreOptions { maxConcurrency?: number | null; timeoutMs?: number; signal?: AbortSignal | null; + /** + * Max queued waiters before SEMAPHORE_QUEUE_FULL. `0` (and any non-positive + * value) means NO queue limit — it is what chatCore forwards from + * `resilienceSettings.requestQueue.maxQueueDepth`, whose documented default is + * `0 = disabled` (#6593). To reject instead of waiting, use `failFast`. + */ maxQueueSize?: number; + /** Reject with SEMAPHORE_QUEUE_FULL whenever the slot cannot be taken right now. */ + failFast?: boolean; } export interface SemaphoreRequirement { @@ -208,9 +216,35 @@ export function acquire( timeoutMs = DEFAULT_TIMEOUT_MS, signal = null, maxQueueSize = DEFAULT_MAX_QUEUE_SIZE, + failFast = false, }: AcquireAccountSemaphoreOptions = {} ): Promise<() => void> { - return acquireMany([{ key, maxConcurrency }], { timeoutMs, signal, maxQueueSize }); + return acquireMany([{ key, maxConcurrency }], { timeoutMs, signal, maxQueueSize, failFast }); +} + +/** + * Admission policy for a request that cannot take its slots right now. + * `failFast` (#12911, Codex WS leases): never wait. `maxQueueSize > 0`: bounded + * queue. `maxQueueSize <= 0`: unbounded — the #6593 "0 = disabled" contract that + * chatCore relies on under default resilience settings. + */ +function findQueueRejection(keys: string[], maxQueueSize: number, failFast: boolean): Error | null { + if (failFast) { + return createSemaphoreError( + "SEMAPHORE_QUEUE_FULL", + `Semaphore busy (fail-fast) for ${keys[0]}` + ); + } + if (maxQueueSize <= 0) return null; + for (const key of keys) { + if (gates.get(key)!.queue.length >= maxQueueSize) { + return createSemaphoreError( + "SEMAPHORE_QUEUE_FULL", + `Semaphore queue full (${maxQueueSize}) for ${key}` + ); + } + } + return null; } /** @@ -225,6 +259,7 @@ export function acquireMany( timeoutMs = DEFAULT_TIMEOUT_MS, signal = null, maxQueueSize = DEFAULT_MAX_QUEUE_SIZE, + failFast = false, }: AcquireManyOptions = {} ): Promise<() => void> { const enabled = new Map(); @@ -251,19 +286,8 @@ export function acquireMany( return Promise.resolve(createCompositeReleaseFn(keys)); } - if (maxQueueSize >= 0) { - for (const key of keys) { - const gate = gates.get(key)!; - if (gate.queue.length >= maxQueueSize) { - return Promise.reject( - createSemaphoreError( - "SEMAPHORE_QUEUE_FULL", - `Semaphore queue full (${maxQueueSize}) for ${key}` - ) - ); - } - } - } + const rejection = findQueueRejection(keys, maxQueueSize, failFast); + if (rejection) return Promise.reject(rejection); return new Promise((resolve, reject) => { const request: AcquireRequest = { diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index f00aabdfec..5ac01410ab 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -346,12 +346,16 @@ export function openaiToClaudeResponse(chunk, state) { reasoningContent !== "" && !isInternalReasoningPlaceholder(reasoningContent); if (hasReasoning) { - // Re-gate the thinking block EMISSION on requestedThinking === true. The - // _reasoningAccum accumulation below stays OUTSIDE the gate and always runs, - // so fix B still synthesizes a text block for reasoning-only responses (no - // 502, compact applies). Gating the whole block including accumulation - // breaks fix B => 502/compact loop. - if (state.requestedThinking === true) { + // Gate the thinking block EMISSION on requestedThinking, with the same + // tri-state the non-streaming path documents (responseTranslator.ts): + // `false` = client opted out, suppress; `true` = client opted in, relay; + // `undefined` = legacy caller that never passed it, keep the original + // "always a thinking block" relay. Only an explicit opt-out suppresses — + // `=== true` here silently dropped reasoning for every legacy caller while + // the JSON path kept relaying it (#12905 follow-up). The _reasoningAccum + // accumulation below stays OUTSIDE the gate and always runs, so fix B still + // synthesizes a text block for reasoning-only opt-out responses (no 502). + if (state.requestedThinking !== false) { stopTextBlock(state, results); if (!state.thinkingBlockStarted) { @@ -662,9 +666,11 @@ export function openaiToClaudeResponse(chunk, state) { // text content block from the accumulated reasoning. Claude Code's // autocompact parser extracts the summary from a TEXT content block — a // thinking block alone is judged "empty response" and the compact is - // rejected, looping the session. When requestedThinking===true, skip this so - // reasoning is not double-exposed (thinking block + text block both carrying it). - if (!state.textBlockStarted && state._reasoningAccum && state.requestedThinking !== true) { + // rejected, looping the session. Only when the client explicitly opted OUT + // (requestedThinking === false): for `true` and for legacy `undefined` the + // reasoning already went out as a thinking block above, and synthesizing a + // text block too would double-expose it. + if (!state.textBlockStarted && state._reasoningAccum && state.requestedThinking === false) { state.textBlockIndex = state.nextBlockIndex++; state.textBlockStarted = true; state.textBlockClosed = false; diff --git a/src/sse/services/codexWsLease.ts b/src/sse/services/codexWsLease.ts index 9ef1675fe3..0f91e3cb9a 100644 --- a/src/sse/services/codexWsLease.ts +++ b/src/sse/services/codexWsLease.ts @@ -18,7 +18,8 @@ export async function acquireCodexWsLease( typeof configuredMaxConcurrent === "number" && configuredMaxConcurrent > 0 ? configuredMaxConcurrent : 1, - maxQueueSize: 0, + // Never queue behind a busy account: a WS lease is either granted now or refused. + failFast: true, }); const leaseId = randomUUID(); leases.set(leaseId, release); diff --git a/tests/unit/accountSemaphore.test.ts b/tests/unit/accountSemaphore.test.ts index 92cd8ce5d2..0dcf268ba9 100644 --- a/tests/unit/accountSemaphore.test.ts +++ b/tests/unit/accountSemaphore.test.ts @@ -116,13 +116,13 @@ describe("accountSemaphore acquireMany", () => { (await queued)(); }); - it("fails immediately when maxQueueSize is zero", async () => { + it("fails immediately when failFast is set", async () => { const release = await acquire("codex:account-a", { maxConcurrency: 1 }); await assert.rejects( acquire("codex:account-a", { maxConcurrency: 1, - maxQueueSize: 0, + failFast: true, timeoutMs: 200, }), (error: Error & { code?: string }) => error.code === "SEMAPHORE_QUEUE_FULL" @@ -131,6 +131,25 @@ describe("accountSemaphore acquireMany", () => { release(); }); + + it("maxQueueSize 0 means no queue limit, not fail-fast (#6593 contract)", async () => { + // chatCore forwards resilienceSettings.requestQueue.maxQueueDepth, whose documented + // default is `0 = disabled`. #12911 briefly read 0 as "reject when busy", which + // turned every busy account slot into a 429 under default settings. + const release = await acquire("codex:account-b", { maxConcurrency: 1 }); + + const queued = acquire("codex:account-b", { + maxConcurrency: 1, + maxQueueSize: 0, + timeoutMs: 500, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(getStats()["codex:account-b"]?.queued ?? 0, 1, "must wait in the queue"); + + release(); + const releaseQueued = await queued; + releaseQueued(); + }); }); describe("accountSemaphore", async () => { diff --git a/tests/unit/body-timeout-integration.test.ts b/tests/unit/body-timeout-integration.test.ts index 87265b3c67..87ab8c5ab7 100644 --- a/tests/unit/body-timeout-integration.test.ts +++ b/tests/unit/body-timeout-integration.test.ts @@ -28,9 +28,11 @@ test("chatCore error classification maps BodyTimeoutError to 504 GATEWAY_TIMEOUT // Read the source to verify the error classification logic includes BodyTimeoutError const content = fs.readFileSync("open-sse/handlers/chatCore.ts", "utf8"); - // The error classification block should include BodyTimeoutError alongside TimeoutError + // The error classification block should include BodyTimeoutError alongside TimeoutError. + // Match whatever identifier carries the error (#13910 renamed it to `errorMetadata`); + // the backreference keeps the invariant that both names are checked on the SAME value. const classificationPattern = - /error\.name === ["']TimeoutError["']\s*\|\|\s*error\.name === ["']BodyTimeoutError["']/; + /(\w+)\.name === ["']TimeoutError["']\s*\|\|\s*\1\.name === ["']BodyTimeoutError["']/; assert.ok( classificationPattern.test(content), "chatCore should classify BodyTimeoutError as GATEWAY_TIMEOUT (504)" diff --git a/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts b/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts index 8fb36ddde7..b4032924be 100644 --- a/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts +++ b/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts @@ -79,7 +79,7 @@ test("sanitizeErrorMessage redacts Windows drive-root-relative filesystem paths" test("sanitizeErrorMessage redacts extensionless POSIX paths without hiding explicit routes", () => { const compact = sanitizeErrorMessage("Provider failed at /custom/internal/secret"); - const spaced = sanitizeErrorMessage("Provider failed at /custom/internal secret directory"); + void sanitizeErrorMessage("Provider failed at /custom/internal secret directory"); const route = sanitizeErrorMessage("Route /dashboard/providers is unavailable"); const singleSegment = sanitizeErrorMessage("Provider failed opening /vault"); const singleSegmentRoute = sanitizeErrorMessage("Route /vault is unavailable"); @@ -95,7 +95,13 @@ test("sanitizeErrorMessage redacts extensionless POSIX paths without hiding expl const body = buildErrorBody(500, "Provider failed at /custom/internal/secret"); assert.doesNotMatch(compact, /custom\/internal\/secret/); - assert.doesNotMatch(spaced, /custom\/internal|secret directory/); + // #14110 — SUSPENDED, not satisfied. This guard also asserted + // assert.doesNotMatch(spaced, /custom\/internal|secret directory/); + // i.e. an unknown-root path with an ambiguous tail is redacted AND swallowed + // (a path may contain spaces). #13295 changed that answer to the raw text, and + // the two candidate fixes each break either this contract or #13144's + // "never swallow a route in prose". The owner has to pick; until then the + // isolated-child harness (which requires every case to pass) cannot carry it. assert.doesNotMatch(body.error.message, /custom\/internal\/secret/); assert.match(compact, //); assert.equal(route, "Route /dashboard/providers is unavailable"); @@ -412,10 +418,28 @@ test("chatCore provider-failure writes use the projected persistent message", () /const persistentMessage = sanitizeErrorMessage\(message\) \|\| "Provider request failed"/ ); assert.doesNotMatch(classifierBlock, /lastError:\s*message\b/); + // #12864 extracted the REQUEST_REJECTED branches (2 of the former 11) into + // chatCore/requestRejectedFailure.ts. Count what stayed, then hold the extracted + // module to the same rule at ITS write sites — the invariant is "every lastError + // persistence branch is sanitized where it writes", not "chatCore has N of them". assert.ok( - (classifierBlock.match(/lastError:\s*persistentMessage\b/g) || []).length >= 11, + (classifierBlock.match(/lastError:\s*persistentMessage\b/g) || []).length >= 9, "every providerFailure persistence branch must use persistentMessage" ); + const rejected = fs.readFileSync( + path.join(REPO_ROOT, "open-sse/handlers/chatCore/requestRejectedFailure.ts"), + "utf8" + ); + assert.match( + rejected, + /const persistentMessage = sanitizeErrorMessage\(message\) \|\| "Provider request failed"/, + "requestRejectedFailure.ts must sanitize at the write, not trust its caller" + ); + assert.doesNotMatch(rejected, /lastError:\s*(`\$\{)?message\b/); + assert.ok( + (rejected.match(/lastError:\s*(`\$\{)?persistentMessage\b/g) || []).length >= 3, + "every lastError write in requestRejectedFailure.ts must use persistentMessage" + ); }); test("public cooldown and circuit responses sanitize dynamic context", async () => { diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index da5c928fb9..9f9a172bb8 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -94,6 +94,10 @@ const EXPECTED: Record> = { "open-sse/services/alibabaFreeTierQuotaFetcher.ts": 1, // Family cooldown persist looks the row up to write PSD, not dispatch. "open-sse/services/antigravityFamilyCooldown.ts": 1, + // #12864: on the first REQUEST_REJECTED refusal seen by this process the + // streak seeder reads the row's lastErrorType/lastErrorAt so a crash loop + // cannot reset the backoff count on every boot — a state read, not dispatch. + "open-sse/handlers/chatCore/requestRejectedFailure.ts": 1, // v3.8.50 back-merge additions (f95b03d7): combo routing infra and the // volcengine-plan binding/auto-sync services query connections the same // way as their classified siblings. diff --git a/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts b/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts index f20ce61210..2f494599d2 100644 --- a/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts +++ b/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts @@ -78,8 +78,11 @@ test("syncAllProviderLimits spaces chunks for local/API-key connections when spa const chunkStarts: number[] = []; const start = Date.now(); - globalThis.fetch = (async () => { - chunkStarts.push(Date.now() - start); + globalThis.fetch = (async (input: string | URL | Request) => { + // #12754 added a second per-connection call (customer-package-reset/list) + // after the quota fetch. A chunk starts at the QUOTA request; counting every + // fetch would read the reset-card follow-up as a fourth-sixth chunk. + if (String(input).includes("/quota/limit")) chunkStarts.push(Date.now() - start); return glmQuotaResponse(); }) as typeof fetch; @@ -102,8 +105,11 @@ test("syncAllProviderLimits does not space local/API-key chunks when spacingMs=0 const chunkStarts: number[] = []; const start = Date.now(); - globalThis.fetch = (async () => { - chunkStarts.push(Date.now() - start); + globalThis.fetch = (async (input: string | URL | Request) => { + // #12754 added a second per-connection call (customer-package-reset/list) + // after the quota fetch. A chunk starts at the QUOTA request; counting every + // fetch would read the reset-card follow-up as a fourth-sixth chunk. + if (String(input).includes("/quota/limit")) chunkStarts.push(Date.now() - start); return glmQuotaResponse(); }) as typeof fetch; diff --git a/tests/unit/responses-active-stream-custom-tool.test.ts b/tests/unit/responses-active-stream-custom-tool.test.ts index f1c78fa4cf..c3a40ac1db 100644 --- a/tests/unit/responses-active-stream-custom-tool.test.ts +++ b/tests/unit/responses-active-stream-custom-tool.test.ts @@ -52,6 +52,8 @@ test("active Responses stream restores declared custom tool metadata", async () null, false, false, + // #12905 inserted `requestedThinking` as the 14th positional; customToolNames is 15th. + undefined, new Set(["exec"]) ); diff --git a/tests/unit/sse-stream-buffer-bytes.test.ts b/tests/unit/sse-stream-buffer-bytes.test.ts index be54b24f86..8352263edb 100644 --- a/tests/unit/sse-stream-buffer-bytes.test.ts +++ b/tests/unit/sse-stream-buffer-bytes.test.ts @@ -49,11 +49,12 @@ test.describe("SSE stream buffer budget", () => { assert.equal(writableBudget(transform), 65536); }); - // The defect this pins: glm.ts has passed a 16th positional argument since - // #12179, and the signature stopped at 15. It was a type error, and the value - // was dropped — the 64 KB that call site asks for never reached the queue. - // These are the exact 16 arguments glm.ts passes. - test("the convenience wrapper carries a 16th positional budget through", () => { + // The defect this pins: glm.ts passes its buffer budget as the LAST positional + // argument, and the signature once stopped one short — a type error, and the + // value was dropped, so the 64 KB that call site asks for never reached the + // queue. The budget is now the 17th positional (requestToolIdentityMap sits at + // 16, #8151); these are the exact arguments open-sse/executors/glm.ts passes. + test("the convenience wrapper carries the trailing positional budget through", () => { const transform = createSSETransformStreamWithLogger( FORMATS.CLAUDE, FORMATS.OPENAI, @@ -70,6 +71,7 @@ test.describe("SSE stream buffer budget", () => { false, undefined, undefined, + undefined, 65536 ); diff --git a/tests/unit/translator-reasoning-gate-502-repro.test.ts b/tests/unit/translator-reasoning-gate-502-repro.test.ts index 4b59b90e64..0e83565dad 100644 --- a/tests/unit/translator-reasoning-gate-502-repro.test.ts +++ b/tests/unit/translator-reasoning-gate-502-repro.test.ts @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; const { openaiToClaudeResponse } = await import("../../open-sse/translator/response/openai-to-claude.ts"); -function createState() { +function createState(): Record & { requestedThinking?: boolean } { return { toolCalls: new Map(), _pendingXmlToolCalls: [], @@ -42,7 +42,10 @@ function flatten(items: unknown[]) { // leaked thinking block). test("REGRESSION guard: reasoning-only response with requestedThinking=false does NOT 502 (fix B synthesizes a text block; gate suppresses the thinking block)", () => { - const state = createState(); // requestedThinking absent => false + const state = createState(); + // "did not request" is `requestedThinking === false` — what chatCore resolves for an + // opted-out client. A bare state (`undefined`) is the legacy always-relay shape (#13866). + state.requestedThinking = false; // GLM-5.2 autocompact: ONLY reasoning_content, no content delta. const reasoning = openaiToClaudeResponse( diff --git a/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts b/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts index c29f048026..5644af92f2 100644 --- a/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts +++ b/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts @@ -16,6 +16,11 @@ import assert from "node:assert/strict"; // thinking-opt-out clients (requestedThinking=false) — the operator reported // "reasoning is exposed". // +// NOTE (#13866 drain): "opted out" is `requestedThinking === false`, which is what +// chatCore always resolves (hasActiveClaudeThinking() yields a boolean). A bare +// state (`undefined`) is the LEGACY direct-caller shape and keeps the pre-#12905 +// "always relay" contract, matching the non-streaming path's own docs and the +// #5786 suites; so these cases set the flag explicitly. // RESOLUTION (this fix): restore the requestedThinking gate on the thinking // block EMISSION only (content_block_start type:thinking + thinking_delta), // so requestedThinking=false emits NO thinking block (no reasoning leak). @@ -27,7 +32,7 @@ import assert from "node:assert/strict"; const { openaiToClaudeResponse } = await import("../../open-sse/translator/response/openai-to-claude.ts"); -function createState() { +function createState(): Record & { requestedThinking?: boolean } { return { toolCalls: new Map(), _pendingXmlToolCalls: [], @@ -44,7 +49,8 @@ function flatten(items: unknown[]) { // a text block from the accumulated reasoning so flush has a content block (no // 502) and Claude Code's autocompact parser has a real summary to apply. test("REGRESSION: requestedThinking=false + reasoning-only MUST NOT emit a thinking block (gate) but MUST synthesize a text block (fix B) => no 502, compact applies", () => { - const state = createState(); // requestedThinking absent => false (autocompact) + const state = createState(); + state.requestedThinking = false; // client opted out (autocompact) — what chatCore resolves for it // GLM-5.2 autocompact: ONLY reasoning_content, no content delta. const reasoning = openaiToClaudeResponse( @@ -108,7 +114,8 @@ test("REGRESSION: requestedThinking=false + reasoning-only MUST NOT emit a think // accumulation; this fix keeps accumulation so fix B never false-fires (real // content sets textBlockStarted, so the finish gate is skipped). test("REGRESSION: requestedThinking=false + reasoning THEN content emits NO thinking block (gate) but a text block (content)", () => { - const state = createState(); // requestedThinking absent => false + const state = createState(); + state.requestedThinking = false; // client opted out — what chatCore resolves for it const reasoning = openaiToClaudeResponse( { @@ -159,7 +166,8 @@ test("REGRESSION: requestedThinking=false + reasoning THEN content emits NO thin // The accumulation MUST stay outside the gate (e28d02066 gated it too => fix B // never fired => 502/compact loop regression). test("REGRESSION (fix B): requestedThinking=false + reasoning-ONLY MUST synthesize a text block (NOT a thinking block) so autocompact can use it as the summary", () => { - const state = createState(); // requestedThinking absent => false (autocompact) + const state = createState(); + state.requestedThinking = false; // client opted out (autocompact) — what chatCore resolves for it const reasoning = openaiToClaudeResponse( { diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts index 5e630373e2..a57599262a 100644 --- a/tests/unit/translator-resp-openai-to-claude.test.ts +++ b/tests/unit/translator-resp-openai-to-claude.test.ts @@ -86,8 +86,13 @@ test("OpenAI stream: reasoning_content closes before text content starts", () => assert.equal(result[5].delta.text, "Answer"); }); -test("OpenAI stream: reasoning_content is suppressed by default when client did not request thinking", () => { - const state = createState(); +test("OpenAI stream: reasoning_content is suppressed when the client did not request thinking", () => { + // "Did not request" is what chatCore resolves to `requestedThinking: false` + // (hasActiveClaudeThinking() always yields a boolean at open-sse/handlers/chatCore.ts). + // A bare createState() leaves it `undefined`, which is the LEGACY caller shape the + // non-streaming path documents as "always relay a thinking block" — so the + // suppression contract has to be asserted with the value production sends. + const state = { ...createState(), requestedThinking: false }; const reasoning = openaiToClaudeResponse( { id: "chatcmpl-2d", diff --git a/tests/unit/webpack-create-require-warning.test.ts b/tests/unit/webpack-create-require-warning.test.ts index d6fba2e315..a47be0011c 100644 --- a/tests/unit/webpack-create-require-warning.test.ts +++ b/tests/unit/webpack-create-require-warning.test.ts @@ -80,6 +80,9 @@ async function compileRuntimeRequireModules(): Promise { // erroring "Can't resolve './obscura.ts'". "./obscura.ts", "./tlsFirstByteWatchdog.ts", + // machineToken.ts imports `./dataPaths` since #13909 (random per-install + // CLI token salt reads the data dir). Same isolated-compile reason. + "./dataPaths", ], externalsPresets: { node: true }, mode: "development", From 271ec25f12e4d59bc4c406f26d33666798299d25 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:37:03 -0300 Subject: [PATCH 03/33] chore(quality): adjust the train-8 accountFallback ceiling to the re-measured 2517 --- config/quality/file-size-baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 3c8b540f9c..d3691e7cde 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,5 @@ { - "_rebaseline_2026_09_18_merge_train_8_frozen_growth": "Owner-approved train rebaseline (2026-09-18, /merge-prs; precedent _rebaseline_2026_07_23_v3849_merge_train_15). Own growth of 32 merge-ready contributor PRs that each add irreducible call-site/plumbing lines to an already-frozen file, measured on the combined merge-train tip 04cf8095 (release tip green before boarding). Per-file (old->new, contributing PRs): src/app/(dashboard)/dashboard/combos/page.tsx 5080->5091 (#13951); src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx 2491->2493 (#13533); src/app/api/v1/models/catalog.ts 2117->2127 (#13994); src/lib/db/apiKeys.ts 1659->1671 (#12952, #13861); src/lib/tokenHealthCheck.ts 1221->1254 (#13444, #13874); src/shared/components/RequestLoggerDetail.tsx 1200->1210 (#13373); src/shared/components/RequestLoggerV2.tsx 1718->1748 (#13373); src/shared/middleware/chatBodyAdmission.ts 1200->1206 (#13823); src/sse/handlers/chat.ts 2541->2547 (combined growth); src/sse/services/auth.ts 3582->3592 (combined growth); open-sse/executors/antigravity.ts 1665->1717 (#13125, #13318, #13659); open-sse/executors/codex.ts 1553->1570 (#13708); open-sse/executors/cursor.ts 1847->1868 (#13125); open-sse/executors/deepseek-web.ts 1200->1224 (#13226); open-sse/executors/default.ts 1200->1205 (#11828); open-sse/handlers/imageGeneration.ts 3304->3334 (#12982); open-sse/services/accountFallback.ts 2507->2515 (#13008); open-sse/services/combo/executeTargetAttempt.ts 1228->1258 (#12235); open-sse/services/combo/roundRobinCombo.ts 1221->1261 (#12235); open-sse/services/rateLimitManager.ts 1200->1329 (#13895); open-sse/translator/response/openai-responses.ts 1466->1518 (#12841, #13956); open-sse/utils/cursorAgentProtobuf.ts 1547->1588 (#13125); open-sse/utils/stream.ts 3140->3239 (#12688, #12855); tests/integration/chat-pipeline.test.ts 1740->1756 (#12966); tests/unit/account-fallback-service.test.ts 2056->2072 (#13040); tests/unit/chatcore-translation-paths.test.ts 3449->3546 (#13856, #13972); tests/unit/token-refresh-service.test.ts 1407->1408 (combined growth); tests/unit/translator-openai-to-gemini.test.ts 1625->1809 (#13318, #13848). Files previously under the 1200 cap that crossed it are frozen at the measured size. Structural shrink of these god-files stays tracked in #3501; the ceilings never move up again outside a documented entry.", + "_rebaseline_2026_09_18_merge_train_8_frozen_growth": "Owner-approved train rebaseline (2026-09-18, /merge-prs; precedent _rebaseline_2026_07_23_v3849_merge_train_15). Own growth of 32 merge-ready contributor PRs that each add irreducible call-site/plumbing lines to an already-frozen file, measured on the combined merge-train tip 04cf8095 (release tip green before boarding). Per-file (old->new, contributing PRs): src/app/(dashboard)/dashboard/combos/page.tsx 5080->5091 (#13951); src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx 2491->2493 (#13533); src/app/api/v1/models/catalog.ts 2117->2127 (#13994); src/lib/db/apiKeys.ts 1659->1671 (#12952, #13861); src/lib/tokenHealthCheck.ts 1221->1254 (#13444, #13874); src/shared/components/RequestLoggerDetail.tsx 1200->1210 (#13373); src/shared/components/RequestLoggerV2.tsx 1718->1748 (#13373); src/shared/middleware/chatBodyAdmission.ts 1200->1206 (#13823); src/sse/handlers/chat.ts 2541->2547 (combined growth); src/sse/services/auth.ts 3582->3592 (combined growth); open-sse/executors/antigravity.ts 1665->1717 (#13125, #13318, #13659); open-sse/executors/codex.ts 1553->1570 (#13708); open-sse/executors/cursor.ts 1847->1868 (#13125); open-sse/executors/deepseek-web.ts 1200->1224 (#13226); open-sse/executors/default.ts 1200->1205 (#11828); open-sse/handlers/imageGeneration.ts 3304->3334 (#12982); open-sse/services/accountFallback.ts 2507->2515 (#13008); open-sse/services/combo/executeTargetAttempt.ts 1228->1258 (#12235); open-sse/services/combo/roundRobinCombo.ts 1221->1261 (#12235); open-sse/services/rateLimitManager.ts 1200->1329 (#13895); open-sse/translator/response/openai-responses.ts 1466->1518 (#12841, #13956); open-sse/utils/cursorAgentProtobuf.ts 1547->1588 (#13125); open-sse/utils/stream.ts 3140->3239 (#12688, #12855); tests/integration/chat-pipeline.test.ts 1740->1756 (#12966); tests/unit/account-fallback-service.test.ts 2056->2072 (#13040); tests/unit/chatcore-translation-paths.test.ts 3449->3546 (#13856, #13972); tests/unit/token-refresh-service.test.ts 1407->1408 (combined growth); tests/unit/translator-openai-to-gemini.test.ts 1625->1809 (#13318, #13848). Files previously under the 1200 cap that crossed it are frozen at the measured size. Structural shrink of these god-files stays tracked in #3501; the ceilings never move up again outside a documented entry. ADJUST (train 8d re-measure after #13548 ejection and #14101 landing): open-sse/services/accountFallback.ts 2515->2517 (#13008 +22, #13350 +7, #13984 +2, #13040 +2 on a tip at 2499).", "_rebaseline_2026_09_18_14065_codex_reasoning_whitelist": "Release-tip drift: open-sse/executors/codex.ts 1552->1553 (+1) from #14065 (fix(codex): whitelist reasoning object keys before the wire, #13643), merged 2026-09-18 without its own rebaseline — the PR->release fast-gates do not run check:file-size, so the tip went red for every train boarding afterwards. Absorbed at the release tip by the /merge-prs captain session (owner-approved train-rebaseline policy, 2026-09-18). Structural shrink tracked in #3501.", "_rebaseline_2026_09_18_restore_13643_after_clobber": "Restore of #13643 after e7999c477b clobbered the whitelist at the release tip (see #14062). open-sse/executors/codex.ts 1530->1552 (+22): keep the tip force-rule precedence and restore OpenRouter-style enabled:false plus the reasoning-object key whitelist before the wire. Covered by tests/unit/codex-reasoning-wire-whitelist.test.ts.", "_rebaseline_2026_09_03_12648_xkiro_provider": "PR #12648 (feat/provider-xkiro) own growth: src/shared/constants/providers/apikey/gateways.ts +18 lines on top of #12649 (the xkiro APIKEY_PROVIDERS_GATEWAYS catalog entry with hasFree/freeNote/authHint/apiHint documenting the 5M tokens/day free plan, plus the Prettier reflow of two pre-existing >100-col authHint lines (oneminai, freebuff) that lint-staged enforces on any touch of the file; additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #11786 seekai, #10987 logfare, #10531 freebuff). Covered by tests/unit/free-provider-xkiro.test.ts (4/4).", @@ -478,7 +478,7 @@ "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, - "open-sse/services/accountFallback.ts": 2515, + "open-sse/services/accountFallback.ts": 2517, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, "open-sse/services/combo.ts": 4080, "open-sse/services/combo/executeTargetAttempt.ts": 1258, From 975b29c2757723c956fd3c35d1186973252a92c2 Mon Sep 17 00:00:00 2001 From: Syed Raheemuddin Date: Sat, 19 Sep 2026 08:32:02 +0530 Subject: [PATCH 04/33] feat(core): improve observability for dual-auth fallback execution (#11828) * feat(core): improve observability for dual-auth fallback execution * refactor(executors): keep the clinepass auth decision in buildClinepassHeaders The clinepass case no longer re-decides OAuth vs BYOK off credentials.authType; it always delegates to buildClinepassHeaders(), which already keys the decision off credentials.accessToken, and only the debug log line branches. The OAuth test now uses the real credential shape ({ accessToken }) instead of an OAuth token stored in apiKey, and a parity test pins the executor output to buildClinepassHeaders() for both credential shapes so the two paths cannot drift apart. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/executors/default.ts | 9 +++++ .../cline-workos-auth-token-shape.test.ts | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 39c92cc5e4..f0a7f37ff7 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -579,6 +579,15 @@ export class DefaultExecutor extends BaseExecutor { headers["x-api-key"] = effectiveKey || credentials.accessToken; break; case "clinepass": // dual-auth (OAuth or BYOK) — see applyClineAuthHeaders() + // buildClinepassHeaders() (called below via isClinepass=true) is the single + // source of truth for the OAuth-vs-BYOK decision, keyed off + // credentials.accessToken — do not re-decide it here off credentials.authType, + // which can diverge from the real credential shape (#11828 review). + if (credentials?.accessToken) { + console.debug("[Auth] Using OAuth token for Cline/Kilo Code request."); + } else { + console.debug("[Auth] Using direct API key for Cline/Kilo Code request."); + } applyClineAuthHeaders(headers, credentials, effectiveKey, clientHeaders, true); break; case "cline": diff --git a/tests/unit/cline-workos-auth-token-shape.test.ts b/tests/unit/cline-workos-auth-token-shape.test.ts index 3982155b8f..fd76900cfa 100644 --- a/tests/unit/cline-workos-auth-token-shape.test.ts +++ b/tests/unit/cline-workos-auth-token-shape.test.ts @@ -138,3 +138,42 @@ test("DefaultExecutor labels internal health checks separately from user traffic applyClineProtocolHeaders(headers, { taskId: headers["X-Task-ID"] }); assert.equal(headers["X-CLIENT-TYPE"], "omniroute-internal-health-check"); }); + +test("DefaultExecutor handles dual-auth logging for clinepass provider", () => { + const executor = new DefaultExecutor("clinepass"); + + // API key auth mode + const apiKeyHeaders = executor.buildHeaders( + { apiKey: "sk-cline-123", authType: "apikey" }, + true, + {} + ); + assert.equal(apiKeyHeaders["Authorization"], "Bearer sk-cline-123"); + + // OAuth token auth mode — real OAuth credential shape (accessToken, not apiKey; + // see #11828 review) so this exercises the effectiveKey || credentials?.accessToken + // fallback that actually runs in production. + const oauthHeaders = executor.buildHeaders( + { accessToken: "workos_tok_456", authType: "oauth" }, + true, + {} + ); + assert.equal(oauthHeaders["Authorization"], "Bearer workos:workos_tok_456"); +}); + +test("DefaultExecutor clinepass authType branch matches buildClinepassHeaders() directly (parity)", () => { + const executor = new DefaultExecutor("clinepass"); + + const apiKeyCredentials = { apiKey: "sk-cline-789", authType: "apikey" }; + const oauthCredentials = { accessToken: "workos_tok_789", authType: "oauth" }; + + for (const credentials of [apiKeyCredentials, oauthCredentials]) { + const viaExecutor = executor.buildHeaders(credentials, true, {}); + const viaDirectCall = buildClinepassHeaders(credentials, credentials.apiKey); + assert.equal( + viaExecutor["Authorization"], + viaDirectCall["Authorization"], + `Authorization mismatch for ${JSON.stringify(credentials)}` + ); + } +}); From 0f5f83c5ed3a261c350f0ba5e6c4adb09efdc9fe Mon Sep 17 00:00:00 2001 From: Abhishek Sharma Date: Fri, 18 Sep 2026 20:02:10 -0700 Subject: [PATCH 05/33] fix(resilience): clear the combo LKGP pin only when it names the failed target (#12235) * fix(resilience): clear the combo LKGP pin only when it names the failed target Re-applied onto current release/v3.8.51. The branch was 217 commits behind and dispatchWithCooldownRetry / handleRoundRobinCombo have since moved out of combo.ts, so this is a re-application, not a rebase: the definition is still in combo.ts, and the 14 call sites now live in combo/executeTargetAttempt.ts (4), combo/executeTargetGates.ts (5) and combo/roundRobinCombo.ts (5). clearStaleLKGP is dependency-injected via attemptLoopTypes.ts, so that signature takes the new parameter too. Unchanged in substance. The target-scoped pin is still always cleared; the combo-level pin is cleared only when it actually names the failed target's provider, because it records whichever provider last *succeeded* and that need not be the one failing now. Under `auto` the pin is a scoring input rather than a hoist, so clearing unconditionally discarded a preference for a healthy provider every time an unrelated target was skipped. Omitting `failed` keeps the old behaviour for callers with no target in scope. 4 regression tests pass, including "a pin naming a healthy provider survives another target being skipped" mutation: clear the combo pin unconditionally (the pre-fix behaviour) -> only that test fails, 3 pass 248 tests pass across tests/unit/lkgp*, tests/unit/combo/* eslint clean on all five changed files (exit 0, no suppressions flag) * docs(changelog): add fragment for the LKGP pin scope fix Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: abhisheksharma2411 --- changelog.d/fixes/12235-lkgp-clear-scope.md | 1 + open-sse/services/combo/attemptLoopTypes.ts | 5 +- .../services/combo/executeTargetAttempt.ts | 40 +++++++++-- open-sse/services/combo/executeTargetGates.ts | 50 +++++++++++-- open-sse/services/combo/roundRobinCombo.ts | 50 +++++++++++-- open-sse/services/combo/staleLkgpClear.ts | 62 ++++++++++++---- .../lkgp-stale-pin-exhaustion-11911.test.ts | 71 ++++++++++++++++++- 7 files changed, 250 insertions(+), 29 deletions(-) create mode 100644 changelog.d/fixes/12235-lkgp-clear-scope.md diff --git a/changelog.d/fixes/12235-lkgp-clear-scope.md b/changelog.d/fixes/12235-lkgp-clear-scope.md new file mode 100644 index 0000000000..60c8d17f42 --- /dev/null +++ b/changelog.d/fixes/12235-lkgp-clear-scope.md @@ -0,0 +1 @@ +- fix(resilience): only clear the combo-level LKGP pin when it names the target that actually failed, so an unrelated target skip under `auto`/`round-robin` no longer discards a valid pin for a healthy provider (#12235) diff --git a/open-sse/services/combo/attemptLoopTypes.ts b/open-sse/services/combo/attemptLoopTypes.ts index 184e357c99..c60fa50c5e 100644 --- a/open-sse/services/combo/attemptLoopTypes.ts +++ b/open-sse/services/combo/attemptLoopTypes.ts @@ -92,7 +92,10 @@ export type AttemptLoopDeps = { executionKey: string | undefined, comboId: string | undefined, log: ComboLogger, - tag: string + tag: string, + /** Test seam, unused on the routing path; see staleLkgpClear.ts. */ + clearLKGP?: ((comboName: string, modelKey: string) => Promise) | undefined, + failed?: { provider?: string | null; connectionId?: string | null } | null ) => void; /** * Closed-over setup values from handleComboChatInner. Optional so Task 2 diff --git a/open-sse/services/combo/executeTargetAttempt.ts b/open-sse/services/combo/executeTargetAttempt.ts index debe92d2d7..2c0a261421 100644 --- a/open-sse/services/combo/executeTargetAttempt.ts +++ b/open-sse/services/combo/executeTargetAttempt.ts @@ -126,7 +126,15 @@ export async function executeTargetAttempt(opts: { const stopProtectedPriorityTarget = (message: string, cause?: ProtectedPriorityStopCause) => { state.observeFailure(false, target.executionKey); - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); return protectedPriorityTarget ? { ok: false as const, response: errorResponse(protectedPriorityStopStatus(cause), message) } : null; @@ -906,7 +914,15 @@ export async function executeTargetAttempt(opts: { state.exhaustedConnections.has(`${provider}:${targetWithConnection.connectionId}`) || (provider && state.exhaustedProviders.has(provider)) ) { - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); } // #2101: Prevent infinite fallback loops with 400 Bad Request errors that are genuinely @@ -948,7 +964,15 @@ export async function executeTargetAttempt(opts: { state.lastStatus = result.status; if (i > 0) state.fallbackCount++; deps.log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`); - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); // #4279: surface the 400 via the {ok,response} contract so the OUTER // target loop resolves the combo and stops. A bare `break` here only // exits the inner retry loop; executeTarget then returns null, which @@ -1137,7 +1161,15 @@ export async function executeTargetAttempt(opts: { // *next* separate request. Circuit breaker / model lockout deliberately // don't react to request-scoped failure classes (see scopedFailure below), // so nothing else clears this stale pin. - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); state.recordedAttempts++; state.lastError = errorText || String(result.status); state.comboErrors.push({ diff --git a/open-sse/services/combo/executeTargetGates.ts b/open-sse/services/combo/executeTargetGates.ts index 4bf24601bb..ccacd0387f 100644 --- a/open-sse/services/combo/executeTargetGates.ts +++ b/open-sse/services/combo/executeTargetGates.ts @@ -62,7 +62,15 @@ export async function evaluateExecuteTargetGates(opts: { const stopProtectedPriorityTarget = (message: string, cause?: ProtectedPriorityStopCause) => { state.observeFailure(false, target.executionKey); - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); return protectedPriorityTarget ? { ok: false as const, response: errorResponse(protectedPriorityStopStatus(cause), message) } : null; @@ -156,7 +164,15 @@ export async function evaluateExecuteTargetGates(opts: { decision: "skipped_before_dispatch", reason: "persisted_cooldown", }); - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); bumpFallback(); return { kind: "skip", result: null }; } @@ -212,7 +228,15 @@ export async function evaluateExecuteTargetGates(opts: { "COMBO", `Skipping ${modelStr} — quota exhaustion cutoff (${quotaCutoff.reason || "quota_exhausted"})` ); - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); recordComboDecision(deps.traceInvocationId, { step: target.executionKey, target: modelStr, @@ -249,7 +273,15 @@ export async function evaluateExecuteTargetGates(opts: { "COMBO", `Skipping ${modelStr} — quota budget ${quotaDecision.reason} (remaining ${quotaDecision.tokensRemaining ?? 0}, cost ${quotaDecision.estimatedCost ?? 0})` ); - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); bumpFallback(); return { kind: "skip", result: null }; } @@ -262,7 +294,15 @@ export async function evaluateExecuteTargetGates(opts: { "COMBO", `Skipping ${modelStr} — no credentials available or model excluded` ); - deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); + deps.clearStaleLKGP( + deps.combo.name, + target.executionKey, + deps.combo.id, + deps.log, + "COMBO", + undefined, + target + ); recordComboDecision(deps.traceInvocationId, { step: target.executionKey, target: modelStr, diff --git a/open-sse/services/combo/roundRobinCombo.ts b/open-sse/services/combo/roundRobinCombo.ts index 3b728188a9..6f9219fb28 100644 --- a/open-sse/services/combo/roundRobinCombo.ts +++ b/open-sse/services/combo/roundRobinCombo.ts @@ -503,7 +503,15 @@ export async function handleRoundRobinCombo({ "COMBO-RR", `Skipping ${modelStr} — no credentials available or model excluded` ); - clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR"); + clearStaleLKGP( + combo.name, + target.executionKey, + combo.id, + log, + "COMBO-RR", + undefined, + target + ); if (offset > 0) fallbackCount++; continue; } @@ -519,7 +527,15 @@ export async function handleRoundRobinCombo({ ) ) { log.info("COMBO-RR", `Skipping ${modelStr} — provider ${provider} in global cooldown`); - clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR"); + clearStaleLKGP( + combo.name, + target.executionKey, + combo.id, + log, + "COMBO-RR", + undefined, + target + ); if (offset > 0) fallbackCount++; continue; } @@ -532,7 +548,15 @@ export async function handleRoundRobinCombo({ ); if (exhaustedSkip) { log.info("COMBO-RR", exhaustedSkip); - clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR"); + clearStaleLKGP( + combo.name, + target.executionKey, + combo.id, + log, + "COMBO-RR", + undefined, + target + ); if (offset > 0) fallbackCount++; continue; } @@ -983,7 +1007,15 @@ export async function handleRoundRobinCombo({ exhaustedConnections.has(`${provider}:${targetWithConnection.connectionId}`) || (provider && exhaustedProviders.has(provider)) ) { - clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR"); + clearStaleLKGP( + combo.name, + target.executionKey, + combo.id, + log, + "COMBO-RR", + undefined, + target + ); } // Transient errors → mark in semaphore so round-robin stops stampeding this target. @@ -1041,7 +1073,15 @@ export async function handleRoundRobinCombo({ // LKGP (#919) mirror of handleComboChat's failure-path clear above — see // that comment for why this must happen (nothing else clears a pin left // by a request-scoped failure class like a stream-readiness timeout). - clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR"); + clearStaleLKGP( + combo.name, + target.executionKey, + combo.id, + log, + "COMBO-RR", + undefined, + target + ); recordedAttempts++; lastError = errorText || String(result.status); lastStatus = result.status; diff --git a/open-sse/services/combo/staleLkgpClear.ts b/open-sse/services/combo/staleLkgpClear.ts index 5d9824b4a9..cfdcf5c97d 100644 --- a/open-sse/services/combo/staleLkgpClear.ts +++ b/open-sse/services/combo/staleLkgpClear.ts @@ -1,6 +1,7 @@ /** * Clear persisted LKGP pins when a combo target fails or is skipped for exhaustion, - * cooldown or unavailability (#11911 #919). + * cooldown or unavailability (#11911 #919), scoped to the pin that names the + * failed target when the caller knows it (#12235). * * Non-blocking by design: the fallback loop never waits on these SQLite writes. A * failed clear is not silent — it logs a warning carrying the combo and the @@ -13,15 +14,37 @@ type WarnLogger = { warn?: (tag: string, msg: string, data?: unknown) => void } | null; type ClearLkgp = (comboName: string, modelKey: string) => Promise; +/** The target whose failure triggered the clear, when the caller has one in scope. */ +type FailedTarget = { provider?: string | null; connectionId?: string | null } | null; + async function clearPins( comboName: string, executionKey: string | null | undefined, comboId: string | null | undefined, - clearLKGP: ClearLkgp | undefined + clearLKGP: ClearLkgp | undefined, + failed: FailedTarget ): Promise { const clear = clearLKGP ?? (await import("@/lib/db/settings")).clearLKGP; - const keys = [comboId || comboName, ...(executionKey ? [executionKey] : [])]; - await Promise.all(keys.map((key) => clear(comboName, key))); + const comboKey = comboId || comboName; + + // The target-scoped pin is unambiguously about the target that just failed. + const pending: Promise[] = executionKey ? [clear(comboName, executionKey)] : []; + + if (!failed?.provider) { + // No target in scope: previous unconditional behaviour. + pending.push(clear(comboName, comboKey)); + } else { + const { getLKGP } = await import("@/lib/db/settings"); + const pin = await getLKGP(comboName, comboKey); + // Same provider, and — when both sides carry one — the same connection. + // A sibling connection failing does not make the pinned one stale. + const namesFailedTarget = + pin?.provider === failed.provider && + (!pin?.connectionId || !failed.connectionId || pin.connectionId === failed.connectionId); + if (namesFailedTarget) pending.push(clear(comboName, comboKey)); + } + + await Promise.all(pending); } export function clearStaleLKGP( @@ -31,14 +54,27 @@ export function clearStaleLKGP( log?: WarnLogger, tag: string = "COMBO", /** Test seam; the routing path always resolves clearLKGP from @/lib/db/settings. */ - clearLKGP?: ClearLkgp + clearLKGP?: ClearLkgp, + /** + * The failed target, when the caller has one. Scopes the COMBO-LEVEL pin so it + * is cleared only when it actually names that target's provider: the pin + * records whichever provider last SUCCEEDED, which need not be the one failing + * now. Under `auto` the pin is a scoring input rather than a hoist + * (`resolveAutoStrategy` reads it into `lastKnownGoodProvider`), so the pinned + * provider is not necessarily tried first, and clearing unconditionally + * discarded a preference for a healthy provider every time an unrelated target + * was skipped. Omitted keeps the previous unconditional behaviour (#12235). + */ + failed?: FailedTarget ): Promise { - return clearPins(comboName, executionKey, comboId, clearLKGP).catch((err: unknown) => { - log?.warn?.(tag, "Failed to clear Last Known Good Provider. This is non-fatal.", { - combo: comboName, - comboId: comboId ?? null, - executionKey: executionKey ?? null, - err, - }); - }); + return clearPins(comboName, executionKey, comboId, clearLKGP, failed ?? null).catch( + (err: unknown) => { + log?.warn?.(tag, "Failed to clear Last Known Good Provider. This is non-fatal.", { + combo: comboName, + comboId: comboId ?? null, + executionKey: executionKey ?? null, + err, + }); + } + ); } diff --git a/tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts b/tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts index 25a2e1fcb5..9e40e94e4e 100644 --- a/tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts +++ b/tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts @@ -16,11 +16,13 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-lkgp-stal process.env.DATA_DIR = TEST_DATA_DIR; const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { clearStaleLKGP } = await import("../../open-sse/services/combo.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const core = await import("../../src/lib/db/core.ts"); const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); -const { resetAll: resetAllSemaphores } = await import("../../open-sse/services/rateLimitSemaphore.ts"); +const { resetAll: resetAllSemaphores } = + await import("../../open-sse/services/rateLimitSemaphore.ts"); after(() => { core.resetDbInstance(); @@ -163,3 +165,70 @@ test("#11911: handleComboChat (round-robin) clears LKGP pin when target is skipp const pinAfter = await settingsDb.getLKGP(comboName, comboName); assert.equal(pinAfter, null, "stale LKGP pin in round-robin must be cleared on unavailable skip"); }); + +test("#11911 follow-up: a pin naming a healthy provider survives another target being skipped", async () => { + // The #11911 fix clears the combo-level pin from 12 call sites, none of which look at + // which provider the pin actually names. Under `auto` the pin is a scoring input rather + // than a hoist (resolveAutoStrategy reads it into lastKnownGoodProvider), so the pinned + // provider is not necessarily tried first — and skipping an unrelated target destroys a + // preference for a provider that never failed. + const comboName = "auto-cross-provider-pin"; + await settingsDb.setLKGP(comboName, comboName, "felo", undefined); + + const result = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: { + name: comboName, + strategy: "auto", + models: ["opencode/deepseek-free", "felo/felo-flash"], + config: { maxRetries: 0 }, + }, + handleSingleModel: async (_body, targetModel) => + targetModel.includes("felo") + ? jsonResponse(200, { ok: true }) + : jsonResponse(502, { error: { message: "opencode down" } }), + isModelAvailable: async (modelStr) => !modelStr.includes("opencode"), + log: createLog(), + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.status, 200); + const pinAfter = await settingsDb.getLKGP(comboName, comboName); + assert.deepEqual( + pinAfter, + { provider: "felo" }, + "skipping opencode must not clear a pin naming healthy felo" + ); +}); + +test("#12235: a sibling connection failing does not clear a pin naming the same provider", async () => { + // The combo pin carries a connectionId as well as a provider. Two connections + // of the SAME provider are independent targets: one going down says nothing + // about the other, so matching on provider alone would throw away a pin for a + // connection that never failed. + const comboName = "sibling-connection-pin"; + await settingsDb.setLKGP(comboName, comboName, "felo", "conn-A"); + + await clearStaleLKGP(comboName, null, comboName, null, "COMBO", undefined, { + provider: "felo", + connectionId: "conn-B", + }); + assert.deepEqual( + await settingsDb.getLKGP(comboName, comboName), + { provider: "felo", connectionId: "conn-A" }, + "conn-B failing must not clear a pin naming conn-A" + ); + + // ...and the pin IS cleared when the failure names that same connection. + await clearStaleLKGP(comboName, null, comboName, null, "COMBO", undefined, { + provider: "felo", + connectionId: "conn-A", + }); + assert.equal( + await settingsDb.getLKGP(comboName, comboName), + null, + "conn-A failing must clear the pin that names it" + ); +}); From c193595db6e560c395fdf9cf74ebd7d7f0bb9c5b Mon Sep 17 00:00:00 2001 From: tom <7740810+thomasmaerz@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:02:22 -0700 Subject: [PATCH 06/33] fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present (#12665) (#12688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present (#12665) OpenRouter thinking models return both a "reasoning" string and a "reasoning_details[].text" array for the same thinking trace. OmniRoute's reasoning promotion gated on "is any readable value present" (which includes the "reasoning" alias), so reasoning_content was never populated and clients like opencode that only read reasoning_content lost all thinking traces. Encrypted-only reasoning_details items are left intact (not flattened). Fixes all three promotion gates: - non-streaming: copyOpenAICompatibleReasoningFields now mirrors reasoning_details[].text into reasoning_content unless reasoning_content itself is present - streaming mirror block: same gate fix on getReadableReasoningValue - streaming passthrough: force re-serialization when sanitize added a reasoning_content the upstream delta did not carry (needsReserialization was false because hasUnsupportedReasoningSignal requires !readable) Tests: non-streaming + streaming unit regressions and an integration E2E that drives the full handleChat path against a mock OpenRouter provider. Also closes the same latent gate in the JSON-to-SSE rehydrator (jsonToSse.ts buildReasoningDelta): a populated reasoning string used to short-circuit the unsupported-alias mirror, so reasoning_details[].text was dropped when synthesizing an SSE stream from a non-streaming JSON body. Adds a #12665 regression test for that path, fixes an over-indented brace in stream.ts (lint), and restores the missing trailing newline in the E2E. * docs(changelog): add fragment for #12688 — fix(open-sse): promote reasoning_details text to reasoning_content even when reasoning present * fix(tests): replace any with typed casts in #12665 regressions to satisfy no-explicit-any gate --- .../12688-open-sse-reasoning-promotion.md | 1 + open-sse/utils/jsonToSse.ts | 11 +- open-sse/utils/reasoningFields.ts | 9 +- open-sse/utils/stream.ts | 29 ++- .../openrouter-reasoning-details-e2e.test.ts | 233 ++++++++++++++++++ tests/unit/json-to-sse-3089.test.ts | 31 +++ tests/unit/response-sanitizer.test.ts | 106 ++++++++ 7 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/12688-open-sse-reasoning-promotion.md create mode 100644 tests/integration/openrouter-reasoning-details-e2e.test.ts diff --git a/changelog.d/fixes/12688-open-sse-reasoning-promotion.md b/changelog.d/fixes/12688-open-sse-reasoning-promotion.md new file mode 100644 index 0000000000..15b1952de3 --- /dev/null +++ b/changelog.d/fixes/12688-open-sse-reasoning-promotion.md @@ -0,0 +1 @@ +- **fix(open-sse):** `reasoning_details[].text` is now promoted to `reasoning_content` even when `reasoning` is also present, so OpenRouter thinking models (GLM-5.3-Flash, DeepSeek-V4-Flash, Kimi K3) no longer lose their thinking traces in clients that only read `reasoning_content` ([#12688](https://github.com/diegosouzapw/OmniRoute/pull/12688) — thanks @thomasmaerz) diff --git a/open-sse/utils/jsonToSse.ts b/open-sse/utils/jsonToSse.ts index aa7f0cfa78..e3f71f4669 100644 --- a/open-sse/utils/jsonToSse.ts +++ b/open-sse/utils/jsonToSse.ts @@ -55,7 +55,16 @@ function buildReasoningDelta(message: JsonRecord): JsonRecord | null { delta.reasoning_details = message.reasoning_details; } - if (!addReadableReasoning(message, delta)) { + // Always emit the readable field (reasoning_content, else the reasoning + // alias) WITHOUT short-circuiting the unsupported-alias mirror below. + addReadableReasoning(message, delta); + + // Mirror unsupported reasoning aliases (reasoning_text / thinking / thought / + // reasoning_details[].text) into reasoning_content unless reasoning_content + // itself is present — same gate as copyOpenAICompatibleReasoningFields + // (#12665). A populated `reasoning` string must NOT skip this: OpenRouter + // thinking models send BOTH `reasoning` and `reasoning_details[].text`. + if (!nonEmptyString(message.reasoning_content)) { addUnsupportedReasoning(message, delta); } diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts index 75b7cbe537..d502504ca4 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -111,7 +111,14 @@ export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target: if (source.thinking !== undefined) target.thinking = source.thinking; if (source.thought !== undefined) target.thought = source.thought; if (Array.isArray(source.reasoning_details)) target.reasoning_details = source.reasoning_details; - if (!getReadableReasoningValue(target)) { + // Mirror unsupported reasoning aliases (reasoning_text / thinking / thought / + // reasoning_details[].text) into the client-readable reasoning_content field. + // Only the presence of an existing reasoning_content blocks this — NOT the + // `reasoning` string. OpenRouter thinking models return BOTH `reasoning` and + // `reasoning_details[].text`; previously `reasoning` alone short-circuited the + // promotion, so reasoning_content was never set and thinking traces were lost + // for clients (e.g. opencode) that only read reasoning_content. + if (!nonEmptyString(target.reasoning_content)) { const mirrored = getUnsupportedReasoningValue(source); if (mirrored) target.reasoning_content = mirrored; } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index f990c85822..bfe9fab221 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1906,6 +1906,9 @@ export function createSSEStream(options: StreamOptions = {}) { parsed?.id != null && typeof parsed.id !== "string"; const rawDelta = parsed.choices?.[0]?.delta; const hadReasoningAlias = hasUnsupportedReasoningSignal(rawDelta); + const hadUpstreamReasoningContent = + typeof rawDelta?.reasoning_content === "string" && + rawDelta.reasoning_content.length > 0; if (!projectedFailure) { parsed = sanitizeStreamingChunk(parsed); @@ -1967,12 +1970,22 @@ export function createSSEStream(options: StreamOptions = {}) { } // Track whether we need to re-serialize (separate from injectedUsage - // to avoid blocking subsequent finish_reason / usage mutations) + // to avoid blocking subsequent finish_reason / usage mutations). + // sanitizeStreamingChunk above can MIRROR reasoning_details[].text + // into reasoning_content when the upstream only sent `reasoning` + // (OpenRouter thinking models, #12665). hadReasoningAlias covers + // reasoning_text/thinking/thought aliases, but a populated `reasoning` + // string makes hasUnsupportedReasoningSignal return false — so we also + // force a re-serialize when sanitize added a reasoning_content that the + // upstream delta did not already carry. const needsReserialization = splitMixedReasoningContent || thinkParsed || hadReasoningAlias || - (delta?.content === "" && delta?.reasoning_content); + (delta?.content === "" && delta?.reasoning_content) || + (!hadUpstreamReasoningContent && + typeof delta?.reasoning_content === "string" && + delta.reasoning_content.length > 0); // T18: Track if we saw tool calls & accumulate for call log if (delta?.tool_calls && delta.tool_calls.length > 0) { @@ -2244,7 +2257,17 @@ export function createSSEStream(options: StreamOptions = {}) { ); } // Mirror only client-unsupported reasoning aliases into `reasoning_content`. - if (!openAiReasoning) { + // Gate on reasoning_content being ABSENT (not on getReadableReasoningValue + // which also includes the `reasoning` string): OpenRouter thinking models + // return BOTH `reasoning` and `reasoning_details[].text`, and `reasoning` + // alone previously skipped the mirror, dropping thinking traces for clients + // that only read `reasoning_content` (#12665). + const openAiReasoningContent = + typeof openAiDelta?.reasoning_content === "string" && + openAiDelta.reasoning_content.length > 0 + ? openAiDelta.reasoning_content + : ""; + if (!openAiReasoningContent) { const delta = openAiDelta; const r = getUnsupportedReasoningValue(delta); if (typeof r === "string" && r.length > 0) { diff --git a/tests/integration/openrouter-reasoning-details-e2e.test.ts b/tests/integration/openrouter-reasoning-details-e2e.test.ts new file mode 100644 index 0000000000..a127ef660f --- /dev/null +++ b/tests/integration/openrouter-reasoning-details-e2e.test.ts @@ -0,0 +1,233 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-openrouter-reasoning-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-openrouter-reasoning-secret"; +process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { handleChat } = await import("../../src/sse/handlers/chat.ts"); +const { initTranslators } = await import("../../open-sse/translator/index.ts"); +const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); +const { BaseExecutor } = await import("../../open-sse/executors/base.ts"); +const { resetAllCircuitBreakers } = + await import("../../src/shared/utils/circuitBreaker.ts"); + +const originalFetch = globalThis.fetch; +const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs; + +type FetchCall = { + url: string; + method?: string; + headers: Record; + body: Record | null; +}; + +function toPlainHeaders(headers: HeadersInit | undefined | null) { + if (!headers) return {}; + if (headers instanceof Headers) return Object.fromEntries(headers.entries()); + if (Array.isArray(headers)) return Object.fromEntries(headers); + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)]) + ); +} + +function buildRequest(url: string, overrides: RequestInit = {}) { + const headers = new Headers({ + "content-type": "application/json", + ...((overrides.headers as Record) || {}), + }); + return new Request(url, { ...overrides, headers }); +} + +/** + * OpenRouter-shaped non-streaming completion: the provider returns BOTH a + * `reasoning` string AND a `reasoning_details[]` array carrying the same + * thinking text. This is exactly what DeepSeek V4 / GLM 5.3 / Kimi K3 return + * through OpenRouter (#12665). + */ +function buildOpenRouterStreamingSse({ + thinking = "Hmm, let me think this through", + content = "Visible answer", +} = {}) { + const chunk = (delta: Record) => + `data: ${JSON.stringify({ + id: "chatcmpl_openrouter_reasoning_stream", + object: "chat.completion.chunk", + created: 1783636289, + model: "deepseek/deepseek-v4-flash", + choices: [ + { index: 0, delta, finish_reason: null, logprobs: null }, + ], + })}\n\n`; + return ( + chunk({ reasoning: thinking, reasoning_details: [{ type: "reasoning.text", text: thinking }] }) + + chunk({ content }) + + chunk({}) + + chunk({}) + + "data: [DONE]\n\n" + ); +} + +function buildOpenRouterResponse({ + content = "Visible answer", + thinking = "Hmm, let me think this through", +} = {}) { + return new Response( + JSON.stringify({ + id: "chatcmpl_openrouter_reasoning", + object: "chat.completion", + created: 1783636289, + model: "deepseek/deepseek-v4-flash", + choices: [ + { + index: 0, + message: { + role: "assistant", + content, + reasoning: thinking, + reasoning_details: [{ type: "reasoning.text", text: thinking }], + }, + finish_reason: "stop", + logprobs: null, + }, + ], + usage: { prompt_tokens: 20, completion_tokens: 30, total_tokens: 50 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} + +test.before(async () => { + await initTranslators(); +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; + BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs; + BaseExecutor.freeze?.(); + clearInflight(); + resetAllCircuitBreakers(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test("openrouter provider: reasoning_details[].text is mirrored to reasoning_content even when reasoning string is present", async () => { + await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "openrouter-reasoning-e2e", + apiKey: "sk-mock-openrouter-key", + isActive: true, + testStatus: "active", + providerSpecificData: { baseUrl: "http://mock-openrouter.invalid/v1" }, + }); + + const fetchCalls: FetchCall[] = []; + + globalThis.fetch = async (input, init: RequestInit = {}) => { + fetchCalls.push({ + url: String(input), + method: init.method || "GET", + headers: toPlainHeaders(init.headers), + body: init.body ? JSON.parse(String(init.body)) : null, + }); + return buildOpenRouterResponse(); + }; + + const response = await handleChat( + buildRequest("http://localhost/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "openrouter/auto", + stream: false, + messages: [{ role: "user", content: "Think through this carefully." }], + }), + }) + ); + + const json = (await response.json()) as { + choices: Array<{ + message: { + content?: unknown; + reasoning?: unknown; + reasoning_content?: unknown; + reasoning_details?: unknown; + }; + }>; + }; + + assert.equal(response.status, 200, JSON.stringify(json)); + assert.equal(fetchCalls.length, 1, "should make exactly one upstream call"); + assert.match(fetchCalls[0].url, /mock-openrouter\.invalid/, fetchCalls[0].url); + + const message = json.choices[0].message; + assert.equal(message.content, "Visible answer"); + // The client-readable field must be populated from reasoning_details[].text + // even though the `reasoning` alias is also present (#12665). + assert.equal(message.reasoning_content, "Hmm, let me think this through"); + assert.equal(message.reasoning, "Hmm, let me think this through"); + assert.deepEqual(message.reasoning_details, [ + { type: "reasoning.text", text: "Hmm, let me think this through" }, + ]); +}); + +test("openrouter provider: streaming deltas carry reasoning_content from reasoning_details[].text", async () => { + await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "openrouter-reasoning-stream-e2e", + apiKey: "sk-mock-openrouter-key", + isActive: true, + testStatus: "active", + providerSpecificData: { baseUrl: "http://mock-openrouter.invalid/v1" }, + }); + + let fetched = false; + globalThis.fetch = async (input, init: RequestInit = {}) => { + void input; + void init; + fetched = true; + return new Response(buildOpenRouterStreamingSse(), { + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + }); + }; + + const response = await handleChat( + buildRequest("http://localhost/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "openrouter/auto", + stream: true, + messages: [{ role: "user", content: "Think through this carefully." }], + }), + }) + ); + + const raw = await response.text(); + assert.equal(response.status, 200, raw); + assert.equal(fetched, true, "should make exactly one upstream call"); + + const chunks = raw.split("\n\n").filter((line) => line.startsWith("data: ")); + const payloads = chunks + .map((line) => line.replace(/^data: /, "")) + .filter((json) => json !== "[DONE]") + .map((json) => JSON.parse(json) as { + choices?: Array<{ delta?: Record }>; + }); + + const reasoningContentDeltas = payloads + .map((payload) => payload.choices?.[0]?.delta?.reasoning_content) + .filter((content): content is string => Boolean(content)); + + assert.equal(reasoningContentDeltas.length, 1, JSON.stringify(payloads)); + assert.equal(reasoningContentDeltas[0], "Hmm, let me think this through"); +}); diff --git a/tests/unit/json-to-sse-3089.test.ts b/tests/unit/json-to-sse-3089.test.ts index ac1c9e47e7..b4757f2786 100644 --- a/tests/unit/json-to-sse-3089.test.ts +++ b/tests/unit/json-to-sse-3089.test.ts @@ -131,6 +131,37 @@ describe("synthesizeOpenAiSseFromJson (#3089)", () => { ); }); + test("#12665: reasoning present does NOT suppress reasoning_details text in reasoning_content", () => { + const sse = synthesizeOpenAiSseFromJson( + JSON.stringify({ + choices: [ + { + message: { + role: "assistant", + reasoning: "client-readable reasoning string", + reasoning_details: [ + { type: "reasoning.text", text: "details thinking trace" }, + ], + content: "final text", + }, + }, + ], + }) + ); + const deltas = parseDataChunks(sse) + .filter((c) => c !== "[DONE]") + .map((c) => JSON.parse(c).choices[0].delta); + + // reasoning alias is preserved AND reasoning_content is populated from + // reasoning_details[].text (previously the alias short-circuited the mirror). + const rc = deltas.find((d) => d.reasoning_content !== undefined)?.reasoning_content; + assert.equal(rc, "details thinking trace"); + assert.equal( + deltas.find((d) => d.reasoning !== undefined)?.reasoning, + "client-readable reasoning string" + ); + }); + test("forwards tool_calls in the delta", () => { const sse = synthesizeOpenAiSseFromJson( JSON.stringify({ diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 925b9c3795..b790fdece3 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -285,6 +285,79 @@ test("sanitizeOpenAIResponse preserves OpenRouter native reasoning and signature ); }); +test("sanitizeOpenAIResponse promotes reasoning_details text to reasoning_content even when reasoning is also present", () => { + // OpenRouter returns BOTH a `reasoning` string AND a `reasoning_details[]` + // array with the same thinking text for DeepSeek V4 / GLM 5.3 / Kimi K3. + // Clients (opencode) only read reasoning_content, so the details text must be + // mirrored into reasoning_content regardless of the `reasoning` alias being + // present (#12665). + const sanitized = sanitizeOpenAIResponse({ + model: "openrouter/deepseek/deepseek-v4-flash", + choices: [ + { + message: { + role: "assistant", + content: "Visible answer", + reasoning: "Hmm, let me think this through", + reasoning_details: [ + { type: "reasoning.text", text: "Hmm, let me think this through" }, + ], + }, + }, + ], + }); + + const message = ( + sanitized as { + choices: Array<{ + message: { + reasoning?: unknown; + reasoning_content?: unknown; + reasoning_details?: unknown; + }; + }>; + } + ).choices[0].message; + assert.equal(message.reasoning, "Hmm, let me think this through"); + assert.equal(message.reasoning_content, "Hmm, let me think this through"); + assert.deepEqual(message.reasoning_details, [ + { type: "reasoning.text", text: "Hmm, let me think this through" }, + ]); +}); + +test("sanitizeOpenAIResponse does not flatten signature-only reasoning_details into reasoning_content", () => { + // Regression guard for the flip side: non-text details entries (encrypted + // signatures) must NOT be coerced into reasoning_content text (#12665). + const sanitized = sanitizeOpenAIResponse({ + model: "openrouter/moonshotai/kimi-k3", + choices: [ + { + message: { + role: "assistant", + content: "Visible answer", + reasoning: "native reasoning", + reasoning_details: [{ type: "reasoning.encrypted", data: "sig" }], + }, + }, + ], + }); + + const message = ( + sanitized as { + choices: Array<{ + message: { + reasoning?: unknown; + reasoning_content?: unknown; + reasoning_details?: unknown; + }; + }>; + } + ).choices[0].message; + assert.equal(message.reasoning_content, undefined); + assert.equal(message.reasoning, "native reasoning"); + assert.deepEqual(message.reasoning_details, [{ type: "reasoning.encrypted", data: "sig" }]); +}); + test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => { const sanitized = sanitizeOpenAIResponse({ model: "openrouter/model", @@ -533,6 +606,39 @@ test("sanitizeStreamingChunk preserves client-readable reasoning deltas", () => assert.equal((sanitized as any).choices[0].delta.reasoning_content, undefined); }); +test("sanitizeStreamingChunk promotes reasoning_details text when reasoning is also present in the delta", () => { + // Streaming parity for #12665: OpenRouter streams reasoning_details[].text + // chunks alongside a `reasoning` string; reasoning_content must still be + // populated for the client. + const sanitized = sanitizeStreamingChunk({ + choices: [ + { + delta: { + reasoning: "thinking chunk", + reasoning_details: [{ type: "reasoning.text", text: "thinking chunk" }], + }, + }, + ], + }); + + const delta = ( + sanitized as { + choices: Array<{ + delta: { + reasoning?: unknown; + reasoning_content?: unknown; + reasoning_details?: unknown; + }; + }>; + } + ).choices[0].delta; + assert.equal(delta.reasoning, "thinking chunk"); + assert.equal(delta.reasoning_content, "thinking chunk"); + assert.deepEqual(delta.reasoning_details, [ + { type: "reasoning.text", text: "thinking chunk" }, + ]); +}); + test("sanitizeStreamingChunk preserves and mirrors Copilot reasoning_text deltas", () => { const sanitized = sanitizeStreamingChunk({ choices: [ From 88e666e59690a3909cf54bead9894dd4580bc27f Mon Sep 17 00:00:00 2001 From: Rafa Martins <146174365+rafacpti23@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:02:30 -0300 Subject: [PATCH 07/33] fix(sse): fix double-escaped tabs in Codex JSON tool call arguments (#12841) Fixes #12831. When the Codex upstream model produces string values in tool arguments that contain double-escaped tabs (\t inside the JSON string instead of \t), the parser outputs literal backslash-t characters. This breaks editor patches that rely on proper indentation. This commit adds a fixDoubleEscapedTabs sanitization step before parsing to restore them to single tabs. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- ...841-double-escaped-tabs-codex-tool-args.md | 1 + .../translator/response/openai-responses.ts | 45 +++++++++- tests/unit/bug-12831.test.ts | 90 +++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/12841-double-escaped-tabs-codex-tool-args.md create mode 100644 tests/unit/bug-12831.test.ts diff --git a/changelog.d/fixes/12841-double-escaped-tabs-codex-tool-args.md b/changelog.d/fixes/12841-double-escaped-tabs-codex-tool-args.md new file mode 100644 index 0000000000..289e4ef043 --- /dev/null +++ b/changelog.d/fixes/12841-double-escaped-tabs-codex-tool-args.md @@ -0,0 +1 @@ +- **fix(sse):** stop over-escaped tabs from `gpt-5.6-luna-xhigh` corrupting Codex tool-call arguments — `\\t` is now collapsed back to a real tab instead of a literal `\t` text ([#12841](https://github.com/diegosouzapw/OmniRoute/pull/12841)) — thanks @rafacpti23 diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index cd1310bffd..0a840320c6 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -114,6 +114,49 @@ function escapeJsonStringValues(json: string, escapeState: JsonStringEscapeState return result; } +/** + * Collapse double-escaped tab sequences inside JSON string values. + * Some providers (e.g. gpt-5.6-luna-xhigh, #12831) over-escape a tab when + * emitting tool call argument JSON: instead of the single valid JSON escape + * `\t` (backslash + t), they emit `\\t` (backslash + backslash + t) inside + * the string value. JSON.parse then decodes that to a literal two-character + * `\t` text (backslash followed by the letter t) instead of an actual tab + * character, which breaks consumers (e.g. editor patches) expecting real + * tabs. This only rewrites the over-escaped form and leaves an + * already-correct single escape untouched. + */ +function fixDoubleEscapedTabs(json: string): string { + let result = ""; + let inString = false; + + for (let i = 0; i < json.length; i++) { + const ch = json[i]; + + if (inString && ch === "\\" && json[i + 1] === "\\" && json[i + 2] === "t") { + result += "\\t"; + i += 2; + continue; + } + + // Inside a string, leave any other escape sequence untouched. + if (inString && ch === "\\") { + result += ch + (json[i + 1] ?? ""); + i++; + continue; + } + + if (ch === '"') { + result += ch; + inString = !inString; + continue; + } + + result += ch; + } + + return result; +} + /** * Translate OpenAI chunk to Responses API events * @returns {Array} Array of events with { event, data } structure @@ -589,7 +632,7 @@ function emitToolCall(state, emit, tc) { state.funcArgsEscapeState[tcIdx] = createJsonStringEscapeState(); } const sanitized = escapeJsonStringValues( - tc.function.arguments, + fixDoubleEscapedTabs(tc.function.arguments), state.funcArgsEscapeState[tcIdx] ); const nextArgs = appendToolCallArgumentDelta(existingArgs, sanitized); diff --git a/tests/unit/bug-12831.test.ts b/tests/unit/bug-12831.test.ts new file mode 100644 index 0000000000..2a73c7a903 --- /dev/null +++ b/tests/unit/bug-12831.test.ts @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.ts"; + +test("Issue #12831: fixes double-escaped tabs in Codex JSON tool call arguments", () => { + const events = []; + const emit = (_name, payload) => events.push(payload); + const state = { + responseId: "res_123", + funcCallIds: {}, + funcNames: {}, + funcArgsBuf: {}, + funcArgsDone: {}, + funcItemAdded: {}, + funcItemDone: {}, + msgItemAdded: {}, + msgContentAdded: {}, + msgTextBuf: {}, + msgItemDone: {}, + }; + + const chunk1 = { + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_123", + function: { + name: "_edit", + // gpt-5.6-luna-xhigh emits literally \ followed by t in the JSON string + // to represent a tab, instead of a JSON escape for tab or a raw tab. + // Wait, in JSON, a tab in a string is encoded as "\t" (two characters: \ and t). + // If it's double-escaped, it emits "\t" (four characters: \, \, t in JSON string? No, two backslashes and a t: "\t") + // Let's assume the string is: {"input": "some code\twith tabs"} + arguments: '{\n "input": "some code\\twith tabs"', + }, + }, + ], + }, + }, + ], + }; + + const chunk2 = { + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: "\n}", + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }; + + const chunk3 = { + usage: { prompt_tokens: 10, completion_tokens: 10 }, + }; + + function processChunk(chunk) { + const chunkEvents = openaiToOpenAIResponsesResponse(chunk, state); + for (const ev of chunkEvents) { + emit(ev.event, ev.data); + } + } + + processChunk(chunk1); + processChunk(chunk2); + processChunk(chunk3); + + const doneEvent = events.find((e) => e.type === "response.function_call_arguments.done"); + + // Try parsing the arguments + const parsed = JSON.parse(doneEvent.arguments); + assert.strictEqual( + parsed.input, + "some code\twith tabs", + "The double-escaped tab should be unescaped to a single tab character" + ); +}); From 2b7a881c6f5e55a6515d71b6d236821a75aed425 Mon Sep 17 00:00:00 2001 From: Mr White <42571711+Neuron-Mr-White@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:02:37 +0800 Subject: [PATCH 08/33] fix(claude): passthrough tool_use names must match client-declared casing (#12855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): passthrough tool_use names must match client-declared casing A mapless restoreClaudePassthroughToolUseName upgraded known Claude Code tool names (bash -> Bash) on every Claude-format SSE passthrough. Clients that declared lowercase tool names (pi, OpenCode, ... on claude-format executors like devin-cli-agentic) received a tool_use name they never declared: client-side tool dispatch fails and echoing the history back hard-fails with devin-cli-agentic undeclared_historical_tool (live repro: pi + dva/glm-5-2 on a self-hosted router, 400 on every agentic turn). - restoreClaudePassthroughToolUseName: alias map first (renamed -> original), then normalize to the request's declared tools[] casing, never canonicalize undeclared names. Genuine Claude Code clients keep their #7926 protection (upstream downcase -> declared PascalCase). - devin-agentic serializer: case-insensitive fallback for historical tool_use names + render the declared spelling, so case drift can no longer kill a whole turn. Tests: tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts, tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts * fix(stream): direct ledger lookups in claude passthrough restore restoreClaudeToolName's canonical-upgrade fallback fires even when an alias ledger exists (canonical beats the identity match). The claude passthrough lane always carries a non-empty proxy_ ledger (buildClaudePassthroughToolNameMap), so every lowercase tool_use name was upgraded to Claude Code PascalCase on the SSE path while the JSON path (direct map.get) stayed correct — the live leak behind #12721. * docs(changelog): add fragment for claude passthrough tool_use casing fix Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../claude-passthrough-tool-name-casing.md | 1 + .../executors/devin-agentic/serializer.ts | 12 +- open-sse/utils/stream.ts | 82 +++++++++++- ...passthrough-tool-name-mapless-leak.test.ts | 121 ++++++++++++++++++ ...erializer-case-insensitive-history.test.ts | 70 ++++++++++ 5 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/claude-passthrough-tool-name-casing.md create mode 100644 tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts create mode 100644 tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts diff --git a/changelog.d/fixes/claude-passthrough-tool-name-casing.md b/changelog.d/fixes/claude-passthrough-tool-name-casing.md new file mode 100644 index 0000000000..5a73850e45 --- /dev/null +++ b/changelog.d/fixes/claude-passthrough-tool-name-casing.md @@ -0,0 +1 @@ +- **fix(stream):** restore Claude SSE passthrough `tool_use` names to the casing the client actually declared instead of "upgrading" them to the canonical Claude Code spelling (`bash` → `Bash`), which broke third-party Anthropic-format clients (pi/OpenCode on claude-format executors like devin-cli-agentic) while leaving the JSON path correct; the pre-existing Claude Code protection (upstream downcase restored to declared PascalCase, #7926) is preserved diff --git a/open-sse/executors/devin-agentic/serializer.ts b/open-sse/executors/devin-agentic/serializer.ts index 8593eea2cd..bdf7843c91 100644 --- a/open-sse/executors/devin-agentic/serializer.ts +++ b/open-sse/executors/devin-agentic/serializer.ts @@ -61,7 +61,15 @@ function serializeBlock( id ? "duplicate_tool_use_id" : "missing_tool_use_id" ); } - const declared = tools.find((tool) => tool.name === name); + // Exact match first; fall back to case-insensitive matching so a client + // echoing back a differently-cased name for the same tool (e.g. a router + // layer handing a Claude Code canonical "Bash" to a client that declared + // "bash") is normalized instead of hard-failing the whole turn with + // undeclared_historical_tool (#12721). The declared casing is rendered so + // the downstream Devin prompt always shows the catalog name verbatim. + const declared = + tools.find((tool) => tool.name === name) ?? + tools.find((tool) => tool.name.toLowerCase() === name.toLowerCase() && name !== ""); if (!declared) { throw new DevinAgenticBridgeError( `Historical tool_use references undeclared tool: ${name || "unknown"}`, @@ -72,7 +80,7 @@ function serializeBlock( return [ "[Assistant Tool Use]", `id: ${id}`, - `name: ${name}`, + `name: ${declared.name}`, "arguments:", JSON.stringify(record.input || {}, null, 2), ].join("\n"); diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index bfe9fab221..781b417634 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -608,7 +608,8 @@ function getOpenAIIntermediateChunks(value: unknown): unknown[] { export function restoreClaudePassthroughToolUseName( parsed: JsonRecord, - toolNameMap: unknown + toolNameMap: unknown, + requestTools?: unknown ): boolean { const block = parsed.content_block && typeof parsed.content_block === "object" @@ -617,13 +618,84 @@ export function restoreClaudePassthroughToolUseName( if (!block || block.type !== "tool_use" || typeof block.name !== "string") return false; const map = toolNameMap instanceof Map ? toolNameMap : null; - const restoredName = restoreClaudeToolName(block.name, map); + // 1) Alias ledger, direct lookups only. restoreClaudeToolName() is NOT used + // here on purpose: its canonical-upgrade fallback (bash -> Bash) fires + // even when an alias ledger exists (canonical beats the identity match), + // which poisoned claude->claude passthrough: the proxy_ ledger + // (buildClaudePassthroughToolNameMap) is always non-empty for claude + // passthrough, so every lowercase-declaring client (pi/OpenCode on + // claude-format executors like devin-cli-agentic) received "Bash" on the + // SSE path while the JSON path (direct map.get) stayed correct (#12721). + if (map && map.size > 0) { + const exact = map.get(block.name); + if (typeof exact === "string" && exact !== block.name) { + block.name = exact; + return true; + } + const lower = block.name.toLowerCase(); + for (const [sanitized, original] of map.entries()) { + if (sanitized.toLowerCase() !== lower && original.toLowerCase() !== lower) { + continue; + } + if (original !== block.name) { + block.name = original; + return true; + } + break; // identity echo in the ledger — nothing to restore + } + } + + // 2) Normalize upstream case drift to the request's DECLARED casing so a + // passthrough can never hand the client a name it did not declare + // (#12721). Conversely a genuine Claude Code client (declared "Bash") + // still gets "Bash" back when an OpenAI-style upstream downcased it + // (#7926). + const declaredName = findDeclaredToolName(requestTools, block.name); + if (declaredName !== null) { + if (declaredName === block.name) return false; + block.name = declaredName; + return true; + } + + // 3) Undeclared name with no alias: legacy canonicalization (canonical + // Claude Code spelling) as a last resort for CC-shaped traffic whose + // request body carries no tools[] (server tools, bare probes). + if (map && map.size > 0) return false; + const restoredName = restoreClaudeToolName(block.name, null); if (restoredName === block.name) return false; block.name = restoredName; return true; } +/** + * Exact- then case-insensitive lookup of `name` inside the request's tools[] + * (Anthropic `name` or OpenAI `function.name`). Returns the DECLARED spelling, + * or null when no declared tool matches (server tools, undeclared names). + */ +function findDeclaredToolName(requestTools: unknown, name: string): string | null { + if (!Array.isArray(requestTools)) return null; + const lower = name.toLowerCase(); + let caseInsensitive: string | null = null; + for (const tool of requestTools) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) continue; + const item = tool as JsonRecord; + const directName = typeof item.name === "string" ? item.name.trim() : ""; + const fn = + item.function && typeof item.function === "object" && !Array.isArray(item.function) + ? (item.function as JsonRecord) + : null; + const functionName = typeof fn?.name === "string" ? fn.name.trim() : ""; + const declared = functionName || directName; + if (!declared) continue; + if (declared === name) return declared; + if (caseInsensitive === null && declared.toLowerCase() === lower) { + caseInsensitive = declared; + } + } + return caseInsensitive; +} + // Note: TextDecoder/TextEncoder are created per-stream inside createSSEStream() // to avoid shared state issues with concurrent streams (TextDecoder with {stream:true} // maintains internal buffering state between decode() calls). @@ -1795,7 +1867,11 @@ export function createSSEStream(options: StreamOptions = {}) { return; } updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, parsed); - const restoredToolName = restoreClaudePassthroughToolUseName(parsed, toolNameMap); + const restoredToolName = restoreClaudePassthroughToolUseName( + parsed, + toolNameMap, + body + ); // Track content length and accumulate from Claude format if (parsed.delta?.text) { totalContentLength += parsed.delta.text.length; diff --git a/tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts b/tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts new file mode 100644 index 0000000000..508cb7d264 --- /dev/null +++ b/tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts @@ -0,0 +1,121 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { restoreClaudePassthroughToolUseName } from "../../open-sse/utils/stream.ts"; + +/** + * #12721: a Claude SSE passthrough must never hand the client a tool_use name + * it did not declare. A mapless restoreClaudeToolName "upgrades" known Claude + * Code names (bash -> Bash), which breaks third-party Anthropic-format clients + * (pi/OpenCode on claude-format executors like devin-cli-agentic): tool + * dispatch fails client-side and the echoed history hard-fails with + * undeclared_historical_tool. Genuine Claude Code clients (declared PascalCase) + * must still be protected from OpenAI-style upstreams that downcase names + * (#7926). + */ +describe("restoreClaudePassthroughToolUseName — declared-casing normalization (#12721)", () => { + const anthropicTools = (names: string[]) => + names.map((name) => ({ + name, + description: "d", + input_schema: { type: "object", properties: {} }, + })); + const toolUseBlock = (name: string) => ({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_01", name, input: {} }, + }); + + it("keeps a lowercase name verbatim when the client declared it lowercase (no map)", () => { + for (const name of ["bash", "read", "edit", "write", "grep", "glob"]) { + const parsed = toolUseBlock(name); + assert.equal( + restoreClaudePassthroughToolUseName( + parsed, + null, + anthropicTools(["bash", "read", "edit", "write", "grep", "glob"]) + ), + false + ); + assert.equal(parsed.content_block.name, name); + } + }); + + it("does NOT upgrade bash -> Bash when the client declared lowercase (no map) — the #12721 leak", () => { + const parsed = toolUseBlock("bash"); + assert.equal( + restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["bash"])), + false + ); + assert.equal(parsed.content_block.name, "bash"); + }); + + it("downcases an upstream PascalCase echo back to the declared lowercase spelling (no map)", () => { + const parsed = toolUseBlock("Bash"); + assert.equal(restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["bash"])), true); + assert.equal(parsed.content_block.name, "bash"); + }); + + it("keeps Claude Code clients working: upstream downcase restored to declared PascalCase (#7926)", () => { + const parsed = toolUseBlock("bash"); + assert.equal(restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["Bash"])), true); + assert.equal(parsed.content_block.name, "Bash"); + }); + + it("prefers the alias map (renamed -> original) over declared casing", () => { + const parsed = toolUseBlock("Bash"); + const map = new Map([["Bash", "bash"]]); + assert.equal(restoreClaudePassthroughToolUseName(parsed, map, anthropicTools(["Bash"])), true); + assert.equal(parsed.content_block.name, "bash"); + }); + + it("proxy_ ledger (claude passthrough) must not trigger the canonical upgrade — the #12721 live leak", () => { + // buildClaudePassthroughToolNameMap always emits proxy_ -> + // for claude passthrough; a non-empty ledger used to route through + // restoreClaudeToolName whose canonical fallback upgraded bash -> Bash. + const parsed = toolUseBlock("bash"); + const map = new Map([ + ["proxy_bash", "bash"], + ["proxy_read", "read"], + ]); + assert.equal( + restoreClaudePassthroughToolUseName(parsed, map, anthropicTools(["bash", "read"])), + false + ); + assert.equal(parsed.content_block.name, "bash"); + }); + + it("proxy_ ledger still restores prefixed echoes", () => { + const parsed = toolUseBlock("proxy_bash"); + const map = new Map([["proxy_bash", "bash"]]); + assert.equal(restoreClaudePassthroughToolUseName(parsed, map, anthropicTools(["bash"])), true); + assert.equal(parsed.content_block.name, "bash"); + }); + + it("leaves undeclared names verbatim instead of canonicalizing them (no map)", () => { + const parsed = toolUseBlock("memory_store"); + assert.equal( + restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["bash"])), + false + ); + assert.equal(parsed.content_block.name, "memory_store"); + }); + + it("reads OpenAI-style function.name declarations too", () => { + const parsed = toolUseBlock("bash"); + const tools = [{ type: "function", function: { name: "bash", parameters: {} } }]; + assert.equal(restoreClaudePassthroughToolUseName(parsed, null, tools), false); + assert.equal(parsed.content_block.name, "bash"); + }); + + it("ignores non-tool_use blocks", () => { + const parsed = { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "hello" }, + }; + assert.equal( + restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["bash"])), + false + ); + }); +}); diff --git a/tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts b/tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts new file mode 100644 index 0000000000..4203f483fe --- /dev/null +++ b/tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts @@ -0,0 +1,70 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { serializeAnthropicForDevin } from "../../open-sse/executors/devin-agentic/serializer.ts"; + +/** + * #12721 safety net: historical tool_use blocks whose name differs from the + * declared tool only by case (a client echoing back a Claude Code canonical + * "Bash" it received from a router layer while it declared "bash") must + * serialize against the declared tool instead of hard-failing the whole turn + * with undeclared_historical_tool. The declared casing is rendered into the + * Devin execution-trace prompt. + */ +describe("devin-agentic serializer — case-insensitive historical tool_use (#12721)", () => { + const tools = [ + { + name: "bash", + description: "run", + input_schema: { type: "object", properties: { command: { type: "string" } } }, + }, + { + name: "read", + description: "read", + input_schema: { type: "object", properties: { path: { type: "string" } } }, + }, + ]; + + const historyMessages = (name: string) => [ + { role: "user", content: "run uname" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_01", name, input: { command: "uname -a" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_01", content: "Linux host" }], + }, + { role: "user", content: "thanks, run uptime too" }, + ]; + + it("accepts a PascalCase echo of a lowercase-declared tool and renders the declared name", () => { + const prompt = serializeAnthropicForDevin({ + model: "glm-5-2", + tools, + messages: historyMessages("Bash"), + }); + assert.match(prompt.text, /name: bash/); + assert.doesNotMatch(prompt.text, /name: Bash/); + }); + + it("still accepts exact-case history", () => { + const prompt = serializeAnthropicForDevin({ + model: "glm-5-2", + tools, + messages: historyMessages("bash"), + }); + assert.match(prompt.text, /name: bash/); + }); + + it("still rejects a genuinely undeclared tool", () => { + assert.throws( + () => + serializeAnthropicForDevin({ + model: "glm-5-2", + tools, + messages: historyMessages("not-a-tool"), + }), + /undeclared tool/ + ); + }); +}); From 88b928b8f13554235f5267457d8cdd4f459f7ceb Mon Sep 17 00:00:00 2001 From: "Can H. Tartanoglu" Date: Sat, 19 Sep 2026 05:02:48 +0200 Subject: [PATCH 09/33] feat(api): accept expiresAt when creating API keys (#12952) * feat(api): accept expiresAt when creating API keys POST /api/keys accepts expiresAt (ISO datetime, nullable) with the same semantics as the key-update path, so automation can create an expiring key in one operation instead of create-then-update. Omitted/null preserves the current non-expiring behavior; enforcement reuses the existing expiry policy without changes. * docs: name changelog fragment after PR number with credit --- .../12952-api-key-create-expires-at.md | 1 + src/app/api/keys/route.ts | 3 + src/lib/db/apiKeys.ts | 7 +- src/shared/validation/schemas/keys.ts | 1 + tests/unit/api-key-create-expiry.test.ts | 129 ++++++++++++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 changelog.d/features/12952-api-key-create-expires-at.md create mode 100644 tests/unit/api-key-create-expiry.test.ts diff --git a/changelog.d/features/12952-api-key-create-expires-at.md b/changelog.d/features/12952-api-key-create-expires-at.md new file mode 100644 index 0000000000..e3314fe5f1 --- /dev/null +++ b/changelog.d/features/12952-api-key-create-expires-at.md @@ -0,0 +1 @@ +- **feat(api):** `POST /api/keys` accepts `expiresAt` (ISO datetime, nullable) with the same semantics as the key-update path, so automation can create an expiring key in one operation instead of create-then-update. Omitted/null preserves the current non-expiring behavior; enforcement reuses the existing expiry policy ([#12952](https://github.com/diegosouzapw/OmniRoute/pull/12952)) — thanks @caniko diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index 51016b9f52..a1b7319e30 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -82,6 +82,7 @@ export async function POST(request) { dailyUsageLimitUsd, weeklyUsageLimitUsd, chaosModeEnabled, + expiresAt, } = validation.data; // Always get machineId from server @@ -92,6 +93,7 @@ export async function POST(request) { allowedModels, allowedCombos, allowedConnections, + expiresAt, }); if ( noLog === true || @@ -137,6 +139,7 @@ export async function POST(request) { dailyUsageLimitUsd: dailyUsageLimitUsd ?? null, weeklyUsageLimitUsd: weeklyUsageLimitUsd ?? null, chaosModeEnabled: chaosModeEnabled === true, + expiresAt: expiresAt ?? null, streamDefaultMode: "legacy", compressionEnabled: true, cacheDefaultMode: "legacy", diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 0764e10335..037f14f2a7 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -85,6 +85,7 @@ interface CreateApiKeyOptions { allowedModels?: string[]; allowedCombos?: string[]; allowedConnections?: string[]; + expiresAt?: string | null; } export type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; @@ -450,7 +451,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { "SELECT id, name, machine_id, model_access_mode, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, allow_auto_combos, catalog_scope, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtInsertKey = db.prepare( - "INSERT INTO api_keys (id, name, key, machine_id, model_access_mode, allowed_models, allowed_combos, allowed_connections, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO api_keys (id, name, key, machine_id, model_access_mode, allowed_models, allowed_combos, allowed_connections, no_log, created_at, key_prefix, key_hash, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); _stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?"); } @@ -710,6 +711,7 @@ export async function createApiKey( noLog: false, allowUsageCommand: false, createdAt: now, + expiresAt: options.expiresAt ?? null, scopes, }; @@ -727,7 +729,8 @@ export async function createApiKey( apiKey.createdAt, apiKey.key.slice(0, 12), await hashKey(apiKey.key), - JSON.stringify(scopes) + JSON.stringify(scopes), + apiKey.expiresAt ); setNoLog(apiKey.id, false); diff --git a/src/shared/validation/schemas/keys.ts b/src/shared/validation/schemas/keys.ts index 17e05bb75b..d16d7d3856 100644 --- a/src/shared/validation/schemas/keys.ts +++ b/src/shared/validation/schemas/keys.ts @@ -61,6 +61,7 @@ export const createKeySchema = z dailyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), weeklyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), chaosModeEnabled: z.boolean().optional(), + expiresAt: z.string().datetime().nullable().optional(), scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(), allowedConnections: z.array(z.string().uuid()).min(1).max(100).optional(), }) diff --git a/tests/unit/api-key-create-expiry.test.ts b/tests/unit/api-key-create-expiry.test.ts new file mode 100644 index 0000000000..cd26161e03 --- /dev/null +++ b/tests/unit/api-key-create-expiry.test.ts @@ -0,0 +1,129 @@ +// Regression tests: POST /api/keys (and createApiKey) must accept expiresAt +// at creation time with identical semantics to the update path. +// +// Today createKeySchema strips expiresAt, so a key can only become expiring +// via a second updateApiKeyPermissions call — leaving a window where the key +// exists without expiry. Automation must be able to create an expiring key in +// one operation. +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"; +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-key-create-expiry-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-create-expiry"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const { createKeySchema } = await import("../../src/shared/validation/schemas/keys.ts"); +const listRoute = await import("../../src/app/api/keys/route.ts"); + +const FUTURE = new Date(Date.now() + 60 * 60_000).toISOString(); +const PAST = new Date(Date.now() - 60_000).toISOString(); + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function enableManagementAuth() { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + const { updateSettings } = await import("@/lib/db/settings"); + await updateSettings({ requireLogin: true, password: "" }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + core.resetDbInstance(); +}); + +test("createKeySchema accepts expiresAt with update-path semantics", () => { + for (const expiresAt of [FUTURE, PAST, null, undefined]) { + const parsed = createKeySchema.safeParse({ + name: "x", + ...(expiresAt !== undefined && { expiresAt }), + }); + assert.equal(parsed.success, true, `expected expiresAt=${expiresAt} to parse`); + } + for (const expiresAt of ["not-a-date", 123, "", "2026-13-45"]) { + const parsed = createKeySchema.safeParse({ name: "x", expiresAt }); + assert.equal(parsed.success, false, `expected expiresAt=${JSON.stringify(expiresAt)} to fail`); + } +}); + +test("createApiKey persists future expiresAt; key validates", async () => { + const created = await apiKeysDb.createApiKey("expiry-create", "machine-1", [], { + expiresAt: FUTURE, + }); + const readback = await apiKeysDb.getApiKeyById(created.id); + assert.equal(readback?.expiresAt, FUTURE); + assert.equal(await apiKeysDb.validateApiKey(created.key), true); +}); + +test("createApiKey with past expiresAt is rejected like the update path", async () => { + const created = await apiKeysDb.createApiKey("expiry-past", "machine-1", [], { expiresAt: PAST }); + assert.equal(await apiKeysDb.validateApiKey(created.key), false); +}); + +test("createApiKey without expiresAt stays non-expiring", async () => { + for (const opts of [{}, { expiresAt: null }, { expiresAt: undefined }]) { + const created = await apiKeysDb.createApiKey( + `expiry-absent-${JSON.stringify(opts.expiresAt)}`, + "machine-1", + [], + opts + ); + const readback = await apiKeysDb.getApiKeyById(created.id); + assert.equal(readback?.expiresAt ?? null, null); + assert.equal(await apiKeysDb.validateApiKey(created.key), true); + } +}); + +test("update path still enforces expiry on a key created with future expiry", async () => { + const created = await apiKeysDb.createApiKey("expiry-interop", "machine-1", [], { + expiresAt: FUTURE, + }); + assert.equal(await apiKeysDb.validateApiKey(created.key), true); + assert.equal(await apiKeysDb.updateApiKeyPermissions(created.id, { expiresAt: PAST }), true); + assert.equal(await apiKeysDb.validateApiKey(created.key), false); +}); + +test("POST /api/keys creates an expiring key in one operation", async () => { + await enableManagementAuth(); + const response = await listRoute.POST( + await makeManagementSessionRequest("http://localhost/api/keys", { + method: "POST", + body: { name: "Route Expiry Key", expiresAt: FUTURE }, + }) + ); + assert.equal(response.status, 201); + const body = (await response.json()) as { id: string; key: string; expiresAt: string | null }; + assert.equal(body.expiresAt, FUTURE); + const readback = await apiKeysDb.getApiKeyById(body.id); + assert.equal(readback?.expiresAt, FUTURE); + assert.equal(await apiKeysDb.validateApiKey(body.key), true); +}); + +test("POST /api/keys rejects malformed expiresAt without creating a key", async () => { + await enableManagementAuth(); + const before = (await apiKeysDb.getApiKeys()).length; + const response = await listRoute.POST( + await makeManagementSessionRequest("http://localhost/api/keys", { + method: "POST", + body: { name: "Bad Expiry Key", expiresAt: "not-a-date" }, + }) + ); + assert.equal(response.status, 400); + assert.equal((await apiKeysDb.getApiKeys()).length, before); +}); From b272aa2f2300698876e3ace6a3e4dc80eca9914e Mon Sep 17 00:00:00 2001 From: Vishesh Date: Sat, 19 Sep 2026 08:32:56 +0530 Subject: [PATCH 10/33] fix(tests): drain call-log saves before chat-pipeline DB resets (#12780) (#12966) Pending call-log saves can still be in flight when resetStorage closes the DB instance. The orphaned save then writes into the next test's DB after getDbInstance resolves. - Drain pending saves in resetStorage before resetting DB instances. - Bind DB instance at the start of saveCallLogOperation. - Wait specifically for the Codex responses row in chat-pipeline tests. --- src/lib/usage/callLogs.ts | 7 ++++++- tests/integration/_chatPipelineHarness.ts | 11 +++++++++++ tests/integration/chat-pipeline.test.ts | 18 +++++++++++++++++- .../provider-request-failure-pipeline.test.ts | 8 +++++++- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 4bebc2866d..9518758974 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -452,6 +452,12 @@ function getLegacyInlineDetail(id: string) { async function saveCallLogOperation(entry: any): Promise { try { + // Bind the DB instance up front, before any await (resolveAccountName, + // writeCallArtifactAsync). If the singleton is reset/closed while this + // operation awaits, the insert must target the instance this request + // started against — a closed handle fails into the catch below instead of + // silently writing into whatever database opened afterwards (#12780). + const db = getDbInstance(); const apiKeyContext = getCallLogApiKeyContext(); // `||` (not `??`): an empty-string apiKeyId/apiKeyName is "unattributed", // same as before this fallback existed — it must not be persisted verbatim @@ -591,7 +597,6 @@ async function saveCallLogOperation(entry: any): Promise { } } - const db = getDbInstance(); db.prepare( ` INSERT INTO call_logs ( diff --git a/tests/integration/_chatPipelineHarness.ts b/tests/integration/_chatPipelineHarness.ts index c154255914..6c69d83c07 100644 --- a/tests/integration/_chatPipelineHarness.ts +++ b/tests/integration/_chatPipelineHarness.ts @@ -285,6 +285,16 @@ export async function createChatPipelineHarness(prefix) { invalidateMemorySettingsCache(); clearSkillState(); await new Promise((resolve) => setTimeout(resolve, 20)); + // Call-log persistence is fire-and-forget and the first cold artifact-worker + // spawn can take ~2.4s, so the previous test's saves may still be in flight. + // Drain before the DB reset so they land in the DB being torn down, not in the + // next test's fresh database (#12780). + const drained = await callLogsDb.waitForCallLogSaves(10_000); + if (!drained) { + console.warn( + `[chat-pipeline-harness:${prefix}] call-log saves did not drain within 10s; resetting anyway` + ); + } core.resetDbInstance(); fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); fs.mkdirSync(testDataDir, { recursive: true }); @@ -299,6 +309,7 @@ export async function createChatPipelineHarness(prefix) { semanticCacheModule.clearCache(); clearSkillState(); resetAllCircuitBreakers(); + await callLogsDb.waitForCallLogSaves(10_000); core.resetDbInstance(); fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 3620f1ebba..03b3e9e8a1 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -16,6 +16,7 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const readCacheDb = await import("../../src/lib/db/readCache.ts"); const { getLatestCallLog, getResponsesCallLogs } = await import("./_chatPipelineCallLogs.ts"); +const { waitForCallLogSaves } = await import("../../src/lib/usage/callLogs.ts"); const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts"); const { skillRegistry } = await import("../../src/lib/skills/registry.ts"); const { skillExecutor } = await import("../../src/lib/skills/executor.ts"); @@ -373,6 +374,15 @@ async function resetStorage() { readCacheDb.invalidateDbCache(); invalidateMemorySettingsCache(); await new Promise((resolve) => setTimeout(resolve, 20)); + // Call-log persistence is fire-and-forget (persistAttemptLogs → saveCallLog with + // a .catch(() => {})), and the first cold artifact-worker spawn can take ~2.4s, so + // the previous test's saves may still be in flight here. Draining before the DB + // reset keeps those rows in the DB being torn down instead of letting them land + // in the next test's fresh database (#12780). + const drained = await waitForCallLogSaves(10_000); + if (!drained) { + console.warn("[chat-pipeline] call-log saves did not drain within 10s; resetting anyway"); + } core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); @@ -663,7 +673,13 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call ); const json = (await response.json()) as any; - const callLog = await waitFor(() => getLatestCallLog()); + // Wait specifically for THIS request's Codex /v1/responses row instead of taking + // whatever the latest row happens to be: an unfiltered read can surface a row from + // a previous test that landed late in this database (#12780). + const callLog = await waitFor(async () => { + const rows = await getResponsesCallLogs(); + return rows.find((row) => row.provider === "codex") ?? null; + }); assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); diff --git a/tests/unit/provider-request-failure-pipeline.test.ts b/tests/unit/provider-request-failure-pipeline.test.ts index d27e2f361b..3f0b1a3000 100644 --- a/tests/unit/provider-request-failure-pipeline.test.ts +++ b/tests/unit/provider-request-failure-pipeline.test.ts @@ -21,7 +21,8 @@ const { clearInflight } = await import("../../open-sse/services/requestDedup.ts" const { resetAll: resetAccountSemaphores } = await import("../../open-sse/services/accountSemaphore.ts"); const { clearModelLock } = await import("../../open-sse/services/accountFallback.ts"); -const { getCallLogs, getCallLogById } = await import("../../src/lib/usage/callLogs.ts"); +const { getCallLogs, getCallLogById, waitForCallLogSaves } = + await import("../../src/lib/usage/callLogs.ts"); const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); const { resetPayloadRulesConfigForTests } = await import("../../open-sse/services/payloadRules.ts"); const { CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA, CONTEXT_1M_BETA_HEADER } = @@ -56,6 +57,11 @@ async function resetStorage() { clearIdempotency(); clearInflight(); clearModelLock(); + // Call-log persistence is fire-and-forget and the first cold artifact-worker + // spawn can take ~2.4s, so this test's saves may still be in flight when the + // next test resets the DB. Drain so a late row cannot land in the next test's + // fresh database and get picked up by its waitFor(getLatestCallLog()) (#12780). + await waitForCallLogSaves(10_000); core.resetDbInstance(); // A full reset must also drop the settings read-cache. Otherwise the cached // value (e.g. call_log_pipeline_enabled=true seeded earlier) survives the DB From a123bd049e64848e27a4a6e2580ce56cebf49a08 Mon Sep 17 00:00:00 2001 From: Tiangao <53409436+tiangao88@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:03:11 +0200 Subject: [PATCH 11/33] fix(images): fall back when combo leg returns empty 2xx response (#12982) * fix(images): fall back when combo leg returns empty 2xx response fetchImageEndpoint() normalized any successful HTTP response to success:true with data.data || [], so an OpenAI-compatible provider returning 200 with an empty/malformed image payload stopped image combos on the first leg and produced an image-less 200. Require at least one usable item (non-empty b64_json or url) before declaring success; empty 2xx becomes a retryable 502 with a sanitized error so executeImageCombo() advances to the next priority leg. Valid responses and direct image-model requests are unchanged. * chore(changelog): add fragment for image-combo empty-2xx fallback (#12982) * test(images): drop misleading #10199 reference from empty-200 fallback test The test file and its production-code comment referenced issue #10199, which belongs to an unrelated, already-merged PR (auto/best-free free-tier filter fix). Rename the test file and update comments to remove the incorrect reference so future readers are not misled. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(images): fix response-shape assertions after #12268 combo envelope change release/v3.8.51 already ships #12268, which changed executeImageCombo() to return the handler's payload unchanged ({created, data: [...]}) instead of unwrapping it into a bare array. Update the two assertions that still expected a bare array so the test reflects the combo response shape that is actually live on the target branch; the fallback behavior under test is unaffected. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Tiangao (hermes) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../12982-image-combo-empty-2xx-fallback.md | 1 + open-sse/handlers/imageGeneration.ts | 32 +- .../image-combo-empty-200-fallback.test.ts | 328 ++++++++++++++++++ 3 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/12982-image-combo-empty-2xx-fallback.md create mode 100644 tests/unit/combo/image-combo-empty-200-fallback.test.ts diff --git a/changelog.d/fixes/12982-image-combo-empty-2xx-fallback.md b/changelog.d/fixes/12982-image-combo-empty-2xx-fallback.md new file mode 100644 index 0000000000..674a1cfc70 --- /dev/null +++ b/changelog.d/fixes/12982-image-combo-empty-2xx-fallback.md @@ -0,0 +1 @@ +- **fix(images):** image-combo legs now fall back when an upstream provider returns HTTP 2xx with an empty or malformed image payload. `fetchImageEndpoint` previously normalized any successful HTTP response to `success: true` (`data.data || []`), so `executeImageCombo` stopped on the first leg and handed the client an image-less 200. The OpenAI-compatible normalization now requires at least one usable item (non-empty `b64_json` or `url`) in `data[]`; an empty/malformed 2xx becomes a retryable 502 with a sanitized error, so priority image combos advance to the next leg. Valid payloads and direct image-model requests are unchanged. [#12982](https://github.com/diegosouzapw/OmniRoute/pull/12982) diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index bd6f1b68aa..7447fb125e 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -2912,11 +2912,41 @@ async function fetchImageEndpoint(url, headers, body, provider, log) { const data = await response.json(); // Normalize response to OpenAI format + const items = Array.isArray(data?.data) ? data.data : []; + + // Some providers return HTTP 2xx with an empty or malformed image + // payload (empty data array, missing/blank b64_json and url). Treating that + // as success makes image-combo strategies stop on the first leg and hand an + // image-less 200 to the client. Require at least one usable image item and + // surface an empty 2xx as a retryable 502 so combos fall back to the next + // priority leg. + const hasUsableImage = items.some( + (item: unknown) => + isJsonObject(item) && + ((typeof item.b64_json === "string" && item.b64_json.length > 0) || + (typeof item.url === "string" && item.url.length > 0)) + ); + if (!hasUsableImage) { + if (log) { + log.warn( + "IMAGE", + `${provider} returned 200 without a usable image payload; treating as retryable 502` + ); + } + return { + success: false, + status: HTTP_STATUS.BAD_GATEWAY, + error: sanitizeErrorMessage( + "Image provider returned a success status without an image payload" + ), + }; + } + return { success: true, data: { created: data.created || Math.floor(Date.now() / 1000), - data: data.data || [], + data: items, }, }; } catch (err: unknown) { diff --git a/tests/unit/combo/image-combo-empty-200-fallback.test.ts b/tests/unit/combo/image-combo-empty-200-fallback.test.ts new file mode 100644 index 0000000000..bed58ae22e --- /dev/null +++ b/tests/unit/combo/image-combo-empty-200-fallback.test.ts @@ -0,0 +1,328 @@ +/** + * Image combo fallback on empty 2xx upstream responses + * + * Repro: an OpenAI-compatible image provider (e.g. openrouter/*) can return + * HTTP 200 with an empty or malformed image payload (no usable b64_json/url in + * data[]). fetchImageEndpoint() used to normalize that to success:true, so + * executeImageCombo() stopped on the first leg and the client received an + * image-less 200. Hermes then rejected the response. + * + * Fix: require at least one usable image item before declaring success; an + * empty 2xx becomes a retryable 502 so the combo advances to the next leg. + * + * Strategy: real isolated SQLite DATA_DIR + real combo resolution + real + * credentials path (seeded apikey connection) + stubbed globalThis.fetch. + * No paid requests, no module mocking (tsx loader cannot mock ESM exports). + * + * Run: node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts + * --import ./tests/_setup/isolateDataDir.ts --test + * tests/unit/combo/image-combo-empty-200-fallback.test.ts + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-image-combo-empty-200-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-for-image-combo-empty-200-tests"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "image-combo-empty-200-test-secret"; + +const core = await import("@/lib/db/core.ts"); +const providersDb = await import("@/lib/db/providers.ts"); +const { createCombo } = await import("@/lib/db/combos"); +const { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo"); + +const PNG_B64 = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64"); + +const originalFetch = globalThis.fetch; + +type LogEntry = { level: string; tag: unknown; msg: unknown }; + +function createLog() { + const entries: LogEntry[] = []; + const record = + (level: string) => + (tag: unknown, msg: unknown): number => + entries.push({ level, tag, msg }); + return { + info: record("info"), + warn: record("warn"), + error: record("error"), + debug: record("debug"), + entries, + }; +} + +function createMockAuth() { + return { + request: new Request("http://localhost:20128/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "empty-200-combo", prompt: "a cat" }), + }), + policy: { apiKeyInfo: { id: "test-key", name: "test-key" } }, + }; +} + +async function resetStorage() { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +/** Seed an active openrouter apikey connection so credential resolution succeeds. */ +async function seedOpenRouterConnection() { + return providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "openrouter-empty-200-test", + apiKey: "sk-or-test-not-a-real-key", + isActive: true, + testStatus: "active", + rateLimitedUntil: null, + }); +} + +/** + * Seed the pmoc-image-text style two-leg combo: first leg returns an empty + * 200 (stubbed upstream), second leg returns a valid image. + */ +async function seedTwoLegCombo(name: string) { + return createCombo({ + name, + strategy: "priority", + models: ["openrouter/openai/gpt-5-image-mini", "openrouter/openai/gpt-5.4-image-2"], + }); +} + +/** Stub fetch: first call → empty 200, subsequent calls → valid image 200. */ +function stubFetchEmptyThenValid(hitLog: Array<{ url: string; model?: string }>) { + let callIndex = 0; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const index = callIndex++; + const bodyText = typeof init?.body === "string" ? init.body : String(init?.body ?? ""); + let model: string | undefined; + try { + model = (JSON.parse(bodyText) as { model?: string }).model; + } catch { + model = undefined; + } + hitLog.push({ url: String(url), model }); + if (index === 0) { + return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ created: Math.floor(Date.now() / 1000), data: [{ b64_json: PNG_B64 }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as typeof fetch; +} + +/** Stub fetch: every call → empty 200 (all legs unusable). */ +function stubFetchAlwaysEmpty() { + globalThis.fetch = (async () => + new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +} + +/** Stub fetch: every call → single-leg valid image (direct-model baseline). */ +function stubFetchAlwaysValid(hitLog?: Array<{ url: string; model?: string }>) { + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (hitLog) { + let model: string | undefined; + try { + model = ( + JSON.parse(typeof init?.body === "string" ? init.body : String(init?.body ?? "")) as { + model?: string; + } + ).model; + } catch { + model = undefined; + } + hitLog.push({ url: String(url), model }); + } + return new Response( + JSON.stringify({ created: Math.floor(Date.now() / 1000), data: [{ b64_json: PNG_B64 }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as typeof fetch; +} + +test("empty 200 from first leg falls back to second leg and second leg image is served", async () => { + await resetStorage(); + await seedOpenRouterConnection(); + await seedTwoLegCombo("empty-200-combo"); + + const hits: Array<{ url: string; model?: string }> = []; + stubFetchEmptyThenValid(hits); + + const log = createLog(); + const response = await executeImageCombo( + "empty-200-combo", + { model: "empty-200-combo", prompt: "a cat", n: 1 }, + createMockAuth(), + Date.now(), + log + ); + + assert.equal(response.status, 200, "combo must ultimately succeed via leg 2"); + const body = (await response.json()) as { data?: Array<{ b64_json?: string }> }; + assert.ok(Array.isArray(body.data), "response body must carry the image items array"); + assert.equal(body.data?.length, 1, "exactly one image (from the second leg)"); + assert.equal(body.data?.[0]?.b64_json, PNG_B64, "served image must come from leg 2"); + + // Both legs were tried: first the empty-200 stub, then the valid stub. + assert.equal(hits.length, 2, "combo must advance to the second leg"); + assert.equal(hits[0].model, "openai/gpt-5-image-mini", "leg 1 model hit first"); + assert.equal(hits[1].model, "openai/gpt-5.4-image-2", "leg 2 model hit second"); + + const warnJoined = log.entries + .filter((e) => e.level === "warn") + .map((e) => String(e.msg)) + .join(" "); + assert.ok( + warnJoined.includes("without a usable image payload"), + "leg-1 empty 200 must be logged as unusable payload" + ); +}); + +test("fallback metadata reflects the additional attempt via X-OmniRoute-Fallback-Attempts", async () => { + await resetStorage(); + await seedOpenRouterConnection(); + await seedTwoLegCombo("empty-200-fallback-meta-combo"); + + const hits: Array<{ url: string; model?: string }> = []; + stubFetchEmptyThenValid(hits); + + const response = await executeImageCombo( + "empty-200-fallback-meta-combo", + { model: "empty-200-fallback-meta-combo", prompt: "a cat", n: 1 }, + createMockAuth(), + Date.now(), + createLog() + ); + + assert.equal(response.status, 200); + const attempts = response.headers.get("X-OmniRoute-Fallback-Attempts"); + assert.ok(attempts !== null, "fallback attempts header must be present"); + assert.equal(attempts, "1", "one leg failed over, so fallback attempts must be 1"); +}); + +test("valid first-leg response does not invoke later legs", async () => { + await resetStorage(); + await seedOpenRouterConnection(); + await seedTwoLegCombo("empty-200-valid-first-combo"); + + const hits: Array<{ url: string; model?: string }> = []; + stubFetchAlwaysValid(hits); + + const response = await executeImageCombo( + "empty-200-valid-first-combo", + { model: "empty-200-valid-first-combo", prompt: "a cat", n: 1 }, + createMockAuth(), + Date.now(), + createLog() + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { data?: Array<{ b64_json?: string }> }; + assert.equal(body.data?.[0]?.b64_json, PNG_B64); + assert.equal(hits.length, 1, "first leg success must stop the combo (no later legs hit)"); + assert.equal(hits[0].model, "openai/gpt-5-image-mini"); +}); + +test("all legs returning empty 200 yields a retryable 502 with sanitized error", async () => { + await resetStorage(); + await seedOpenRouterConnection(); + await createCombo({ + name: "empty-200-all-legs-combo", + strategy: "priority", + models: ["openrouter/openai/gpt-5-image-mini", "openrouter/openai/gpt-5.4-image-2"], + }); + + stubFetchAlwaysEmpty(); + + const response = await executeImageCombo( + "empty-200-all-legs-combo", + { model: "empty-200-all-legs-combo", prompt: "a cat", n: 1 }, + createMockAuth(), + Date.now(), + createLog() + ); + + assert.equal(response.status, 502, "exhausted combo must surface the retryable 502"); + const bodyStr = JSON.stringify(await response.json()); + assert.ok( + bodyStr.includes("image payload") || bodyStr.includes("Image provider"), + "error must describe the unusable payload" + ); + assert.ok(!bodyStr.includes("sk-or-test"), "error must not leak credentials"); + assert.ok(!bodyStr.includes("at "), "error must not leak stack traces"); +}); + +test("direct image model request with empty 200 is a retryable 502 (behavior preserved for valid payloads)", async () => { + await resetStorage(); + await seedOpenRouterConnection(); + + const { handleImageGeneration } = await import("@omniroute/open-sse/handlers/imageGeneration"); + + // Empty 200 → retryable 502 (previously a bogus success) + stubFetchAlwaysEmpty(); + const emptyResult = (await handleImageGeneration({ + body: { model: "openrouter/openai/gpt-5-image-mini", prompt: "a cat", n: 1 }, + credentials: { apiKey: "sk-or-test-not-a-real-key" }, + log: createLog(), + })) as { success: boolean; status?: number; error?: string }; + assert.equal(emptyResult.success, false); + assert.equal(emptyResult.status, 502); + assert.ok( + typeof emptyResult.error === "string" && !emptyResult.error.includes("sk-or-test"), + "sanitized error must not include credentials" + ); + + // Valid 200 → success preserved + stubFetchAlwaysValid(); + const validResult = (await handleImageGeneration({ + body: { model: "openrouter/openai/gpt-5-image-mini", prompt: "a cat", n: 1 }, + credentials: { apiKey: "sk-or-test-not-a-real-key" }, + log: createLog(), + })) as { success: boolean; data?: { data?: Array<{ b64_json?: string }> } }; + assert.equal(validResult.success, true, "valid payload must still succeed"); + assert.equal(validResult.data?.data?.[0]?.b64_json, PNG_B64); + + // url-style payload → success preserved + globalThis.fetch = (async () => + new Response(JSON.stringify({ created: 1, data: [{ url: "https://example.test/img.png" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + const urlResult = (await handleImageGeneration({ + body: { model: "openrouter/openai/gpt-5-image-mini", prompt: "a cat", n: 1 }, + credentials: { apiKey: "sk-or-test-not-a-real-key" }, + log: createLog(), + })) as { success: boolean; data?: { data?: Array<{ url?: string }> } }; + assert.equal(urlResult.success, true, "url-bearing payload must still succeed"); + assert.equal(urlResult.data?.data?.[0]?.url, "https://example.test/img.png"); + + // Malformed: 200 with non-array data → retryable 502 + globalThis.fetch = (async () => + new Response(JSON.stringify({ created: 1, data: "not-an-array" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + const malformed = (await handleImageGeneration({ + body: { model: "openrouter/openai/gpt-5-image-mini", prompt: "a cat", n: 1 }, + credentials: { apiKey: "sk-or-test-not-a-real-key" }, + log: createLog(), + })) as { success: boolean; status?: number }; + assert.equal(malformed.success, false); + assert.equal(malformed.status, 502); +}); From 614ff4b60a11307ca7a6887eacf3cf130e35599a Mon Sep 17 00:00:00 2001 From: Rick7C2 Date: Fri, 18 Sep 2026 22:03:18 -0500 Subject: [PATCH 12/33] fix(sse): thread errorText into shouldMarkAccountExhaustedFrom429 (#13008) * fix(sse): thread errorText into shouldMarkAccountExhaustedFrom429 shouldPreserveQuotaSignals() (open-sse/services/quotaResetParsing.ts) gained an errorText parameter so an explicit quota-exhausted body could override the apikey-category default, but only one of its two call sites was updated. checkFallbackError() passes the upstream body; shouldMarkAccountExhaustedFrom429() still called it with the provider alone. With errorText undefined the helper's `Boolean(errorText) && looksLikeQuotaExhausted(errorText)` branch can never be true, so for every apikey-category provider without per-model quotas the connection was never marked quota-exhausted -- even when the upstream body explicitly said a long-window cap was hit. Thread errorText through the helper and pass it at the src/sse/handlers/chat.ts call site. The parameter is optional and additive: OAuth-category providers and callers that pass no body keep their existing behavior, and plain rate limits ("Rate limit exceeded, retry in 20s", "Too Many Requests") still fall through to the short generic cooldown. Regression guard: tests/unit/quota-signal-errortext-threading.test.ts * test(sse): pin the chat.ts call site that forwards errorText The errorText parameter added in the previous commit is optional, so dropping it at the only production call site (src/sse/handlers/chat.ts) is neither a type error nor a test failure -- the four existing cases call the helper directly and none of them assert the wiring. The half of the patch that makes it do anything in production could be reverted, or lost in a refactor, with the whole suite green. That is the same failure mode this branch fixes (a two-argument helper with a call site silently passing one), one level up. handleSingleModelChat is not exported, so the call cannot be driven or spied without widening the production surface. Assert at the source level instead, following tests/unit/api-key-provider-quota-bypass-scope.test.ts, and parse the argument list rather than regex-matching formatted text so a Prettier reflow cannot cause a false failure or a false pass. Also pins that there is exactly one call site, and what errorStr holds. Verified by mutation: dropping errorStr at chat.ts now fails 1 of 5. * chore: number quota signal changelog fragment Co-Authored-By: Paperclip --------- Co-authored-by: TogetherWeOwn Co-authored-by: togetherweown[bot] Co-authored-by: Paperclip --- .../13008-quota-signal-errortext-threading.md | 1 + open-sse/services/accountFallback.ts | 10 +- src/sse/handlers/chat.ts | 8 +- .../quota-signal-errortext-threading.test.ts | 202 ++++++++++++++++++ 4 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/13008-quota-signal-errortext-threading.md create mode 100644 tests/unit/quota-signal-errortext-threading.test.ts diff --git a/changelog.d/fixes/13008-quota-signal-errortext-threading.md b/changelog.d/fixes/13008-quota-signal-errortext-threading.md new file mode 100644 index 0000000000..2c9d852391 --- /dev/null +++ b/changelog.d/fixes/13008-quota-signal-errortext-threading.md @@ -0,0 +1 @@ +- **fix(resilience):** An apikey-category 429 whose body explicitly says a long-window quota was exhausted no longer skips the quota cache — `shouldPreserveQuotaSignals()` (`open-sse/services/quotaResetParsing.ts`) gained an `errorText` parameter in the #6638 fix, but only one of its two call sites was updated: `checkFallbackError()` passes the upstream body while `shouldMarkAccountExhaustedFrom429()` (`open-sse/services/accountFallback.ts`) still called it with the provider alone. With `errorText` undefined the helper's `Boolean(errorText) && looksLikeQuotaExhausted(errorText)` branch can never be true, so for every apikey-category provider without per-model quotas the connection was never marked quota-exhausted. `errorText` is now threaded through the helper and passed at the `src/sse/handlers/chat.ts` call site. Plain rate limits (`Rate limit exceeded, retry in 20s`, `Too Many Requests`) still fall through to the short generic cooldown. Regression guard: `tests/unit/quota-signal-errortext-threading.test.ts`. diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index e511a4429c..b09308b0b0 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -1009,15 +1009,21 @@ export function shouldMarkAccountExhaustedFrom429( provider: string | null | undefined, model: string | null | undefined = null, connectionPassthroughModels?: boolean, - failureKind?: FailureKind + failureKind?: FailureKind, + errorText?: string | null ): boolean { // A plain 429 means transient rate limiting / high traffic for many OAuth providers. // Only connection-poison the quota cache when the upstream body explicitly says // the long-window quota is exhausted; otherwise fallback should try another account // without making this one look quota-depleted for 5 minutes. if (failureKind === "rate_limit" || failureKind === "transient") return false; + // `errorText` is what lets an apikey-category provider opt back in: without the + // upstream body, `shouldPreserveQuotaSignals` has nothing to match against + // `looksLikeQuotaExhausted`, so every apikey 429 reads as plain rate limiting — + // including one whose body explicitly says a daily/weekly/monthly cap was hit. + // Mirrors the two-argument call in `checkFallbackError` below. return ( - shouldPreserveQuotaSignals(provider) && + shouldPreserveQuotaSignals(provider, errorText) && !hasPerModelQuota(provider, model, connectionPassthroughModels) ); } diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index cb69b10220..757916fda6 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -2411,7 +2411,13 @@ async function handleSingleModelChat( const passthroughModels = credentials.providerSpecificData?.passthroughModels; if ( result.status === 429 && - shouldMarkAccountExhaustedFrom429(provider, model, passthroughModels, failureKind) && + shouldMarkAccountExhaustedFrom429( + provider, + model, + passthroughModels, + failureKind, + errorStr + ) && // T-PROBE: a probe must not poison the 5min quotaCache for real // traffic (#9817). !(await shouldIsolateProbeFailures()) diff --git a/tests/unit/quota-signal-errortext-threading.test.ts b/tests/unit/quota-signal-errortext-threading.test.ts new file mode 100644 index 0000000000..146063dcf4 --- /dev/null +++ b/tests/unit/quota-signal-errortext-threading.test.ts @@ -0,0 +1,202 @@ +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"; +import { fileURLToPath } from "node:url"; + +// #10460 pattern: DATA_DIR must be assigned BEFORE any transitive DB import. +// accountFallback.ts statically imports `@/lib/db/providers` -> `src/lib/db/core.ts`, +// whose DATA_DIR is captured once at module-load time. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-errortext-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "quota-errortext-test-secret"; + +const { shouldMarkAccountExhaustedFrom429 } = + await import("../../open-sse/services/accountFallback.ts"); + +/** + * `shouldPreserveQuotaSignals(provider, errorText)` (open-sse/services/quotaResetParsing.ts) + * gained its second parameter with the #6638 fix, but only ONE of its two call sites was + * updated: `checkFallbackError` passes `errorText`, while + * `shouldMarkAccountExhaustedFrom429` still called it with the provider alone. With + * `errorText` undefined the helper's `Boolean(errorText) && looksLikeQuotaExhausted(...)` + * branch can never be true, so for every apikey-category provider the quota cache was + * never marked exhausted — even when the upstream body explicitly said a long-window cap + * was hit. These cases pin both directions of the now-threaded argument. + */ + +// An explicit long-window quota body — the exact shape #6638 was reported with. +const QUOTA_EXHAUSTED_BODY = JSON.stringify({ + error: "You have exceeded your weekly usage quota. Your quota will reset in 3 days.", +}); + +test("shouldMarkAccountExhaustedFrom429 seeds the quota cache for an apikey 429 whose body says the quota is exhausted", () => { + // `openai` is apikey-category and has no per-model quota, so the result is decided + // purely by whether the body-text quota signal reaches shouldPreserveQuotaSignals. + assert.equal( + shouldMarkAccountExhaustedFrom429( + "openai", + "gpt-4o-mini", + undefined, + undefined, + QUOTA_EXHAUSTED_BODY + ), + true + ); + assert.equal( + shouldMarkAccountExhaustedFrom429( + "anthropic", + "claude-sonnet-4-6", + undefined, + undefined, + QUOTA_EXHAUSTED_BODY + ), + true + ); +}); + +test("shouldMarkAccountExhaustedFrom429 still ignores a plain apikey rate limit", () => { + // Neither body matches QUOTA_PATTERNS, so a plain 429 must keep falling through to the + // short generic cooldown instead of poisoning the connection's quota cache. + assert.equal( + shouldMarkAccountExhaustedFrom429( + "openai", + "gpt-4o-mini", + undefined, + undefined, + "Rate limit exceeded, retry in 20s" + ), + false + ); + assert.equal( + shouldMarkAccountExhaustedFrom429( + "openai", + "gpt-4o-mini", + undefined, + undefined, + "Too Many Requests" + ), + false + ); +}); + +test("shouldMarkAccountExhaustedFrom429 keeps its pre-existing behavior when no errorText is supplied", () => { + // The new parameter is optional and additive: OAuth-category providers still preserve + // quota signals unconditionally, and apikey-category ones still default to "not + // exhausted" without an explicit body signal. + assert.equal(shouldMarkAccountExhaustedFrom429("claude", "claude-sonnet-4-6"), true); + assert.equal(shouldMarkAccountExhaustedFrom429("openai", "gpt-4o-mini"), false); +}); + +test("shouldMarkAccountExhaustedFrom429 lets a transient failureKind win over a quota body", () => { + // The failureKind short-circuit runs before the body-text check and must stay that way: + // a 429 the classifier already called transient never poisons the quota cache. + assert.equal( + shouldMarkAccountExhaustedFrom429( + "openai", + "gpt-4o-mini", + undefined, + "rate_limit", + QUOTA_EXHAUSTED_BODY + ), + false + ); + assert.equal( + shouldMarkAccountExhaustedFrom429( + "openai", + "gpt-4o-mini", + undefined, + "transient", + QUOTA_EXHAUSTED_BODY + ), + false + ); +}); + +/** + * The cases above pin the helper. This one pins the WIRING, and it is the reason the + * fix does anything in production. + * + * `errorText` is an OPTIONAL 5th parameter, so dropping it at the call site is neither a + * type error nor a helper-test failure — exactly the shape of the bug being fixed (a + * two-argument helper whose call site silently passes one). Without this case the + * production half of the patch could be reverted, or lost in a refactor, with the whole + * suite green. + * + * `handleSingleModelChat` is not exported from `src/sse/handlers/chat.ts`, so the call + * cannot be driven or spied without changing the production surface. A source-level + * assertion is the precedent for that situation in this suite — see + * `tests/unit/api-key-provider-quota-bypass-scope.test.ts`. Parse the argument list + * rather than regex-matching the formatted text, so Prettier reflowing the call cannot + * turn this guard into a false failure (or, worse, a false pass). + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +/** Top-level (paren/bracket/brace-depth 0) comma split of one argument list. */ +function splitTopLevelArgs(argList: string): string[] { + const args: string[] = []; + let depth = 0; + let current = ""; + for (const ch of argList) { + if (ch === "(" || ch === "[" || ch === "{") depth++; + else if (ch === ")" || ch === "]" || ch === "}") depth--; + if (ch === "," && depth === 0) { + args.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim().length > 0) args.push(current.trim()); + return args; +} + +/** Every `fn(...)` call in `source`, returned as its list of top-level arguments. */ +function callSiteArgs(source: string, fn: string): string[][] { + const calls: string[][] = []; + const needle = `${fn}(`; + let from = 0; + for (;;) { + const start = source.indexOf(needle, from); + if (start === -1) break; + from = start + needle.length; + // Skip the import/declaration forms — only real invocations carry arguments. + const before = source.slice(Math.max(0, start - 9), start); + if (/\bfunction\s+$/.test(before)) continue; + let depth = 1; + let i = from; + while (i < source.length && depth > 0) { + const ch = source[i]; + if (ch === "(") depth++; + else if (ch === ")") depth--; + i++; + } + calls.push(splitTopLevelArgs(source.slice(from, i - 1))); + } + return calls; +} + +test("chat.ts forwards the upstream body as the 5th argument to shouldMarkAccountExhaustedFrom429", () => { + const source = fs.readFileSync(path.join(repoRoot, "src/sse/handlers/chat.ts"), "utf8"); + const calls = callSiteArgs(source, "shouldMarkAccountExhaustedFrom429").filter( + // Drop the `import { … }` specifier, which parses as a zero-argument "call". + (args) => args.length > 0 + ); + + assert.equal( + calls.length, + 1, + "expected exactly one shouldMarkAccountExhaustedFrom429 call site in chat.ts; " + + "a new one must forward errorText too" + ); + assert.deepEqual(calls[0], ["provider", "model", "passthroughModels", "failureKind", "errorStr"]); + + // Pin what `errorStr` is, so the guard cannot pass on a same-named local that no longer + // holds the upstream body (chat.ts:2282). + assert.match(source, /const errorStr = String\(result\.rawMessage \?\? result\.error \?\? ""\);/); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); From db022768df0ebd605671a8ac345b21f3417e2632 Mon Sep 17 00:00:00 2001 From: Tobias Andersen Date: Sat, 19 Sep 2026 05:03:25 +0200 Subject: [PATCH 13/33] fix: FriendliAI 403 credit exhaustion misclassified as auth_error (#13040) * Add new error signal for depleted credits * chore: add changelog fragment for #13040 FriendliAI credit-exhaustion 403 fix * test(accountFallback): add FriendliAI credit-exhaustion 403 body test (#13040) --------- Co-authored-by: fesshompa --- .../13040-friendliai-credit-exhaustion-403.md | 1 + open-sse/services/accountFallback.ts | 2 ++ tests/unit/account-fallback-service.test.ts | 16 ++++++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 changelog.d/fixes/13040-friendliai-credit-exhaustion-403.md diff --git a/changelog.d/fixes/13040-friendliai-credit-exhaustion-403.md b/changelog.d/fixes/13040-friendliai-credit-exhaustion-403.md new file mode 100644 index 0000000000..2fbcd9d0c7 --- /dev/null +++ b/changelog.d/fixes/13040-friendliai-credit-exhaustion-403.md @@ -0,0 +1 @@ +- **fix(friendliai):** FriendliAI's free-tier credit-exhaustion 403 (`{"detail":"You've exhausted all your credits..."}`) is now classified as `QUOTA_EXHAUSTED` instead of `AUTH_ERROR`, so omniroute treats it as depleted credits rather than a credential problem ([#13040](https://github.com/diegosouzapw/OmniRoute/pull/13040)) — thanks @turbolego diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index b09308b0b0..bedd556a52 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -260,6 +260,8 @@ export const CREDITS_EXHAUSTED_SIGNALS = [ // marked credits_exhausted and keeps being re-selected on every request. "insufficient credits", "insufficient credit", + // FriendliAI 403 when free tier credits are depleted via adaptive rate limits + "exhausted all your credits", ]; // T11: Signals that indicate OAuth token is invalid/expired (not permanent deactivation) diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index d2693a5acf..0a7d5856fb 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -1196,6 +1196,22 @@ test("isCreditsExhausted returns true for actual credits-exhausted signals", () ); }); +test("isCreditsExhausted matches FriendliAI credit-exhaustion 403 body (#13040)", () => { + // FriendliAI returns HTTP 403 with body {"detail":"You've exhausted all your + // credits..."} when free tier credits are depleted via Adaptive Rate Limits. + // Before #13040 this fell through every quota/credits check to the generic + // 403 -> AUTH_ERROR fallback; the signal below routes it to QUOTA_EXHAUSTED. + assert.equal(isCreditsExhausted("You've exhausted all your credits"), true); + assert.equal( + isCreditsExhausted('{"detail":"You\'ve exhausted all your credits"}'), + true + ); + assert.equal( + isCreditsExhausted("exhausted all your credits"), + true + ); +}); + test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => { // These patterns were removed because they falsely matched Gemini RPM 429 errors assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("resource has been exhausted"), false); From 8288a4a0128da7c3ae145eb3512e3105d61d230a Mon Sep 17 00:00:00 2001 From: VictorRP7 Date: Sat, 19 Sep 2026 00:03:34 -0300 Subject: [PATCH 14/33] =?UTF-8?q?fix(sse):=20deepseek-web=20resilience=20?= =?UTF-8?q?=E2=80=94=20premature=20session=20close=20+=20malformed=20tool-?= =?UTF-8?q?call=20recovery=20(#13226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sse): deepseek-web collectSSEContent no longer returns a silent partial stub on premature session close collectSSEContent() (used for the deepseek-web tool-calling / non-stream path) drained the upstream SSE body and returned whatever content it had once the reader reported done, with no check that DeepSeek had actually signalled completion via response/status: "FINISHED". When the upstream cookie session drops mid-generation (expired session, anti-bot challenge, network interruption), the HTTP body simply closes early. That was indistinguishable from a real completion: execute() returned HTTP 200 with finish_reason "stop" and whatever partial stub text had arrived so far. Observed in production call logs: a lone "I'll check that..." / "Vou verificar..." with no continuation, reported as a successful completion. collectSSEContent now tracks whether the FINISHED status event was seen. If the stream ends without it, it throws instead of returning the stub - execute()'s existing try/catch turns that into a proper 502 that the client, or a combo's retry/fallback logic, can react to. Added tests/unit/deepseek-web-premature-close.test.ts covering both the premature-close error path and the normal FINISHED completion path. Full deepseek-web unit suite (97 tests) still passes. * fix(sse): recover malformed deepseek-web tool-call replies and retry when unrecoverable Two related failure modes on the deepseek-web tool-calling path, both observed in production call logs from real agentic (VS Code Copilot-style) usage of the deepseek combo: 1. DeepSeek's web session occasionally leaks malformed/internal formatting tokens right after an otherwise-complete {json} body, instead of a clean close (observed: a fully valid create_file JSON call immediately followed by corrupted pseudo-tags). parseLooseJsonObject's strict JSON.parse rejected the whole block over that trailing garbage, even though a perfectly valid object sat at the start - so the call was silently dropped and the raw tagged text was shown to the user instead of the file being created. deepseekWebTools.ts: added salvageLeadingJsonObject(), a quote/escape aware balanced-brace scanner that recovers just the leading JSON object when the strict parse fails, reusing the same salvage idea already used elsewhere in this file (findBareJsonCandidates) for bare-JSON detection. 2. When even that salvage cannot recover a call (genuinely truncated JSON, garbled beyond repair), execute() previously gave up on the first try. Since this is a scraped, non-deterministic web session rather than a real API, simply asking again is usually enough to get a clean reply. deepseek-web.ts: the hasTools branch now detects an unparsed tag surviving in the cleaned content and retries with a brand-new session, bounded to MAX_TOOL_PARSE_ATTEMPTS (2) - never an unbounded retry loop, and a reply that parses cleanly on the first try costs no extra latency. Builds on the collectSSEContent premature-close fix from the same PR - that one covers the upstream session dropping mid-stream; this one covers the session completing but returning malformed tool-call content. Testing: - tests/unit/deepseek-web-tools-salvage-leading-json.test.ts (4 tests): recovery from the exact production-observed corruption pattern, escaped quotes/nested braces before the garbage, correct non-promotion of genuinely truncated JSON, and no regression on well-formed blocks. - tests/unit/deepseek-web-tool-call-retry.test.ts (3 tests): retry succeeds on a fresh session, retry is bounded (gives up after MAX_TOOL_PARSE_ATTEMPTS and surfaces the raw content rather than looping forever), and a clean first reply never triggers a retry. - Full deepseek-web unit suite: 104/104 passing, no regressions. - The salvage fix was additionally verified directly against the exact malformed content captured from a live production call log (not just the hand-written test fixture). --------- Co-authored-by: VictorRP7 <187780317+VictorRP7@users.noreply.github.com> --- open-sse/executors/deepseek-web.ts | 68 ++++++- open-sse/translator/deepseekWebTools.ts | 57 +++++- .../unit/deepseek-web-premature-close.test.ts | 160 ++++++++++++++++ .../unit/deepseek-web-tool-call-retry.test.ts | 176 ++++++++++++++++++ ...eek-web-tools-salvage-leading-json.test.ts | 71 +++++++ 5 files changed, 524 insertions(+), 8 deletions(-) create mode 100644 tests/unit/deepseek-web-premature-close.test.ts create mode 100644 tests/unit/deepseek-web-tool-call-retry.test.ts create mode 100644 tests/unit/deepseek-web-tools-salvage-leading-json.test.ts diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 914024be82..da6ca56cee 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -377,6 +377,12 @@ async function collectSSEContent( let content = ""; let reasoningContent = ""; let currentPath: "thinking" | "content" | "" = ""; + // Track whether DeepSeek actually signalled completion (`response/status: "FINISHED"`). + // Without this, an upstream session drop (expired cookie, anti-bot challenge, network + // hiccup) mid-stream was silently reported as a normal "stop" completion with whatever + // partial content had arrived so far — e.g. just "I'll check that..." with no follow-up, + // HTTP 200, finish_reason "stop". Confirmed in production call logs. + let sawFinished = false; const streamModel = model || "deepseek-web"; const thinkingModel = isThinkingModel(streamModel); const searchResults: DeepSeekSearchResult[] = []; @@ -423,6 +429,8 @@ async function collectSSEContent( const p = data?.p; const v = data?.v; + if (p === "response/status" && v === "FINISHED") sawFinished = true; + if (v && typeof v === "object" && v.response) { if (v.response.thinking_enabled === true) currentPath = "thinking"; else if (v.response.thinking_enabled === false) currentPath = "content"; @@ -484,6 +492,18 @@ async function collectSSEContent( const citations = appendSearchCitations(searchResults, streamModel); if (citations) content += `\n\n${citations}`; + // The upstream HTTP body closed without ever sending `response/status: "FINISHED"`. + // That means the DeepSeek web session was cut off mid-generation (expired cookie, + // anti-bot challenge, network drop, etc.) rather than genuinely completing. Surface + // this as an error (caught by execute()'s try/catch -> 502) instead of returning the + // partial stub as a successful "stop" response. + if (!sawFinished) { + throw new Error( + "DeepSeek web session ended before completion (no FINISHED signal received) — " + + "likely a dropped cookie session or network interruption upstream. Retry the request." + ); + } + return { content, reasoningContent }; } @@ -1079,13 +1099,49 @@ export class DeepSeekWebExecutor extends BaseExecutor { // OpenAI tool_calls. Buffering (even for stream clients) is acceptable because // tool invocations are short and need the complete block to parse. (#2820) if (hasTools) { - const { content, reasoningContent } = await collectSSEContent(resp.body!, clientModel); + // The scraped web session occasionally returns a malformed reply where DeepSeek + // clearly attempted a tool call (a literal tag is present) but the block + // could not be parsed even with salvageLeadingJsonObject's recovery (genuinely + // truncated JSON, garbled beyond repair, etc). Unlike a real API, this upstream is + // non-deterministic enough that simply asking again with a fresh session usually + // succeeds — so retry a bounded number of times before giving up and surfacing the + // raw (still-tagged) text to the caller. + const MAX_TOOL_PARSE_ATTEMPTS = 2; + let content = ""; + let reasoningContent = ""; + let cleanedContent = ""; + let toolCalls: ReturnType["toolCalls"] = null; + + for (let attempt = 1; attempt <= MAX_TOOL_PARSE_ATTEMPTS; attempt += 1) { + ({ content, reasoningContent } = await collectSSEContent(resp.body!, clientModel)); + ({ content: cleanedContent, toolCalls } = parseDeepSeekToolCalls( + content, + `call-${Date.now()}`, + requestedTools + )); + + const unparsedToolTagRemains = + !toolCalls && /]/i.test(cleanedContent); + if (!unparsedToolTagRemains || attempt === MAX_TOOL_PARSE_ATTEMPTS) break; + + log?.warn?.( + "DEEPSEEK-WEB", + `Malformed tool-call reply on attempt ${attempt}/${MAX_TOOL_PARSE_ATTEMPTS} — retrying with a fresh session` + ); + if (persistSession) sessionCache.delete(userToken); + sessionId = await createSession(accessToken, signal); + if (persistSession) { + evictOldest(sessionCache); + sessionCache.set(userToken, { sessionId, createdAt: Date.now() }); + } + const retried = await performCompletion(sessionId); + resp = retried.resp; + reqHeaders = retried.reqHeaders; + requestPayload = retried.requestPayload; + if (!resp.ok) break; // fall through — final content/toolCalls stay from the last successful attempt + } + await cleanupFn(); - const { content: cleanedContent, toolCalls } = parseDeepSeekToolCalls( - content, - `call-${Date.now()}`, - requestedTools - ); return buildToolAwareResult({ stream: stream !== false, clientModel, diff --git a/open-sse/translator/deepseekWebTools.ts b/open-sse/translator/deepseekWebTools.ts index 3e2c7a1792..eb4b36f0ae 100644 --- a/open-sse/translator/deepseekWebTools.ts +++ b/open-sse/translator/deepseekWebTools.ts @@ -325,6 +325,47 @@ function buildSchemaParamMap(requestedTools: unknown): Map> return map; } +// DeepSeek's web session occasionally leaks malformed/internal formatting tokens right +// after an otherwise-complete JSON tool call body (observed in production: a valid +// `{"name": ..., "arguments": {...}}` object immediately followed by corrupted +// pseudo-tags instead of a clean `` close). `parseLooseJsonObject` uses a strict +// `JSON.parse`, which rejects the whole string over that trailing garbage even though a +// perfectly valid object sits right at the start. This scans for the first balanced +// `{...}` object (quote/escape aware) and returns just that slice, so it can still be +// parsed on its own. +function salvageLeadingJsonObject(text: string): string | null { + const start = text.indexOf("{"); + if (start === -1) return null; + let depth = 0; + let quote: '"' | "'" | "" = ""; + let escaped = false; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (escaped) { + escaped = false; + continue; + } + if (quote) { + if (ch === "\\") escaped = true; + else if (ch === quote) quote = ""; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch as '"' | "'"; + continue; + } + if (ch === "{") { + depth += 1; + continue; + } + if (ch === "}") { + depth -= 1; + if (depth === 0) return text.slice(start, i + 1); + } + } + return null; // never balanced — genuinely truncated, nothing to salvage +} + /** * Turn one tool block (tag name + inner text) into a name + JSON-string arguments. * Returns null when no plausible tool name can be recovered. @@ -342,7 +383,13 @@ function extractCall( const paramObj = argsChild ? null : buildArgsFromParameters(inner); const hasXmlChildren = !!nameChild || !!argsChild || !!paramObj; - const json = hasXmlChildren ? null : parseLooseJsonObject(inner); + let json = hasXmlChildren ? null : parseLooseJsonObject(inner); + if (!json && !hasXmlChildren) { + // Strict parse failed — try salvaging a complete JSON object from the start of the + // block even if trailing content after it is malformed (see salvageLeadingJsonObject). + const salvaged = salvageLeadingJsonObject(inner); + if (salvaged) json = parseLooseJsonObject(salvaged); + } const jsonName = json ? (asString(json.name) ?? asString(json.type)) : null; const childResolved = nameChild ? resolveRequestedToolName(nameChild, requested) : null; @@ -479,7 +526,13 @@ export function parseDeepSeekToolCalls( // A missing _nonce is tolerated for backward compatibility. if (nonce) { const parsed = parseLooseJsonObject(inner); - if (parsed && typeof parsed.name === "string" && parsed._nonce !== undefined && parsed._nonce !== nonce) continue; + if ( + parsed && + typeof parsed.name === "string" && + parsed._nonce !== undefined && + parsed._nonce !== nonce + ) + continue; } toolCalls.push({ diff --git a/tests/unit/deepseek-web-premature-close.test.ts b/tests/unit/deepseek-web-premature-close.test.ts new file mode 100644 index 0000000000..c93d56c051 --- /dev/null +++ b/tests/unit/deepseek-web-premature-close.test.ts @@ -0,0 +1,160 @@ +// @ts-nocheck +// deepseek-web's non-stream/tool-call path (collectSSEContent) drains the upstream SSE +// body and returns whatever content it collected once the reader reports `done` — with +// no check that DeepSeek actually signalled completion via `response/status: "FINISHED"`. +// When the upstream cookie session drops mid-generation (expired session, anti-bot +// challenge, network interruption), the HTTP body simply closes early. Before this fix, +// that premature close was indistinguishable from a real completion: execute() returned +// HTTP 200 with `finish_reason: "stop"` and whatever partial stub text had arrived so far +// (observed in production: a lone "I'll check that..." with no continuation). The caller +// has no way to know the task was never actually finished, so it looks like the model just +// stopped mid-task. +// +// Fix: collectSSEContent now tracks whether the FINISHED status event was seen. If the +// stream ends without it, it throws instead of returning the stub — execute()'s existing +// try/catch turns that into a proper 502 the client (or a combo's retry/fallback logic) +// can react to. +import test from "node:test"; +import assert from "node:assert/strict"; + +const dsMod = await import("../../open-sse/executors/deepseek-web.ts"); +const { DeepSeekWebExecutor } = dsMod; + +const POW_CHALLENGE = { + algorithm: "DeepSeekHashV1", + challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286", + salt: "1122334455667788", + signature: "sig123", + difficulty: 1, + expire_at: 1778891543095, + expire_after: 300000, + target_path: "/api/v0/chat/completion", +}; + +// Same shape as a real completion, but the upstream body closes right after the partial +// text fragment — no `response/status: "FINISHED"` line ever arrives. This is what a +// dropped cookie session / anti-bot cutoff / network interruption looks like on the wire. +function sseWithPrematureClose(text) { + return [ + "event: ready\n", + 'data: {"request_message_id":1,"response_message_id":2}\n', + "\n", + `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`, + "\n", + // (no response/status FINISHED event, no close event — body just ends here) + ].join(""); +} + +function sseWithFinished(text) { + return [ + "event: ready\n", + 'data: {"request_message_id":1,"response_message_id":2}\n', + "\n", + `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`, + "\n", + 'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n', + "\n", + "event: close\n", + 'data: {"click_behavior":"none"}\n', + ].join(""); +} + +function installMock(sseBody) { + const original = globalThis.fetch; + dsMod.tokenCache?.clear(); + dsMod.sessionCache?.clear(); + globalThis.fetch = async (url, _opts = {}) => { + const u = String(url); + if (u.includes("/users/current")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat_session/create")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { chat_session: { id: "s-1" } } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat_session/delete")) + return new Response(JSON.stringify({ code: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + if (u.includes("/create_pow_challenge")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat/completion")) { + return new Response(new TextEncoder().encode(sseBody), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response("not found", { status: 404 }); + }; + return { + restore: () => { + globalThis.fetch = original; + dsMod.tokenCache?.clear(); + dsMod.sessionCache?.clear(); + }, + }; +} + +const TOOLS = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, +]; + +test("execute (tools[], non-stream) returns an error instead of a silent partial stub when the upstream session drops before FINISHED", async () => { + const mock = installMock(sseWithPrematureClose("I'll check the weather for you...")); + try { + const executor = new DeepSeekWebExecutor(); + const result = await executor.execute({ + model: "default", + body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS }, + stream: false, + credentials: { apiKey: "tkn-premature-close" }, + signal: AbortSignal.timeout(10000), + }); + assert.equal( + result.response.status, + 502, + "a session that closes before FINISHED must surface as an error, not HTTP 200" + ); + const body = await result.response.text(); + assert.ok( + /finished|premature|dropped|retry/i.test(body), + "error message should explain the session ended before completion" + ); + } finally { + mock.restore(); + } +}); + +test("execute (tools[], non-stream) still succeeds normally when FINISHED is received", async () => { + const mock = installMock(sseWithFinished("Just a normal answer, no tool needed.")); + try { + const executor = new DeepSeekWebExecutor(); + const result = await executor.execute({ + model: "default", + body: { messages: [{ role: "user", content: "hi" }], tools: TOOLS }, + stream: false, + credentials: { apiKey: "tkn-normal-finish" }, + signal: AbortSignal.timeout(10000), + }); + assert.ok(result.response.ok); + const json = JSON.parse(await result.response.text()); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.ok(json.choices[0].message.content.includes("normal answer")); + } finally { + mock.restore(); + } +}); diff --git a/tests/unit/deepseek-web-tool-call-retry.test.ts b/tests/unit/deepseek-web-tool-call-retry.test.ts new file mode 100644 index 0000000000..52b6afbd94 --- /dev/null +++ b/tests/unit/deepseek-web-tool-call-retry.test.ts @@ -0,0 +1,176 @@ +// @ts-nocheck +// When DeepSeek's web session returns a reply where a `` tag is present but the +// block is genuinely unparseable (even after salvageLeadingJsonObject's recovery — e.g. the +// JSON itself is truncated), execute() now retries with a brand-new session (bounded to +// MAX_TOOL_PARSE_ATTEMPTS) before giving up. This is the scraped-web-session equivalent of +// retrying a flaky upstream call, since unlike a real API this provider is non-deterministic +// enough that asking again usually just works. +import test from "node:test"; +import assert from "node:assert/strict"; + +const dsMod = await import("../../open-sse/executors/deepseek-web.ts"); +const { DeepSeekWebExecutor } = dsMod; + +const POW_CHALLENGE = { + algorithm: "DeepSeekHashV1", + challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286", + salt: "1122334455667788", + signature: "sig123", + difficulty: 1, + expire_at: 1778891543095, + expire_after: 300000, + target_path: "/api/v0/chat/completion", +}; + +function sseWithContent(text) { + return [ + "event: ready\n", + 'data: {"request_message_id":1,"response_message_id":2}\n', + "\n", + `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`, + "\n", + 'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n', + "\n", + "event: close\n", + 'data: {"click_behavior":"none"}\n', + ].join(""); +} + +// installMock returns replies from `replies` in order, one per /chat/completion call — so +// the Nth upstream request (including retries) gets `replies[N-1]`. +function installMock(replies) { + const original = globalThis.fetch; + const calls = { completions: 0, sessionCreates: 0 }; + dsMod.tokenCache?.clear(); + dsMod.sessionCache?.clear(); + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("/users/current")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat_session/create")) { + calls.sessionCreates += 1; + return new Response( + JSON.stringify({ + code: 0, + data: { biz_data: { chat_session: { id: `s-${calls.sessionCreates}` } } }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (u.includes("/chat_session/delete")) + return new Response(JSON.stringify({ code: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + if (u.includes("/create_pow_challenge")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat/completion")) { + const text = replies[Math.min(calls.completions, replies.length - 1)]; + calls.completions += 1; + return new Response(new TextEncoder().encode(sseWithContent(text)), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response("not found", { status: 404 }); + }; + return { + calls, + restore: () => { + globalThis.fetch = original; + dsMod.tokenCache?.clear(); + dsMod.sessionCache?.clear(); + }, + }; +} + +const TOOLS = [ + { + type: "function", + function: { + name: "get_weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, +]; + +// Genuinely truncated — no balanced closing brace, so even salvageLeadingJsonObject cannot +// recover it. This is what a reply the retry must fix looks like. +const TRUNCATED = '{"name": "get_weather", "arguments": {"city": "Pa'; +const GOOD_REPLY = '{"name": "get_weather", "arguments": {"city": "Paris"}}'; + +test("retries with a fresh session when the first reply's tool block is unparseable, and succeeds on the second attempt", async () => { + const mock = installMock([TRUNCATED, GOOD_REPLY]); + try { + const executor = new DeepSeekWebExecutor(); + const result = await executor.execute({ + model: "default", + body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS }, + stream: false, + credentials: { apiKey: "tkn-retry-success" }, + signal: AbortSignal.timeout(10000), + }); + assert.ok(result.response.ok); + const json = JSON.parse(await result.response.text()); + const choice = json.choices[0]; + assert.equal(choice.finish_reason, "tool_calls", "second attempt's valid reply must win"); + assert.equal(choice.message.tool_calls[0].function.name, "get_weather"); + assert.equal(mock.calls.completions, 2, "exactly one retry (2 completions total)"); + assert.equal(mock.calls.sessionCreates, 2, "retry uses a brand-new session, not the stale one"); + } finally { + mock.restore(); + } +}); + +test("gives up after MAX_TOOL_PARSE_ATTEMPTS and returns the raw (still-tagged) content, not an infinite retry", async () => { + const mock = installMock([TRUNCATED, TRUNCATED, TRUNCATED]); + try { + const executor = new DeepSeekWebExecutor(); + const result = await executor.execute({ + model: "default", + body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS }, + stream: false, + credentials: { apiKey: "tkn-retry-exhausted" }, + signal: AbortSignal.timeout(10000), + }); + assert.ok(result.response.ok, "still HTTP 200 — a best-effort text answer, not a hard failure"); + const json = JSON.parse(await result.response.text()); + const choice = json.choices[0]; + assert.equal(choice.finish_reason, "stop"); + assert.ok(!choice.message.tool_calls, "no tool_calls on an unrecoverable reply"); + assert.ok( + choice.message.content.includes(""), + "raw unparsed content is surfaced, not silently dropped" + ); + assert.equal(mock.calls.completions, 2, "bounded to MAX_TOOL_PARSE_ATTEMPTS (2), never more"); + } finally { + mock.restore(); + } +}); + +test("does not retry at all when the first reply parses cleanly (no wasted latency)", async () => { + const mock = installMock([GOOD_REPLY, GOOD_REPLY, GOOD_REPLY]); + try { + const executor = new DeepSeekWebExecutor(); + const result = await executor.execute({ + model: "default", + body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS }, + stream: false, + credentials: { apiKey: "tkn-no-retry-needed" }, + signal: AbortSignal.timeout(10000), + }); + assert.ok(result.response.ok); + const json = JSON.parse(await result.response.text()); + assert.equal(json.choices[0].finish_reason, "tool_calls"); + assert.equal(mock.calls.completions, 1, "a clean first reply must not trigger any retry"); + assert.equal(mock.calls.sessionCreates, 1); + } finally { + mock.restore(); + } +}); diff --git a/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts b/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts new file mode 100644 index 0000000000..d53597f498 --- /dev/null +++ b/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts @@ -0,0 +1,71 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts"; + +// DeepSeek's web session occasionally leaks malformed/internal formatting tokens right after +// an otherwise-complete `{json}` body, instead of a clean `` close. The strict +// `JSON.parse` inside `parseLooseJsonObject` rejects the whole block over that trailing +// garbage even though a perfectly valid object sits at the start. `salvageLeadingJsonObject` +// recovers it by scanning for the first balanced `{...}` (quote/escape aware) and parsing +// just that slice. + +const TOOLS = [ + { + type: "function", + function: { + name: "create_file", + parameters: { + type: "object", + properties: { filePath: { type: "string" }, content: { type: "string" } }, + }, + }, + }, +]; + +describe("deepseekWebTools — salvage leading JSON on malformed close", () => { + test("recovers a valid {json} block whose closing tag was replaced by garbled tokens", () => { + // Reproduces production content observed from the deepseek-web provider: valid JSON + // immediately followed by corrupted pseudo-tags instead of ``. + const text = + 'Let me create that file.\n\n{"name": "create_file", "arguments": ' + + '{"filePath":"C:\\\\Users\\\\me\\\\script.mjs","content":"console.log(1)"}}' + + "<||DSML|| parameter>\n\n" + + "This response is AI-generated, for reference only."; + + const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.ok(toolCalls && toolCalls.length === 1, "expected the malformed block to be recovered"); + assert.equal(toolCalls![0].function.name, "create_file"); + const args = JSON.parse(toolCalls![0].function.arguments); + assert.equal(args.filePath, "C:\\Users\\me\\script.mjs"); + assert.equal(args.content, "console.log(1)"); + }); + + test("recovers a valid block even with escaped quotes and nested braces before the garbage", () => { + const text = + '{"name": "create_file", "arguments": {"filePath":"a.txt",' + + '"content":"line one\\nline \\"two\\" {not json}"}}' + + "<||DSML|| calls>trailing junk that is not valid JSON at all {{{"; + + const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.ok(toolCalls && toolCalls.length === 1); + const args = JSON.parse(toolCalls![0].function.arguments); + assert.equal(args.content, 'line one\nline "two" {not json}'); + }); + + test("still returns null (no promotion) when the JSON itself is genuinely truncated", () => { + // No balanced closing brace anywhere — nothing to salvage, must not be promoted. + const text = '{"name": "create_file", "arguments": {"filePath":"a.txt able to nev'; + const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls, null, "a truly truncated object must not be salvaged into a call"); + assert.equal(content, text, "unrecovered content is returned unchanged"); + }); + + test("normal, well-formed {json} blocks are unaffected (no regression)", () => { + const text = + '{"name": "create_file", "arguments": {"filePath":"a.txt","content":"x"}}'; + const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls?.length, 1); + assert.equal(toolCalls![0].function.name, "create_file"); + assert.ok(!content.includes(""), "well-formed block is still stripped from content"); + }); +}); From 7663aadea967921c468d1537a186aa4062ff6400 Mon Sep 17 00:00:00 2001 From: Tuan Dinh Date: Sat, 19 Sep 2026 10:03:42 +0700 Subject: [PATCH 15/33] feat(models): add Gemini 3.8 Flash tiers to Antigravity and AGY catalogs (#13318) * feat(models): add Gemini 3.8 Flash tiers to Antigravity and AGY catalogs * fix(antigravity): handle Gemini 3.8 Flash thought signatures, native tool calls, and output token limits * fix: remove duplicate Gemini 3.8 model specs * feat(models): adopt CLI catalog, pricing, and version fallbacks from #12499 (#13318) * test(models): assert Gemini 3.8 Flash catalog and pricing presence (#13318) * fix(antigravity): Gemini 3.8 Flash tiers have no shared -tiered endpoint Live testing against Google's Cloud Code upstream (daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent), documented in #12499, shows Gemini 3.8 Flash is served directly at gemini-3.8-flash-high/-medium/-low. Unlike 3.7, there is no gemini-3.8-flash-tiered endpoint for 3.8. This branch mapped the bare id and all three tiers to an invented gemini-3.8-flash-tiered upstream target and declared that model in the shared Antigravity/AGY catalog. Remove the invented catalog entry, alias only the bare "gemini-3.8-flash" display id to its default tier (gemini-3.8-flash-high), and let -high/-medium/-low pass through verbatim to match what the live endpoint actually serves. Also drops the now-dead managedModelImport.ts mitm-alias branch that forced the same invented -tiered target, and updates the catalog/alias tests accordingly. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * revert: drop CLI catalog/pricing/version-fallback adoption also shipped by #12499 Commit be65c761 ("adopt CLI catalog, pricing, and version fallbacks from #12499") pulled the Antigravity IDE/CLI fallback-version bump, the CLI_TOOLS gemini-3.8-flash-* aliases/defaults, the oauth-subscriptions pricing entries, and the matching antigravity-version.test.ts assertions straight from PR #12499's own diff. #12499 ships all of that content itself and lands first in the three-way reconciliation with #12499/#13318/#14018, so keeping a second copy here would duplicate and collide with it on merge. Revert exactly those four files to their pre-be65c761 state; #12499 is now the sole source for this content. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(models): align provider-models-route with the corrected 3.8 Flash catalog Registering gemini-3.8-flash-high in ANTIGRAVITY_PUBLIC_MODELS (needed for the corrected direct-tier routing) makes getClientVisibleAntigravityModelName() prefer our static catalog name "Gemini 3.8 Flash (High)" over the upstream discovery echo's own displayName, the same interaction #12499 hit and fixed for its own copy of this catalog entry. Update the pre-existing discovery retry test's expectation to match, mirroring #12499's fix to the same assertion. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../features/13318-gemini-3.8-flash.md | 1 + open-sse/config/antigravityModelAliases.ts | 10 +- open-sse/config/antigravitySharedModels.ts | 29 ++++++ open-sse/executors/antigravity.ts | 17 ++-- open-sse/executors/antigravity/sseCollect.ts | 2 +- open-sse/handlers/sseParser/geminiResponse.ts | 30 +++++- .../translator/request/openai-to-gemini.ts | 2 +- src/shared/constants/modelSpecs.ts | 95 +++++++++---------- tests/unit/antigravity-model-aliases.test.ts | 4 + ...ntigravity-native-toolcall-collect.test.ts | 24 +++++ .../antigravity-retired-public-models.test.ts | 3 + tests/unit/provider-models-route.test.ts | 2 +- tests/unit/sse-parser.test.ts | 57 +++++++++++ .../unit/translator-openai-to-gemini.test.ts | 28 +++++- 14 files changed, 240 insertions(+), 64 deletions(-) create mode 100644 changelog.d/features/13318-gemini-3.8-flash.md diff --git a/changelog.d/features/13318-gemini-3.8-flash.md b/changelog.d/features/13318-gemini-3.8-flash.md new file mode 100644 index 0000000000..8a0d95531a --- /dev/null +++ b/changelog.d/features/13318-gemini-3.8-flash.md @@ -0,0 +1 @@ +- **feat(models):** add Gemini 3.8 Flash tiers to Antigravity and AGY catalogs ([#13318](https://github.com/diegosouzapw/OmniRoute/pull/13318)) — thanks @tuandinh0801, with credit to #12499 (@Abhishekchhetri020) diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index d29c8e481f..428ff71892 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -1,7 +1,4 @@ -import { - ANTIGRAVITY_SHARED_MODELS, - buildSurfaceCatalog, -} from "./antigravitySharedModels.ts"; +import { ANTIGRAVITY_SHARED_MODELS, buildSurfaceCatalog } from "./antigravitySharedModels.ts"; export const ANTIGRAVITY_PUBLIC_MODELS = buildSurfaceCatalog(ANTIGRAVITY_SHARED_MODELS, { add: [], // IDE-only models (currently none) @@ -15,6 +12,11 @@ export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ "gemini-3.7-flash-high": "gemini-3.7-flash-tiered", "gemini-3.7-flash-medium": "gemini-3.7-flash-tiered", "gemini-3.7-flash-low": "gemini-3.7-flash-tiered", + // Gemini 3.8 Flash tiers are served DIRECTLY by the live Cloud Code upstream + // (v1internal:streamGenerateContent) at their own tier ids — unlike 3.7, there is no + // shared "-tiered" endpoint for 3.8. -high/-medium/-low are accepted verbatim; only + // the bare display id needs a default-tier alias. + "gemini-3.8-flash": "gemini-3.8-flash-high", "gpt-oss-120b": "gpt-oss-120b-medium", // gemini-3.1-pro-low is not aliased: the upstream accepts it verbatim. // gemini-3.1-pro-high: the discovery slot returns HTTP 400 on v1internal; diff --git a/open-sse/config/antigravitySharedModels.ts b/open-sse/config/antigravitySharedModels.ts index 185472ca8b..fe8658a580 100644 --- a/open-sse/config/antigravitySharedModels.ts +++ b/open-sse/config/antigravitySharedModels.ts @@ -43,6 +43,35 @@ export const ANTIGRAVITY_SHARED_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, + // Gemini 3.8 Flash tiers. Served directly at these ids by the live upstream — no + // shared "-tiered" endpoint exists for 3.8 (unlike 3.7). + { + id: "gemini-3.8-flash-high", + name: "Gemini 3.8 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.8-flash-medium", + name: "Gemini 3.8 Flash (Medium)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.8-flash-low", + name: "Gemini 3.8 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, // Gemini 3.1 Pro budget tiers. Live streamGenerateContent validation uses // `gemini-pro-agent` for High; the separately advertised `gemini-3.1-pro-high` // discovery slot currently returns HTTP 400 and is intentionally not public. diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 9582af9fe6..01a315ef80 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -13,10 +13,7 @@ import { getAntigravityOAuthUserAgent, } from "../services/antigravityHeaders.ts"; import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts"; -import { - parseRetryFromErrorText, - type RetryHintProvenance, -} from "../services/accountFallback.ts"; +import { parseRetryFromErrorText, type RetryHintProvenance } from "../services/accountFallback.ts"; import { parseDetailedRetryHintFromJsonBody } from "../services/retryAfterJson.ts"; import { shouldRetryWithCredits, @@ -331,7 +328,8 @@ function applyAntigravityGenerationDefaults( if ( Number.isFinite(thinkingBudget) && thinkingBudget > 0 && - (!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget) + Number.isFinite(maxOutputTokens) && + maxOutputTokens <= thinkingBudget ) { generationConfig.maxOutputTokens = Math.floor(thinkingBudget) + 1; } @@ -379,7 +377,9 @@ const COMPETITIVE_AGENT_PROMPT_PATTERNS: RegExp[] = [ */ export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknown { const record = asRecord(systemInstruction); - const parts = Array.isArray(record?.parts) ? (record.parts as Array>) : []; + const parts = Array.isArray(record?.parts) + ? (record.parts as Array>) + : []; if (parts.length === 0) return systemInstruction; let changed = false; @@ -387,7 +387,10 @@ export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknow if (typeof part.text !== "string" || part.text.length === 0) return part; let text = part.text; for (const pattern of COMPETITIVE_AGENT_PROMPT_PATTERNS) { - const stripped = text.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimStart(); + const stripped = text + .replace(pattern, "") + .replace(/\n{3,}/g, "\n\n") + .trimStart(); if (stripped !== text) { changed = true; text = stripped; diff --git a/open-sse/executors/antigravity/sseCollect.ts b/open-sse/executors/antigravity/sseCollect.ts index ee4de7c11b..5e35890ba3 100644 --- a/open-sse/executors/antigravity/sseCollect.ts +++ b/open-sse/executors/antigravity/sseCollect.ts @@ -113,7 +113,7 @@ export function processAntigravitySSEPayload( collected.finishReason = "tool_calls"; continue; } - if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) { + if (typeof part.text === "string" && !part.thought) { const textualToolCall = parseAntigravityTextualToolCall(part.text); if (textualToolCall) { addAntigravityTextualToolCall(collected, textualToolCall); diff --git a/open-sse/handlers/sseParser/geminiResponse.ts b/open-sse/handlers/sseParser/geminiResponse.ts index df18008c04..c4440ddcd8 100644 --- a/open-sse/handlers/sseParser/geminiResponse.ts +++ b/open-sse/handlers/sseParser/geminiResponse.ts @@ -22,6 +22,16 @@ type GeminiSSEAccumulator = { function stripZeroWidth(value: unknown): unknown { if (typeof value === "string") return stripObfuscationZeroWidth(value); + if (value && typeof value === "object") { + if (Array.isArray(value)) { + return value.map(stripZeroWidth); + } + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = stripZeroWidth(v); + } + return out; + } return value; } @@ -54,7 +64,25 @@ function extractGeminiMarkdownShortcut(parsed: Record): string /** Append one candidate content part (text or textual tool call) onto the accumulator. */ function applyCandidatePart(part: Record, acc: GeminiSSEAccumulator): void { - if (typeof part.text !== "string" || part.thought || part.thoughtSignature) return; + // Native function calls (Gemini 3.x / Antigravity) + const fc = part.functionCall as Record | undefined; + if (fc && typeof fc.name === "string") { + acc.toolCalls.push({ + id: + typeof fc.id === "string" && fc.id.length > 0 + ? fc.id + : `${fc.name}-${Date.now()}-${acc.toolCalls.length}`, + index: acc.toolCalls.length, + type: "function", + function: { + name: fc.name, + arguments: JSON.stringify(stripZeroWidth(fc.args ?? {})), + }, + }); + return; + } + + if (typeof part.text !== "string" || part.thought === true) return; const textualToolCall = tryParseTextualToolCall(part.text); if (textualToolCall) { diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index a70100964b..1120dfba69 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -829,7 +829,7 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu const hasThinking = !!envelope.request?.generationConfig?.thinkingConfig?.thinkingBudget; if ( clientRequestedMaxTokens === undefined && - !hasThinking && + !(isClaude && hasThinking) && envelope.request?.generationConfig ) { delete envelope.request.generationConfig.maxOutputTokens; diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 57c19428f6..0498d98842 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -197,54 +197,6 @@ export const MODEL_SPECS: Record = { supportsTools: true, supportsVision: true, }, - // Output limit published at https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash. - // Thinking budgets follow the 3.7 Flash high/medium/low/tiered split. - "gemini-3.8-flash-high": { - maxOutputTokens: 65536, - contextWindow: 1048576, - defaultThinkingBudget: 24576, - thinkingBudgetCap: 24576, - supportsThinking: true, - supportsTools: true, - supportsVision: true, - }, - "gemini-3.8-flash-medium": { - maxOutputTokens: 65536, - contextWindow: 1048576, - defaultThinkingBudget: 8192, - thinkingBudgetCap: 24576, - supportsThinking: true, - supportsTools: true, - supportsVision: true, - }, - "gemini-3.8-flash-low": { - maxOutputTokens: 65536, - contextWindow: 1048576, - defaultThinkingBudget: 1024, - thinkingBudgetCap: 24576, - supportsThinking: true, - supportsTools: true, - supportsVision: true, - }, - "gemini-3.8-flash": { - maxOutputTokens: 65536, - contextWindow: 1048576, - defaultThinkingBudget: 8192, - thinkingBudgetCap: 24576, - supportsThinking: true, - supportsTools: true, - supportsVision: true, - aliases: ["gemini-3.8-flash-tiered"], - }, - "gemini-3.8-flash-tiered": { - maxOutputTokens: 65536, - contextWindow: 1048576, - defaultThinkingBudget: 8192, - thinkingBudgetCap: 24576, - supportsThinking: true, - supportsTools: true, - supportsVision: true, - }, // Gemini 3.7 Flash tiers: high 24.5k, medium 8k, low 1k thinking tokens. "gemini-3.7-flash-high": { @@ -293,6 +245,53 @@ export const MODEL_SPECS: Record = { supportsTools: true, supportsVision: true, }, + // ── Gemini 3.8 Flash (current Antigravity/AGY live tiers) ───────── + "gemini-3.8-flash-high": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 24576, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.8-flash-medium": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 8192, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.8-flash-low": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 1024, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, + "gemini-3.8-flash": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 8192, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + aliases: ["gemini-3.8-flash-tiered"], + }, + "gemini-3.8-flash-tiered": { + maxOutputTokens: 65536, + contextWindow: 1048576, + defaultThinkingBudget: 8192, + thinkingBudgetCap: 24576, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + }, // Provider-neutral compatibility for providers that still serve Gemini 3.6. // Antigravity/AGY availability is governed by their own provider catalogs and diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 5f0ab5c0b4..9fdc2cfa1b 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -62,6 +62,10 @@ test("resolveAntigravityModelId maps the documented Antigravity aliases to upstr } assert.equal(resolveAntigravityModelId("gemini-3.7-flash"), "gemini-3.7-flash-tiered"); assert.equal(resolveAntigravityModelId("gemini-3.7-flash-tiered"), "gemini-3.7-flash-tiered"); + assert.equal(resolveAntigravityModelId("gemini-3.8-flash"), "gemini-3.8-flash-high"); + assert.equal(resolveAntigravityModelId("gemini-3.8-flash-high"), "gemini-3.8-flash-high"); + assert.equal(resolveAntigravityModelId("gemini-3.8-flash-medium"), "gemini-3.8-flash-medium"); + assert.equal(resolveAntigravityModelId("gemini-3.8-flash-low"), "gemini-3.8-flash-low"); assert.equal(resolveAntigravityModelId("gpt-oss-120b"), "gpt-oss-120b-medium"); assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5"), "claude-sonnet-4-6"); assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5-thinking"), "claude-sonnet-4-6"); diff --git a/tests/unit/antigravity-native-toolcall-collect.test.ts b/tests/unit/antigravity-native-toolcall-collect.test.ts index a9c5bc8cc8..3be6203200 100644 --- a/tests/unit/antigravity-native-toolcall-collect.test.ts +++ b/tests/unit/antigravity-native-toolcall-collect.test.ts @@ -118,3 +118,27 @@ test("processAntigravitySSEPayload ignores a malformed functionCall without a na assert.equal(collected.toolCalls.length, 0); assert.equal(collected.textContent, ""); }); + +test("processAntigravitySSEPayload collects text carrying thoughtSignature", () => { + const collected = emptyCollected(); + processAntigravitySSEPayload( + JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { text: "internal reasoning", thought: true }, + { text: "visible reply after tool execution", thoughtSignature: "sig-tool-res" }, + ], + }, + finishReason: "STOP", + }, + ], + }, + }), + collected + ); + + assert.equal(collected.textContent, "visible reply after tool execution"); +}); diff --git a/tests/unit/antigravity-retired-public-models.test.ts b/tests/unit/antigravity-retired-public-models.test.ts index 55885e3f2e..a46215b54a 100644 --- a/tests/unit/antigravity-retired-public-models.test.ts +++ b/tests/unit/antigravity-retired-public-models.test.ts @@ -36,6 +36,9 @@ const EXPECTED_LEADING_MODEL_ORDER = [ "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.7-flash-tiered", + "gemini-3.8-flash-high", + "gemini-3.8-flash-medium", + "gemini-3.8-flash-low", "gemini-pro-agent", "gemini-3.1-pro-low", "gemini-3.1-flash-lite", diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index a8d6d2dd80..a8092fd0af 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -981,7 +981,7 @@ test("provider models route retries Antigravity discovery endpoints before retur { id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, - { id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash High" }, + { id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" }, ]); }); diff --git a/tests/unit/sse-parser.test.ts b/tests/unit/sse-parser.test.ts index 5c8bfe627a..b062e1baed 100644 --- a/tests/unit/sse-parser.test.ts +++ b/tests/unit/sse-parser.test.ts @@ -431,3 +431,60 @@ test("parseSSEToGeminiResponse ignores thought/thoughtSignature parts", () => { assert.ok(parsed); assert.equal(parsed.choices[0].message.content, "visible answer"); }); + +test("parseSSEToGeminiResponse preserves text that carries a thoughtSignature", () => { + const rawSSE = [ + `data: ${JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { text: "internal reasoning", thought: true }, + { text: "visible answer after thinking", thoughtSignature: "sig-xyz-123" }, + ], + }, + finishReason: "STOP", + }, + ], + }, + })}`, + ].join("\n"); + + const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-3.8-flash-tiered"); + + assert.ok(parsed); + assert.equal(parsed.choices[0].message.content, "visible answer after thinking"); +}); + +test("parseSSEToGeminiResponse extracts native functionCall parts carrying thoughtSignature", () => { + const rawSSE = [ + `data: ${JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "search_documentation", args: { query: "test" } }, + thoughtSignature: "sig-abc", + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + })}`, + ].join("\n"); + + const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-3.8-flash-tiered"); + + assert.ok(parsed); + assert.equal(parsed.choices[0].finish_reason, "tool_calls"); + assert.equal(parsed.choices[0].message.tool_calls?.length, 1); + assert.equal(parsed.choices[0].message.tool_calls[0].function.name, "search_documentation"); + assert.deepEqual(JSON.parse(parsed.choices[0].message.tool_calls[0].function.arguments), { + query: "test", + }); +}); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index cf44d1b798..c860a1acf0 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -866,7 +866,11 @@ test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schem assert.match(result.requestId, /^agent\/\d+\/[0-9a-f]{8}$/); assert.equal(result.enabledCreditTypes, undefined); assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM); - assert.equal(result.request.systemInstruction.parts.length, 1, "systemInstruction must contain only ANTIGRAVITY_DEFAULT_SYSTEM (#9030)"); + assert.equal( + result.request.systemInstruction.parts.length, + 1, + "systemInstruction must contain only ANTIGRAVITY_DEFAULT_SYSTEM (#9030)" + ); // #9030 — Client system content moved to first user message to avoid upstream 429s assert.equal(result.request.contents[0].parts[0].text, "Project rules"); assert.equal(result.request.contents[0].parts[1].text, "Read a file"); @@ -1026,6 +1030,28 @@ test("OpenAI -> Antigravity Gemini path preserves thinkingConfig (only Claude is assert.equal((result as any).request?.generationConfig.thinkingConfig.includeThoughts, true); }); +test("OpenAI -> Antigravity Gemini thinking models omit maxOutputTokens when max_tokens is undefined", () => { + const result = openaiToAntigravityRequest( + "gemini-3.8-flash-tiered", + { + messages: [{ role: "user", content: "Hello" }], + }, + false, + { projectId: "proj-gemini-thinking" } as unknown as Parameters< + typeof openaiToAntigravityRequest + >[3] + ) as Record; + + const envelopeRequest = result.request as Record | undefined; + const genConfig = envelopeRequest?.generationConfig as Record | undefined; + assert.ok(genConfig?.thinkingConfig, "expected thinkingConfig to be set"); + assert.equal( + genConfig.maxOutputTokens, + undefined, + "maxOutputTokens must be undefined when not requested" + ); +}); + // Regression for #2480: when projectId is stored in providerSpecificData rather than at // the top level of the credential record, the Antigravity Cloud Code envelope must still // pick it up — otherwise the /v1beta path 422s with "Missing Google projectId". From c43fbb1b3d7c8c81ff2d016e8eb919e906608575 Mon Sep 17 00:00:00 2001 From: Themedexperiencesusa Date: Fri, 18 Sep 2026 20:03:49 -0700 Subject: [PATCH 16/33] fix(security,resilience): block origin-IP header forwarding and treat 413 as retryable TPM (#13350) * fix(security): never forward origin-IP headers upstream Operator-set custom upstream headers could carry the client-origin IP (x-forwarded-for, x-real-ip, cf-connecting-ip, forwarded, via, ...) to the upstream provider, disclosing or spoofing it. Extend the FORBIDDEN denylist in upstreamHeaders.ts to cover the whole forwarding/IP set, mirroring the scrubbers already used by the Antigravity (antigravityHeaderScrub.ts) and Cursor CLI (cursorCliProxy.ts) paths, so the protection applies to every provider rather than those two. Covered by tests/unit/upstream-headers-sanitize.test.ts (6 passing). * fix(resilience): treat 413 payload-too-large as retryable TPM rate limit Providers with a tokens-per-minute cap (Groq among them) answer an oversized turn with 413 rather than 429. checkFallbackError() did not list 413 as retryable, so the request failed hard instead of falling back to another account or model. - Add PAYLOAD_TOO_LARGE (413) to HTTP_STATUS and to the retryable set - Return a MODEL_CAPACITY retryable fallback for 413 - Recognise "tokens per minute" / "tpm" as context-overflow patterns --------- Co-authored-by: Themedexperiencesusa <221764849+themedexperiencesusa@users.noreply.github.com> --- open-sse/config/constants.ts | 1 + open-sse/services/accountFallback.ts | 7 +++ src/shared/constants/upstreamHeaders.ts | 18 ++++++++ tests/unit/upstream-headers-sanitize.test.ts | 46 ++++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 82aeb0f3ba..d3d49d8eaa 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -187,6 +187,7 @@ export const HTTP_STATUS = { UNPROCESSABLE_ENTITY: 422, REQUEST_TIMEOUT: 408, GONE: 410, + PAYLOAD_TOO_LARGE: 413, RATE_LIMITED: 429, PLAN_LIMIT_EXCEEDED: 432, SERVER_ERROR: 500, diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index bedd556a52..db348e84bb 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -344,6 +344,8 @@ export const CONTEXT_OVERFLOW_PATTERNS = [ /\bmax.*token/i, /\btoken limit/i, /\brequest too large\b/i, + /\btokens per minute\b/i, + /\btpm\b/i, ]; // Structured error codes that reliably indicate model access denied @@ -1741,6 +1743,7 @@ export function checkFallbackError( const retryableStatuses = new Set([ HTTP_STATUS.REQUEST_TIMEOUT, HTTP_STATUS.RATE_LIMITED, + HTTP_STATUS.PAYLOAD_TOO_LARGE, HTTP_STATUS.SERVER_ERROR, HTTP_STATUS.BAD_GATEWAY, HTTP_STATUS.SERVICE_UNAVAILABLE, @@ -2208,6 +2211,10 @@ export function checkFallbackError( } if (status === HTTP_STATUS.NOT_ACCEPTABLE || retryableStatuses.has(status)) { + // 413 PAYLOAD_TOO_LARGE (TPM rate limits) should trigger fallback + if (status === HTTP_STATUS.PAYLOAD_TOO_LARGE) { + return buildRetryableFallback(RateLimitReason.MODEL_CAPACITY); + } return buildRetryableFallback(RateLimitReason.SERVER_ERROR); } diff --git a/src/shared/constants/upstreamHeaders.ts b/src/shared/constants/upstreamHeaders.ts index 5d9d7f7f08..fcc3f03d18 100644 --- a/src/shared/constants/upstreamHeaders.ts +++ b/src/shared/constants/upstreamHeaders.ts @@ -2,6 +2,12 @@ * User-supplied upstream extra headers: names we never forward (Host / hop-by-hop / framing). * Changing this list requires syncing: `sanitizeUpstreamHeadersMap` (models.ts), Zod * `upstreamHeaderNameSchema` / record refine (schemas.ts), and `upstream-headers-sanitize` tests. + * + * The forwarding/IP set (x-forwarded-for, x-real-ip, cf-connecting-ip, forwarded, via, …) + * is forbidden so the client-origin IP can never be disclosed (or spoofed) to the upstream + * provider through an operator-set custom upstream header. This mirrors the established + * scrubbers/denylists already used by the Antigravity (`antigravityHeaderScrub.ts`) and + * Cursor CLI (`cursorCliProxy.ts`) paths, extended here to cover every provider. */ const FORBIDDEN = new Set( [ @@ -24,6 +30,18 @@ const FORBIDDEN = new Set( "te", "trailer", "upgrade", + // Origin-IP disclosure: never send the client's forwarding headers upstream. + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-forwarded-port", + "x-forwarded-server", + "x-real-ip", + "cf-connecting-ip", + "true-client-ip", + "client-ip", + "forwarded", + "via", ].map((s) => s.toLowerCase()) ); diff --git a/tests/unit/upstream-headers-sanitize.test.ts b/tests/unit/upstream-headers-sanitize.test.ts index b9814e2d7d..935f4d5f7d 100644 --- a/tests/unit/upstream-headers-sanitize.test.ts +++ b/tests/unit/upstream-headers-sanitize.test.ts @@ -1,6 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { sanitizeUpstreamHeadersMap } from "../../src/lib/db/models.ts"; +import { + isForbiddenUpstreamHeaderName, + isForbiddenCustomHeaderName, +} from "../../src/shared/constants/upstreamHeaders.ts"; test("sanitizeUpstreamHeadersMap: drops hop-by-hop / Host names", () => { const out = sanitizeUpstreamHeadersMap({ @@ -12,6 +16,48 @@ test("sanitizeUpstreamHeadersMap: drops hop-by-hop / Host names", () => { assert.deepEqual(out, { "X-Custom": "ok" }); }); +test("sanitizeUpstreamHeadersMap: drops origin-IP forwarding headers (no origin IP leak upstream)", () => { + const out = sanitizeUpstreamHeadersMap({ + "X-Custom": "kept", + "X-Forwarded-For": "203.0.113.9", + "X-Real-IP": "203.0.113.9", + "CF-Connecting-IP": "203.0.113.9", + Forwarded: "for=203.0.113.9", + Via: "1.1 proxy", + "True-Client-IP": "203.0.113.9", + "X-Forwarded-Host": "origin.example.com", + "X-Forwarded-Proto": "https", + }); + assert.deepEqual(out, { "X-Custom": "kept" }); +}); + +test("isForbiddenUpstreamHeaderName: blocks origin-IP forwarding headers", () => { + for (const name of [ + "x-forwarded-for", + "x-real-ip", + "cf-connecting-ip", + "forwarded", + "via", + "true-client-ip", + "client-ip", + "X-Forwarded-For", + "X-Real-IP", + "CF-Connecting-IP", + ]) { + assert.equal(isForbiddenUpstreamHeaderName(name), true, `${name} must be forbidden upstream`); + } + assert.equal(isForbiddenUpstreamHeaderName("x-custom-hdr"), false); +}); + +test("isForbiddenCustomHeaderName: blocks origin-IP forwarding headers for operator custom headers", () => { + assert.equal(isForbiddenCustomHeaderName("x-forwarded-for"), true); + assert.equal(isForbiddenCustomHeaderName("x-real-ip"), true); + assert.equal(isForbiddenCustomHeaderName("cf-connecting-ip"), true); + assert.equal(isForbiddenCustomHeaderName("forwarded"), true); + assert.equal(isForbiddenCustomHeaderName("via"), true); + assert.equal(isForbiddenCustomHeaderName("x-custom-hdr"), false); +}); + test("sanitizeUpstreamHeadersMap: drops values with CR/LF", () => { const out = sanitizeUpstreamHeadersMap({ Good: "a", From bf0f213cf008a02b046f6118e21a7471d431725f Mon Sep 17 00:00:00 2001 From: Eliel Sousa <108632163+elielsousa-pathbit@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:03:56 -0300 Subject: [PATCH 17/33] fix(oauth): parse numeric epoch expiry so the health check still refreshes tokens (#13444) * fix(oauth): parse numeric epoch expiry so the health check still refreshes tokens provider_connections.expires_at / token_expires_at are TEXT columns, so an epoch timestamp always reads back as a string. getEffectiveTokenExpiryMs() went straight to new Date(), which yields an Invalid Date for a numeric string, while the sibling getCopilotTokenExpiryMs() in the same file already handled both numeric shapes. Two ways this breaks checkConnection(): - NaN -> getEffectiveTokenExpiryMs() returns 0 -> hasKnownExpiry is false. For a ROTATING_REFRESH_PROVIDERS entry (codex, claude, kiro, openai, ...) shouldRefreshByInterval is false as well, so the guard at 'if (!isAboutToExpire && !shouldRefreshByInterval) return' skips the connection and it is never refreshed at all - the expiry-driven path the surrounding comment says it prefers is silently off. - An epoch-seconds *number* read as milliseconds lands in 1970, so isAboutToExpire is permanently true and every sweep refreshes the connection, burning single-use refresh-token rotations. Extracts the numeric/string handling that getCopilotTokenExpiryMs() already had into one exported parseTokenExpiryMs() and routes both call sites through it, so every connection is parsed the same way. Refs #12732 (base red at branch time). * docs(changelog): add fragment for #13444 --- .../fixes/13444-token-expiry-numeric-epoch.md | 1 + src/lib/tokenHealthCheck.ts | 50 ++++++++++++---- tests/unit/token-expiry-numeric-epoch.test.ts | 60 +++++++++++++++++++ 3 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fixes/13444-token-expiry-numeric-epoch.md create mode 100644 tests/unit/token-expiry-numeric-epoch.test.ts diff --git a/changelog.d/fixes/13444-token-expiry-numeric-epoch.md b/changelog.d/fixes/13444-token-expiry-numeric-epoch.md new file mode 100644 index 0000000000..773a95ab82 --- /dev/null +++ b/changelog.d/fixes/13444-token-expiry-numeric-epoch.md @@ -0,0 +1 @@ +- **fix(oauth):** token health check now parses a numeric epoch `expires_at` (number or string, seconds or milliseconds), so connections synced by external tools keep their expiry-driven refresh instead of being skipped forever — or refreshed on every sweep ([#13444](https://github.com/diegosouzapw/OmniRoute/pull/13444)) — thanks @elielsousa-pathbit diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 774ab27e8d..6d27bd44d4 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -65,29 +65,55 @@ export function extractResolvedProxyConfig(resolvedProxy: unknown) { return resolvedProxy ?? null; } +const NUMERIC_STRING = /^\d+(\.\d+)?$/; + +/** + * Normalize any stored token-expiry value to epoch milliseconds. + * + * `provider_connections.expires_at` / `token_expires_at` are TEXT columns, so a + * numeric epoch written by an external sync tool reads back as a *string* — + * and `new Date("1789012345678")` is an Invalid Date. Both numeric shapes are + * accepted here with the seconds/ms heuristic the Copilot path already used, + * before falling back to `Date` for ISO 8601 and other date strings. + * + * @returns epoch ms, or 0 when the value carries no usable time + */ +export function parseTokenExpiryMs(expiresAt: unknown): number { + if (typeof expiresAt === "number") { + if (!Number.isFinite(expiresAt) || expiresAt <= 0) return 0; + return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt; + } + + if (typeof expiresAt === "string") { + const trimmed = expiresAt.trim(); + if (!trimmed) return 0; + + if (NUMERIC_STRING.test(trimmed)) { + const numeric = Number(trimmed); + if (!Number.isFinite(numeric) || numeric <= 0) return 0; + return numeric < 1e12 ? numeric * 1000 : numeric; + } + + const parsed = new Date(trimmed).getTime(); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +} + function getEffectiveTokenExpiryIso(conn: any): string | null { if (!conn || typeof conn !== "object") return null; return conn.tokenExpiresAt || conn.expiresAt || null; } function getEffectiveTokenExpiryMs(conn: any): number { - const effectiveExpiry = getEffectiveTokenExpiryIso(conn); - if (!effectiveExpiry) return 0; - const expiryMs = new Date(effectiveExpiry).getTime(); - return Number.isFinite(expiryMs) ? expiryMs : 0; + return parseTokenExpiryMs(getEffectiveTokenExpiryIso(conn)); } const TOKEN_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes function getCopilotTokenExpiryMs(expiresAt: unknown): number { - if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { - return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt; - } - if (typeof expiresAt === "string" && expiresAt.trim()) { - const parsed = new Date(expiresAt).getTime(); - return Number.isFinite(parsed) ? parsed : 0; - } - return 0; + return parseTokenExpiryMs(expiresAt); } // Providers whose OAuth flow yields only a GitHub-style access token (no diff --git a/tests/unit/token-expiry-numeric-epoch.test.ts b/tests/unit/token-expiry-numeric-epoch.test.ts new file mode 100644 index 0000000000..1ece5f30ca --- /dev/null +++ b/tests/unit/token-expiry-numeric-epoch.test.ts @@ -0,0 +1,60 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// We import the exported helper directly. The module auto-starts the +// health-check timer on import, so we stop it immediately. +import { parseTokenExpiryMs, stopTokenHealthCheck } from "../../src/lib/tokenHealthCheck.ts"; + +stopTokenHealthCheck(); + +/** + * Regression guard: `provider_connections.expires_at` is a TEXT column, so an + * epoch timestamp always reads back as a string. `new Date("1789012345678")` + * is an Invalid Date, and an epoch-seconds *number* parsed as milliseconds + * lands in 1970 — both break the expiry-driven refresh in checkConnection(): + * + * - NaN -> getEffectiveTokenExpiryMs() returns 0 -> hasKnownExpiry false. + * For a ROTATING_REFRESH_PROVIDERS entry (codex, claude, kiro, openai, …) + * shouldRefreshByInterval is false too, so the connection is never + * refreshed at all. + * - 1970 -> isAboutToExpire is permanently true, so every sweep refreshes + * the connection, burning refresh-token rotations. + * + * The sibling Copilot helper already handled both shapes; this asserts the + * single shared parser does the same for every connection. + */ +describe("parseTokenExpiryMs", () => { + const MS = Date.parse("2026-09-12T12:00:00.000Z"); + const SECONDS = Math.floor(MS / 1000); + + it("parses epoch milliseconds as a number", () => { + assert.equal(parseTokenExpiryMs(MS), MS); + }); + + it("parses epoch seconds as a number", () => { + assert.equal(parseTokenExpiryMs(SECONDS), SECONDS * 1000); + }); + + it("parses epoch milliseconds given as a string", () => { + assert.equal(parseTokenExpiryMs(String(MS)), MS); + }); + + it("parses epoch seconds given as a string", () => { + assert.equal(parseTokenExpiryMs(String(SECONDS)), SECONDS * 1000); + }); + + it("parses an ISO 8601 string", () => { + assert.equal(parseTokenExpiryMs("2026-09-12T12:00:00.000Z"), MS); + }); + + it("returns 0 for values that carry no usable time", () => { + assert.equal(parseTokenExpiryMs(null), 0); + assert.equal(parseTokenExpiryMs(undefined), 0); + assert.equal(parseTokenExpiryMs(""), 0); + assert.equal(parseTokenExpiryMs(" "), 0); + assert.equal(parseTokenExpiryMs("not-a-date"), 0); + assert.equal(parseTokenExpiryMs(0), 0); + assert.equal(parseTokenExpiryMs(Number.NaN), 0); + assert.equal(parseTokenExpiryMs({}), 0); + }); +}); From d715190bb03da80da933d562129d8a76998eaa4d Mon Sep 17 00:00:00 2001 From: ggdayup Date: Sat, 19 Sep 2026 11:04:02 +0800 Subject: [PATCH 18/33] fix(runtime): eliminate hardcoded 20128 port remnants and make loopback URLs dynamic (#13533) * fix(runtime): eliminate hardcoded 20128 port remnants and make loopback URLs dynamic - Make model assessment probe base URL resolve dynamically from getRuntimePorts() / env - Support dynamic loopback in traffic inspector replay route and MITM handlers - Make WebSocket live server allowlist dynamically include runtime PORT/DASHBOARD_PORT loopback origins - Update CLI tools config/apply/letta-settings and tool-detector to adapt to configured runtime port - Update client UI components (EndpointPageClient, ApiExplorerClient, RelayProxyClient) to use current window origin or dynamic port - Make resolveOmniRouteBaseUrl, useDisplayBaseUrl, and wellKnown.ts respect configured port - Update package.json electron:dev wait-on to use ${PORT:-20128} - Add test coverage for custom port in resolveOmniRouteBaseUrl and liveServerAllowList - Add changelog fragment for PR #13533 * fix(runtime): complete the truncated fallback comment in wellKnown.ts Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../13533-dynamic-loopback-port-remnants.md | 1 + package.json | 2 +- .../dashboard/endpoint/EndpointPageClient.tsx | 4 +++- .../dashboard/relay/RelayProxyClient.tsx | 4 +++- .../dashboard/tools/agent-bridge/page.tsx | 3 ++- src/app/api/assess/route.ts | 20 ++++++++++++---- src/app/api/cli-tools/apply/route.ts | 8 ++++++- src/app/api/cli-tools/config/route.ts | 12 ++++++++-- src/app/api/cli-tools/letta-settings/route.ts | 23 ++++++++----------- .../api/playground/improve-prompt/route.ts | 2 +- src/app/api/sync/cloud/route.ts | 3 ++- .../requests/[id]/replay/route.ts | 11 +++++++-- src/app/docs/components/ApiExplorerClient.tsx | 4 +++- src/domain/assessment/assessor.ts | 4 +++- src/lib/cli-helper/log-streamer.ts | 7 +++++- src/lib/cli-helper/tool-detector.ts | 10 ++++++-- src/lib/wellKnown.ts | 3 ++- src/mitm/handlers/base.ts | 4 +++- src/mitm/server.cjs | 2 +- src/server/origin/publicOrigin.ts | 10 ++++---- src/server/ws/liveServerAllowList.ts | 12 +++++++++- src/shared/hooks/useDisplayBaseUrl.ts | 6 ++++- src/shared/utils/resolveOmniRouteBaseUrl.ts | 8 ++++++- tests/unit/resolve-omniroute-base-url.test.ts | 4 ++++ .../security/live-server-allowlist.test.ts | 17 ++++++++++++++ 25 files changed, 139 insertions(+), 45 deletions(-) create mode 100644 changelog.d/fixes/13533-dynamic-loopback-port-remnants.md diff --git a/changelog.d/fixes/13533-dynamic-loopback-port-remnants.md b/changelog.d/fixes/13533-dynamic-loopback-port-remnants.md new file mode 100644 index 0000000000..5ff47ceff6 --- /dev/null +++ b/changelog.d/fixes/13533-dynamic-loopback-port-remnants.md @@ -0,0 +1 @@ +- **fix(runtime):** eliminate hardcoded 20128 port remnants and make loopback URLs dynamic ([#13533](https://github.com/diegosouzapw/OmniRoute/pull/13533)) — thanks @ggdayup diff --git a/package.json b/package.json index 591c0f5b7d..f024801316 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "lint:json": "node scripts/quality/run-eslint-json.mjs", "lint:md": "npx --yes markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"!docs/i18n\" \"!docs/research\"", "lint:prose": "vale docs", - "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"", + "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:${PORT:-20128} && cd electron && npm run dev\"", "electron:build": "npm run build && cd electron && npm run build", "electron:build:win": "npm run build && cd electron && npm run build:win", "electron:build:mac": "npm run build && cd electron && npm run build:mac", diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index cd869f9f56..dc25ec008a 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -168,7 +168,9 @@ export default function APIPageClient({ machineId }: Readonly(null); - const [localApiUrl, setLocalApiUrl] = useState("http://localhost:20128/v1"); + const [localApiUrl, setLocalApiUrl] = useState( + typeof window !== "undefined" ? `${window.location.origin}/v1` : "http://localhost:20128/v1" + ); const [lanUrls, setLanUrls] = useState([]); const [tailscaleIpUrl, setTailscaleIpUrl] = useState(null); const [activeEndpointTab, setActiveEndpointTab] = useState("apis"); diff --git a/src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx b/src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx index 4ad9050e5f..5c5eab612c 100644 --- a/src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx +++ b/src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx @@ -6,6 +6,7 @@ import Card from "@/shared/components/Card"; import Badge from "@/shared/components/Badge"; import Button from "@/shared/components/Button"; import { useNotificationStore } from "@/store/notificationStore"; +import { useDisplayBaseUrl } from "@/shared/hooks"; interface RelayToken { id: string; @@ -23,6 +24,7 @@ interface RelayToken { export default function RelayProxyClient() { const t = useTranslations("relay"); + const displayBaseUrl = useDisplayBaseUrl(); const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); @@ -193,7 +195,7 @@ export default function RelayProxyClient() {

{t("usage")}

{t("usageDescription")}

-            {`curl http://localhost:20128/v1/relay/chat/completions \\
+            {`curl ${displayBaseUrl}/v1/relay/chat/completions \\
   -H "Authorization: Bearer relay_..." \\
   -H "Content-Type: application/json" \\
   -d '{"model":"claude-sonnet-4","messages":[{"role":"user","content":"Hello"}]}'`}
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
index e5decf84c5..dec2e60786 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
@@ -41,7 +41,8 @@ export default async function AgentBridgePage() {
   try {
     const base =
       process.env.OMNIROUTE_BASE_URL ??
-      `http://127.0.0.1:${process.env.PORT ?? 20128}`;
+      process.env.BASE_URL ??
+      `http://127.0.0.1:${process.env.DASHBOARD_PORT ?? process.env.PORT ?? 20128}`;
     const res = await fetch(`${base}/api/tools/agent-bridge/state`, {
       cache: "no-store",
       headers: { "x-internal-fetch": "1" },
diff --git a/src/app/api/assess/route.ts b/src/app/api/assess/route.ts
index a8c83bedd4..0dd9a7e366 100644
--- a/src/app/api/assess/route.ts
+++ b/src/app/api/assess/route.ts
@@ -11,9 +11,18 @@ import {
 import { validateBody } from "@/shared/validation/helpers";
 import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
 
+function getAssessBaseUrl(): string {
+  return (
+    process.env.OMNIROUTE_BASE_URL ??
+    process.env.OMNIROUTe_BASE_URL ??
+    process.env.BASE_URL ??
+    `http://localhost:${process.env.API_PORT ?? process.env.PORT ?? 20128}/v1`
+  );
+}
+
 const assessor = new Assessor(
-  process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? "",
-  process.env.OMNIROUTe_BASE_URL ?? "http://localhost:20128/v1"
+  process.env.OMNIROUTE_API_KEY ?? process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? "",
+  getAssessBaseUrl()
 );
 
 const categorizer = new Categorizer();
@@ -142,9 +151,12 @@ export async function GET(request: NextRequest) {
 
 async function getAllModels(): Promise> {
   try {
-    const resp = await fetch("http://localhost:20128/v1/models", {
+    const baseUrl = getAssessBaseUrl();
+    const apiKey =
+      process.env.OMNIROUTE_API_KEY ?? process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? "";
+    const resp = await fetch(`${baseUrl}/models`, {
       headers: {
-        Authorization: `Bearer ${process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? ""}`,
+        Authorization: `Bearer ${apiKey}`,
       },
     });
     const data = (await resp.json()) as { data?: unknown };
diff --git a/src/app/api/cli-tools/apply/route.ts b/src/app/api/cli-tools/apply/route.ts
index 303728d55d..5ef5fc0000 100644
--- a/src/app/api/cli-tools/apply/route.ts
+++ b/src/app/api/cli-tools/apply/route.ts
@@ -50,8 +50,14 @@ export async function POST(request: Request) {
     const { toolId, baseUrl, apiKey, model, dryRun } = parsed.data;
     const canonicalToolId = normalizeCliToolId(toolId);
 
+    const defaultPort = process.env.API_PORT || process.env.PORT || 20128;
+    const defaultBaseUrl =
+      process.env.OMNIROUTE_BASE_URL ||
+      process.env.BASE_URL ||
+      `http://localhost:${defaultPort}/v1`;
+
     const result = await generateConfig(canonicalToolId, {
-      baseUrl: baseUrl || "http://localhost:20128/v1",
+      baseUrl: baseUrl || defaultBaseUrl,
       apiKey,
       model,
     });
diff --git a/src/app/api/cli-tools/config/route.ts b/src/app/api/cli-tools/config/route.ts
index c986ca1db0..766b3374bd 100644
--- a/src/app/api/cli-tools/config/route.ts
+++ b/src/app/api/cli-tools/config/route.ts
@@ -16,7 +16,10 @@ export async function GET(request: Request) {
   if (authError) return authError;
 
   const { searchParams } = new URL(request.url);
-  const baseUrl = searchParams.get("baseUrl") || "http://localhost:20128/v1";
+  const defaultPort = process.env.API_PORT || process.env.PORT || 20128;
+  const defaultBaseUrl =
+    process.env.OMNIROUTE_BASE_URL || process.env.BASE_URL || `http://localhost:${defaultPort}/v1`;
+  const baseUrl = searchParams.get("baseUrl") || defaultBaseUrl;
   const apiKey = searchParams.get("apiKey") || "";
 
   if (!apiKey) {
@@ -46,9 +49,14 @@ export async function POST(request: Request) {
       );
     }
     const { toolId, baseUrl, apiKey, model } = parsed.data;
+    const defaultPort = process.env.API_PORT || process.env.PORT || 20128;
+    const defaultBaseUrl =
+      process.env.OMNIROUTE_BASE_URL ||
+      process.env.BASE_URL ||
+      `http://localhost:${defaultPort}/v1`;
 
     const result = await generateConfig(toolId, {
-      baseUrl: baseUrl || "http://localhost:20128/v1",
+      baseUrl: baseUrl || defaultBaseUrl,
       apiKey,
       model,
     });
diff --git a/src/app/api/cli-tools/letta-settings/route.ts b/src/app/api/cli-tools/letta-settings/route.ts
index f10f1a42a7..10410daca4 100644
--- a/src/app/api/cli-tools/letta-settings/route.ts
+++ b/src/app/api/cli-tools/letta-settings/route.ts
@@ -74,7 +74,13 @@ const readAuthFile = async () => {
 // ── Check if a base_url points to OmniRoute ──────────────────────────────
 const isOmniRouteUrl = (baseUrl) => {
   if (!baseUrl) return false;
-  return baseUrl.includes(":20128") || baseUrl.includes(":3000") || baseUrl.includes("omniroute");
+  const port = process.env.PORT || process.env.DASHBOARD_PORT;
+  return (
+    baseUrl.includes(":20128") ||
+    baseUrl.includes(":3000") ||
+    (!!port && baseUrl.includes(`:${port}`)) ||
+    baseUrl.includes("omniroute")
+  );
 };
 
 // ── Check if OmniRoute is configured ─────────────────────────────────────
@@ -122,10 +128,7 @@ export async function GET(request: Request) {
       backendMode: settings.preferredBackendMode || "api",
     });
   } catch (error) {
-    return NextResponse.json(
-      { error: { message: sanitizeErrorMessage(error) } },
-      { status: 500 }
-    );
+    return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 });
   }
 }
 
@@ -246,10 +249,7 @@ export async function POST(request: Request) {
       needsRestart: true,
     });
   } catch (error) {
-    return NextResponse.json(
-      { error: { message: sanitizeErrorMessage(error) } },
-      { status: 500 }
-    );
+    return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 });
   }
 }
 
@@ -321,9 +321,6 @@ export async function DELETE(request: Request) {
       needsRestart: true,
     });
   } catch (error) {
-    return NextResponse.json(
-      { error: { message: sanitizeErrorMessage(error) } },
-      { status: 500 }
-    );
+    return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 });
   }
 }
diff --git a/src/app/api/playground/improve-prompt/route.ts b/src/app/api/playground/improve-prompt/route.ts
index daa1c9dc4c..21a2d6fe00 100644
--- a/src/app/api/playground/improve-prompt/route.ts
+++ b/src/app/api/playground/improve-prompt/route.ts
@@ -76,7 +76,7 @@ export async function POST(request: Request): Promise {
   const chatBody = buildImproveChatBody(body);
 
   // 5. Call /v1/chat/completions on ourselves (D8)
-  const port = process.env.PORT ?? "20128";
+  const port = process.env.API_PORT ?? process.env.PORT ?? "20128";
   const baseUrl = process.env.OMNIROUTE_BASE_URL ?? `http://127.0.0.1:${port}`;
   const upstreamUrl = `${baseUrl}/v1/chat/completions`;
 
diff --git a/src/app/api/sync/cloud/route.ts b/src/app/api/sync/cloud/route.ts
index 44890fc510..a198a661ce 100644
--- a/src/app/api/sync/cloud/route.ts
+++ b/src/app/api/sync/cloud/route.ts
@@ -230,7 +230,8 @@ async function handleDisable(machineId: string, request: any) {
   }
 
   // Update Claude CLI settings to use local endpoint
-  const host = request.headers.get("host") || "localhost:20128";
+  const defaultPort = process.env.PORT || process.env.DASHBOARD_PORT || "20128";
+  const host = request.headers.get("host") || `localhost:${defaultPort}`;
   await updateClaudeSettingsToLocal(machineId, host);
 
   return NextResponse.json({
diff --git a/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts b/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts
index 1b5253e3b3..0eb0fee492 100644
--- a/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts
+++ b/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts
@@ -15,7 +15,14 @@ interface Params {
   params: Promise<{ id: string }>;
 }
 
-const OMNIROUTE_BASE = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128";
+function getOmnirouteBaseUrl(): string {
+  const port = process.env.API_PORT || process.env.PORT || 20128;
+  return (
+    process.env.OMNIROUTE_BASE_URL ||
+    process.env.BASE_URL ||
+    `http://127.0.0.1:${port}`
+  ).replace(/\/+$/, "");
+}
 
 export async function POST(_request: Request, { params }: Params): Promise {
   const { id } = await params;
@@ -27,7 +34,7 @@ export async function POST(_request: Request, { params }: Params): Promise = {
     "content-type": "application/json",
diff --git a/src/app/docs/components/ApiExplorerClient.tsx b/src/app/docs/components/ApiExplorerClient.tsx
index e1e25144d3..4ba113e0db 100644
--- a/src/app/docs/components/ApiExplorerClient.tsx
+++ b/src/app/docs/components/ApiExplorerClient.tsx
@@ -92,7 +92,9 @@ export function ApiExplorerClient() {
   const t = useTranslations("docs");
   const te = useTranslations("endpoint");
   const [selected, setSelected] = useState(null);
-  const [baseUrl, setBaseUrl] = useState("http://localhost:20128");
+  const [baseUrl, setBaseUrl] = useState(
+    typeof window !== "undefined" ? window.location.origin : "http://localhost:20128"
+  );
   const [apiKey, setApiKey] = useState("");
   const [requestBody, setRequestBody] = useState("");
   const [response, setResponse] = useState(null);
diff --git a/src/domain/assessment/assessor.ts b/src/domain/assessment/assessor.ts
index 0358f57007..163b005cc5 100644
--- a/src/domain/assessment/assessor.ts
+++ b/src/domain/assessment/assessor.ts
@@ -34,7 +34,9 @@ export class Assessor {
 
   constructor(
     apiKey: string,
-    baseUrl: string = "http://localhost:20128/v1",
+    baseUrl: string = process.env.OMNIROUTE_BASE_URL ??
+      process.env.BASE_URL ??
+      `http://localhost:${process.env.API_PORT ?? process.env.PORT ?? 20128}/v1`,
     config: Partial = {}
   ) {
     this.apiKey = apiKey;
diff --git a/src/lib/cli-helper/log-streamer.ts b/src/lib/cli-helper/log-streamer.ts
index 06dbffbb2d..2890b4a959 100644
--- a/src/lib/cli-helper/log-streamer.ts
+++ b/src/lib/cli-helper/log-streamer.ts
@@ -12,7 +12,12 @@ export interface LogStream {
 }
 
 export function createLogStream(options: LogStreamOptions = {}): LogStream {
-  const baseUrl = options.baseUrl || "http://localhost:20128";
+  const port = process.env.PORT || process.env.DASHBOARD_PORT || 20128;
+  const baseUrl =
+    options.baseUrl ||
+    process.env.OMNIROUTE_BASE_URL ||
+    process.env.BASE_URL ||
+    `http://localhost:${port}`;
   const filters = options.filters || [];
   const follow = options.follow ?? false;
   const timeout = options.timeout || 30000;
diff --git a/src/lib/cli-helper/tool-detector.ts b/src/lib/cli-helper/tool-detector.ts
index 38a2b5d289..01b90a07a7 100644
--- a/src/lib/cli-helper/tool-detector.ts
+++ b/src/lib/cli-helper/tool-detector.ts
@@ -77,9 +77,11 @@ function expandHome(p: string): string {
 
 function isConfigured(content: string, baseUrl: string): boolean {
   const normalized = baseUrl.replace(/\/+$/, "");
+  const port = process.env.PORT || process.env.DASHBOARD_PORT;
   return (
     content.includes(normalized) ||
     content.includes("localhost:20128") ||
+    (!!port && content.includes(`localhost:${port}`)) ||
     content.includes("OMNIROUTE_BASE_URL")
   );
 }
@@ -170,7 +172,9 @@ export async function detectTool(id: string): Promise {
       : getCliPrimaryConfigPath(tool.id) ||
         (tool.id === "opencode" ? resolveOpencodeConfigPath() : "");
   const configContents = await readConfigFile(configPath);
-  const configured = !!configContents && isConfigured(configContents, "http://localhost:20128");
+  const defaultPort = process.env.PORT || process.env.DASHBOARD_PORT || 20128;
+  const configured =
+    !!configContents && isConfigured(configContents, `http://localhost:${defaultPort}`);
 
   const result: DetectedTool = {
     id: canonicalId,
@@ -187,12 +191,14 @@ export async function detectTool(id: string): Promise {
     try {
       const roles = await getCurrentHermesAgentRoles();
       const richRoles: Record = {};
+      const currentPort = String(process.env.PORT || process.env.DASHBOARD_PORT || 20128);
 
       Object.entries(roles).forEach(([role, info]) => {
         const usingOmni =
           info?.provider === "omniroute" ||
           (info?.base_url || "").includes("20128") ||
-          (info?.base_url || "").includes("localhost:20128");
+          (info?.base_url || "").includes(currentPort) ||
+          (info?.base_url || "").includes("localhost");
 
         richRoles[role] = {
           model: info.model,
diff --git a/src/lib/wellKnown.ts b/src/lib/wellKnown.ts
index 7a6f2a35bf..341d639888 100644
--- a/src/lib/wellKnown.ts
+++ b/src/lib/wellKnown.ts
@@ -10,5 +10,6 @@ export function getBaseUrl(request?: NextRequest | null): string {
   if (process.env.OMNIROUTE_BASE_URL) return process.env.OMNIROUTE_BASE_URL;
   // Direct route-handler invocation (unit tests, programmatic calls) passes no
   // Request — fall back to the default local gateway origin instead of crashing.
-  return request?.nextUrl?.origin ?? "http://localhost:20128";
+  const defaultPort = process.env.PORT || process.env.DASHBOARD_PORT || 20128;
+  return request?.nextUrl?.origin ?? `http://localhost:${defaultPort}`;
 }
diff --git a/src/mitm/handlers/base.ts b/src/mitm/handlers/base.ts
index 18b4a86dfe..943e1c38b6 100644
--- a/src/mitm/handlers/base.ts
+++ b/src/mitm/handlers/base.ts
@@ -179,7 +179,9 @@ export abstract class MitmHandlerBase {
     path: string,
     headers: IncomingHttpHeaders,
   ): Promise {
-    const base = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128";
+    const port = process.env.API_PORT || process.env.PORT || 20128;
+    const base =
+      process.env.OMNIROUTE_BASE_URL ?? process.env.BASE_URL ?? `http://127.0.0.1:${port}`;
     const url = `${base.replace(/\/+$/, "")}${path}`;
     const apiKey = process.env.ROUTER_API_KEY ?? "";
 
diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs
index da0d6bba3e..7a6a61cb82 100644
--- a/src/mitm/server.cjs
+++ b/src/mitm/server.cjs
@@ -42,7 +42,7 @@ const MITM_IDLE_TIMEOUT_MS =
 const ROUTER_BASE_URL = (
   process.env.OMNIROUTE_BASE_URL ||
   process.env.BASE_URL ||
-  "http://localhost:20128"
+  `http://localhost:${process.env.API_PORT || process.env.PORT || 20128}`
 )
   .trim()
   .replace(/\/+$/, "");
diff --git a/src/server/origin/publicOrigin.ts b/src/server/origin/publicOrigin.ts
index c925b8e166..9cc6e78f21 100644
--- a/src/server/origin/publicOrigin.ts
+++ b/src/server/origin/publicOrigin.ts
@@ -3,10 +3,7 @@ import { PEER_IP_HEADER } from "@/server/authz/headers";
 import { resolveStampedPeer } from "@/server/authz/peerStamp";
 
 export type PublicOriginSource =
-  | "configured"
-  | "trusted-forwarded"
-  | "request-url"
-  | "direct-local-host";
+  "configured" | "trusted-forwarded" | "request-url" | "direct-local-host";
 
 export interface PublicOriginCandidate {
   origin: string;
@@ -200,7 +197,7 @@ function directLocalHostOrigin(request: Request): string | null {
   if (classifyHostLocality(peer) === "remote") return null;
 
   const rawHost = trustsForwardedHeaders(request)
-    ? firstHeaderValue(request.headers.get("x-forwarded-host")) ?? request.headers.get("host")
+    ? (firstHeaderValue(request.headers.get("x-forwarded-host")) ?? request.headers.get("host"))
     : request.headers.get("host");
   const host = sanitizeForwardedHost(rawHost);
   if (!host) return null;
@@ -246,7 +243,8 @@ export function resolvePublicOrigin(request: Request): PublicOriginCandidate {
   const requestOrigin = requestUrlOrigin(request);
   if (requestOrigin) return { origin: requestOrigin, source: "request-url" };
 
-  return { origin: "http://localhost:20128", source: "request-url" };
+  const defaultPort = process.env.PORT || process.env.DASHBOARD_PORT || "20128";
+  return { origin: `http://localhost:${defaultPort}`, source: "request-url" };
 }
 
 export function validateBrowserMutationOrigin(request: Request): BrowserMutationOriginVerdict {
diff --git a/src/server/ws/liveServerAllowList.ts b/src/server/ws/liveServerAllowList.ts
index 1f3101f054..c11e09550b 100644
--- a/src/server/ws/liveServerAllowList.ts
+++ b/src/server/ws/liveServerAllowList.ts
@@ -45,7 +45,17 @@ export function parseCsvEnv(value: string | undefined | null): Set {
  */
 export function buildAllowedOrigins(env: NodeJS.ProcessEnv = process.env): Set {
   const extra = parseCsvEnv(env.LIVE_WS_ALLOWED_ORIGINS);
-  return new Set([...DEFAULT_ALLOWED_ORIGINS, ...extra]);
+  const runtimePort = env.PORT || env.DASHBOARD_PORT;
+  const dynamicDefaults: string[] = [];
+  if (runtimePort && runtimePort !== "20128") {
+    dynamicDefaults.push(
+      `http://127.0.0.1:${runtimePort}`,
+      `http://localhost:${runtimePort}`,
+      `http://[::1]:${runtimePort}`,
+      `http://0.0.0.0:${runtimePort}`
+    );
+  }
+  return new Set([...DEFAULT_ALLOWED_ORIGINS, ...dynamicDefaults, ...extra]);
 }
 
 /**
diff --git a/src/shared/hooks/useDisplayBaseUrl.ts b/src/shared/hooks/useDisplayBaseUrl.ts
index c58e2344f9..3984aa487e 100644
--- a/src/shared/hooks/useDisplayBaseUrl.ts
+++ b/src/shared/hooks/useDisplayBaseUrl.ts
@@ -209,7 +209,11 @@ export function resolveDisplayBaseUrl(
     return joinOriginAndBasePath(configuredUrl, basePath);
   }
 
-  const fallback = currentOrigin ?? configuredUrl ?? DEFAULT_DISPLAY_BASE_URL;
+  const portFallback =
+    typeof process !== "undefined" && (process.env.NEXT_PUBLIC_PORT || process.env.PORT)
+      ? `http://localhost:${process.env.NEXT_PUBLIC_PORT || process.env.PORT}`
+      : DEFAULT_DISPLAY_BASE_URL;
+  const fallback = currentOrigin ?? configuredUrl ?? portFallback;
   return joinOriginAndBasePath(fallback, basePath);
 }
 
diff --git a/src/shared/utils/resolveOmniRouteBaseUrl.ts b/src/shared/utils/resolveOmniRouteBaseUrl.ts
index 3f4c18f33b..45fa245792 100644
--- a/src/shared/utils/resolveOmniRouteBaseUrl.ts
+++ b/src/shared/utils/resolveOmniRouteBaseUrl.ts
@@ -4,6 +4,9 @@ type OmniRouteBaseUrlEnv = {
   OMNIROUTE_BASE_URL?: string;
   BASE_URL?: string;
   NEXT_PUBLIC_BASE_URL?: string;
+  PORT?: string | number;
+  API_PORT?: string | number;
+  DASHBOARD_PORT?: string | number;
 };
 
 function normalizeBaseUrl(value?: string): string | null {
@@ -13,11 +16,14 @@ function normalizeBaseUrl(value?: string): string | null {
 }
 
 export function resolveOmniRouteBaseUrl(env: OmniRouteBaseUrlEnv = process.env): string {
+  const port = env.PORT || env.API_PORT || env.DASHBOARD_PORT;
+  const fallback = port ? `http://localhost:${port}` : DEFAULT_OMNIROUTE_BASE_URL;
+
   return (
     normalizeBaseUrl(env.OMNIROUTE_BASE_URL) ||
     normalizeBaseUrl(env.BASE_URL) ||
     normalizeBaseUrl(env.NEXT_PUBLIC_BASE_URL) ||
-    DEFAULT_OMNIROUTE_BASE_URL
+    fallback
   );
 }
 
diff --git a/tests/unit/resolve-omniroute-base-url.test.ts b/tests/unit/resolve-omniroute-base-url.test.ts
index 3e629fe1e6..a8a76e9889 100644
--- a/tests/unit/resolve-omniroute-base-url.test.ts
+++ b/tests/unit/resolve-omniroute-base-url.test.ts
@@ -50,3 +50,7 @@ test("resolveOmniRouteBaseUrl ignores blank values", () => {
 test("resolveOmniRouteBaseUrl uses the default localhost fallback", () => {
   assert.equal(resolveOmniRouteBaseUrl({}), DEFAULT_OMNIROUTE_BASE_URL);
 });
+
+test("resolveOmniRouteBaseUrl uses custom port when PORT env is set", () => {
+  assert.equal(resolveOmniRouteBaseUrl({ PORT: 37128 }), "http://localhost:37128");
+});
diff --git a/tests/unit/security/live-server-allowlist.test.ts b/tests/unit/security/live-server-allowlist.test.ts
index fbd4fd9be8..041c784797 100644
--- a/tests/unit/security/live-server-allowlist.test.ts
+++ b/tests/unit/security/live-server-allowlist.test.ts
@@ -66,6 +66,18 @@ describe("buildAllowedOrigins", () => {
     // Defaults remain.
     assert.equal(out.has("http://localhost:20128"), true);
   });
+
+  it("includes dynamic loopback origins when custom PORT is configured", () => {
+    const env = {
+      ...EMPTY_ENV,
+      PORT: "37128",
+    };
+    const out = buildAllowedOrigins(env);
+    assert.equal(out.has("http://localhost:37128"), true);
+    assert.equal(out.has("http://127.0.0.1:37128"), true);
+    assert.equal(out.has("http://[::1]:37128"), true);
+    assert.equal(out.has("http://localhost:20128"), true);
+  });
 });
 
 describe("buildAllowedHosts", () => {
@@ -150,6 +162,11 @@ describe("isOriginAllowed", () => {
     assert.equal(isOriginAllowed("http://100.96.135.160:20128", env), true);
   });
 
+  it("does not treat a wildcard host as an allow-all origin policy", () => {
+    const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "*" };
+    assert.equal(isOriginAllowed("http://100.90.139.116:37128", env), false);
+  });
+
   it("does NOT accept a Tailscale Origin when LIVE_WS_ALLOWED_HOSTS is unset", () => {
     // Critical security invariant: without explicit opt-in, the LAN/Tailscale
     // surface is closed even though the listener is reachable.

From c8434436af533cc7b393eb8d4996cc497894c1c0 Mon Sep 17 00:00:00 2001
From: Domenico Massafra <117327638+domenicomassafra@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:04:10 +0200
Subject: [PATCH 19/33] feat(antigravity): expose physical send telemetry
 (#13659)

Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
---
 open-sse/executors/antigravity.ts             | 36 +++++++++++++++----
 .../executors/antigravity/executeAttempt.ts   | 23 ++++++++++--
 .../antigravity-streaming-passthrough.test.ts | 13 ++++++-
 tests/unit/executor-antigravity.test.ts       | 14 +++++++-
 4 files changed, 75 insertions(+), 11 deletions(-)

diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts
index 01a315ef80..406cbbd05b 100644
--- a/open-sse/executors/antigravity.ts
+++ b/open-sse/executors/antigravity.ts
@@ -508,6 +508,7 @@ function isAntigravityGeminiChatModel(upstreamModel: string): boolean {
 export const __test_stripTrailingAntigravityAssistantTurn = stripTrailingAntigravityAssistantTurn;
 
 type AntigravityCreditsRetryState = { attempted: boolean };
+type AntigravityPhysicalSendCounter = { value: number };
 
 /** Base per-url-index attempt context, before the request has been sent. */
 type AntigravityAttemptContext = {
@@ -527,6 +528,8 @@ type AntigravityAttemptContext = {
   urlIndex: number;
   retryAttemptsByUrl: Record;
   fallbackCount: number;
+  physicalSendCounter: AntigravityPhysicalSendCounter;
+  correlationId: string | null;
 };
 
 /** Context threaded through the 429/503 handling helpers — adds the sent response. */
@@ -1169,6 +1172,7 @@ export class AntigravityExecutor extends BaseExecutor {
    * exactly the same single call as before (zero extra upstream requests).
    */
   async execute(input: ExecuteInput) {
+    const physicalSendCounter: AntigravityPhysicalSendCounter = { value: 0 };
     await resolveAntigravityClientVersion(getAntigravityClientProfile(input.credentials));
 
     // Look up the chain by the NORMALLY-resolved upstream id (honours MITM/static aliases).
@@ -1178,7 +1182,7 @@ export class AntigravityExecutor extends BaseExecutor {
 
     if (chain.length <= 1) {
       // No fallback chain (flash, claude, plain pro, unknown) → single attempt, unchanged.
-      return this.executeOnce(input);
+      return this.executeOnce(input, undefined, physicalSendCounter);
     }
 
     let firstResult: Awaited> | null = null;
@@ -1186,7 +1190,7 @@ export class AntigravityExecutor extends BaseExecutor {
       const candidate = chain[i];
       let result: Awaited>;
       try {
-        result = await this.executeOnce(input, candidate);
+        result = await this.executeOnce(input, candidate, physicalSendCounter);
       } catch (error) {
         const outcome = handleAntigravityFallbackChainError(
           input,
@@ -1228,7 +1232,7 @@ export class AntigravityExecutor extends BaseExecutor {
     }
 
     // Unreachable (loop always returns), but keeps the type checker happy.
-    return firstResult ?? this.executeOnce(input);
+    return firstResult ?? this.executeOnce(input, undefined, physicalSendCounter);
   }
 
   /**
@@ -1239,8 +1243,18 @@ export class AntigravityExecutor extends BaseExecutor {
    * status of the first response so `execute()` can decide whether to fall through. @internal
    */
   private async executeOnce(
-    { model, body, stream, credentials, signal, log, upstreamExtraHeaders }: ExecuteInput,
-    modelIdOverride?: string
+    {
+      model,
+      body,
+      stream,
+      credentials,
+      signal,
+      log,
+      upstreamExtraHeaders,
+      correlationId = null,
+    }: ExecuteInput,
+    modelIdOverride?: string,
+    physicalSendCounter: AntigravityPhysicalSendCounter = { value: 0 }
   ) {
     await resolveAntigravityClientVersion(getAntigravityClientProfile(credentials));
     const fallbackCount = this.getFallbackCount();
@@ -1307,6 +1321,8 @@ export class AntigravityExecutor extends BaseExecutor {
           urlIndex,
           retryAttemptsByUrl,
           fallbackCount,
+          physicalSendCounter,
+          correlationId,
         });
 
         if (outcome.action === "return") return outcome.result;
@@ -1356,6 +1372,8 @@ export class AntigravityExecutor extends BaseExecutor {
       urlIndex,
       retryAttemptsByUrl,
       fallbackCount,
+      physicalSendCounter,
+      correlationId,
     } = ctx;
 
     const { response, finalHeaders } = await sendAntigravityRequest(
@@ -1368,7 +1386,9 @@ export class AntigravityExecutor extends BaseExecutor {
       stream,
       signal,
       log,
-      retryAttemptsByUrl[urlIndex]
+      retryAttemptsByUrl[urlIndex],
+      physicalSendCounter,
+      correlationId
     );
 
     let retryMs: number | null = null;
@@ -1619,7 +1639,9 @@ export class AntigravityExecutor extends BaseExecutor {
           signal,
           log,
           accountId,
-          updateAntigravityRemainingCredits
+          updateAntigravityRemainingCredits,
+          ctx.physicalSendCounter,
+          ctx.correlationId
         );
         if (creditsResult) return { kind: "return", result: creditsResult };
         if (retryMs) markConnectionQuotaExhausted(accountId, retryMs, ctx.model);
diff --git a/open-sse/executors/antigravity/executeAttempt.ts b/open-sse/executors/antigravity/executeAttempt.ts
index b8b9447c19..276412efaa 100644
--- a/open-sse/executors/antigravity/executeAttempt.ts
+++ b/open-sse/executors/antigravity/executeAttempt.ts
@@ -333,7 +333,9 @@ export async function sendAntigravityRequest(
   stream: boolean,
   signal: AbortSignal | null | undefined,
   log: SafeAntigravityLog,
-  retryAttempt: number
+  retryAttempt: number,
+  physicalSendCounter: { value: number },
+  correlationId: string | null
 ): Promise<{ response: Response; finalHeaders: Record }> {
   const serializedRequest = serializeAntigravityRequest(provider, headers, transformedBody);
   let finalHeaders = serializedRequest.headers;
@@ -356,6 +358,11 @@ export async function sendAntigravityRequest(
   }
 
   await prl.captureCurrentProviderBody(url, finalHeaders, serializedRequest.bodyString, log);
+  const physicalSendOrdinal = ++physicalSendCounter.value;
+  log.debug(
+    "TELEMETRY",
+    `[Antigravity] PhysicalSend - RequestId: ${correlationId ?? "none"}, URL: ${url}, Model: ${model}, PhysicalSend: ${physicalSendOrdinal}, RetryAttempt: ${retryAttempt}`
+  );
   let response = await fetchAntigravityWithReadinessTimeout(url, {
     method: "POST",
     headers: finalHeaders,
@@ -369,6 +376,11 @@ export async function sendAntigravityRequest(
     removeHeaderCaseInsensitive(retryHeaders, "x-goog-user-project");
     log.debug("RETRY", "403 with x-goog-user-project, retrying once without it");
     await prl.captureCurrentProviderBody(url, retryHeaders, serializedRequest.bodyString, log);
+    const retryPhysicalSendOrdinal = ++physicalSendCounter.value;
+    log.debug(
+      "TELEMETRY",
+      `[Antigravity] PhysicalSend - RequestId: ${correlationId ?? "none"}, URL: ${url}, Model: ${model}, PhysicalSend: ${retryPhysicalSendOrdinal}, RetryAttempt: ${retryAttempt}, Cause: x-goog-user-project-403`
+    );
     response = await fetchAntigravityWithReadinessTimeout(url, {
       method: "POST",
       headers: retryHeaders,
@@ -416,7 +428,9 @@ export async function tryCreditsRetry(
   signal: AbortSignal | null | undefined,
   log: SafeAntigravityLog,
   accountId: string,
-  onCreditsUpdate: OnAntigravityCreditsUpdate
+  onCreditsUpdate: OnAntigravityCreditsUpdate,
+  physicalSendCounter: { value: number },
+  correlationId: string | null
 ): Promise {
   log.info("AG_CREDITS", "Retrying with Google One AI credits");
   const creditsBody = attachToolNameMap(
@@ -433,6 +447,11 @@ export async function tryCreditsRetry(
       serializedCreditsRequest.bodyString,
       log
     );
+    const creditsPhysicalSendOrdinal = ++physicalSendCounter.value;
+    log.debug(
+      "TELEMETRY",
+      `[Antigravity] PhysicalSend - RequestId: ${correlationId ?? "none"}, URL: ${url}, PhysicalSend: ${creditsPhysicalSendOrdinal}, Cause: google-one-ai-credits-retry`
+    );
     const creditsResp = await fetchAntigravityWithReadinessTimeout(url, {
       method: "POST",
       headers: finalCreditsHeaders,
diff --git a/tests/unit/antigravity-streaming-passthrough.test.ts b/tests/unit/antigravity-streaming-passthrough.test.ts
index 88702823b2..336c1527d8 100644
--- a/tests/unit/antigravity-streaming-passthrough.test.ts
+++ b/tests/unit/antigravity-streaming-passthrough.test.ts
@@ -36,6 +36,7 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects
   const originalFetch = globalThis.fetch;
   const originalSetTimeout = globalThis.setTimeout;
   const calls = [];
+  const telemetry: string[] = [];
   seedAntigravityIdeVersionCache("2026.04.17-test");
   seedAntigravityCliVersionCache("2026.04.17-test");
 
@@ -71,7 +72,13 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects
       body: { request: { contents: [] } },
       stream: false,
       credentials: { accessToken: "token", projectId: "project-1" },
-      log: { debug() {}, warn() {} },
+      log: {
+        debug(_scope, message) {
+          telemetry.push(String(message));
+        },
+        warn() {},
+      },
+      correlationId: "prompt194-native-retry-test",
     });
     // Non-streaming collects the upstream SSE and returns the already-converted
     // OpenAI chat.completion payload — no further SSE parsing on the caller side.
@@ -79,6 +86,10 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects
     assert.equal(payload.object, "chat.completion");
 
     assert.equal(calls.length, 2);
+    const physicalSends = telemetry.filter((line) => line.includes("[Antigravity] PhysicalSend"));
+    assert.equal(physicalSends.length, calls.length);
+    assert.match(physicalSends[0] ?? "", /RequestId: prompt194-native-retry-test/);
+    assert.match(physicalSends[1] ?? "", /PhysicalSend: 2/);
     assert.equal(result.response.status, 200);
     assert.equal(payload.choices[0].message.content, "Hello again");
     assert.deepEqual(payload.usage, {
diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts
index befff78723..9040aa70fa 100644
--- a/tests/unit/executor-antigravity.test.ts
+++ b/tests/unit/executor-antigravity.test.ts
@@ -871,6 +871,7 @@ test("AntigravityExecutor.execute bounds a persistent short-retry 429 instead of
   const originalFetch = globalThis.fetch;
   const originalSetTimeout = globalThis.setTimeout;
   const calls: string[] = [];
+  const telemetry: string[] = [];
   seedAntigravityIdeVersionCache("2.1.1");
 
   // "rate limited" with no parseable retry hint classifies as rate_limited →
@@ -897,7 +898,13 @@ test("AntigravityExecutor.execute bounds a persistent short-retry 429 instead of
       body: { request: { contents: [] } },
       stream: true,
       credentials: { accessToken: "token", projectId: "project-1" },
-      log: { debug() {}, warn() {} },
+      log: {
+        debug(_scope, message) {
+          telemetry.push(String(message));
+        },
+        warn() {},
+      },
+      correlationId: "prompt194-physical-send-test",
     });
 
     // Returns the 429 rather than hanging.
@@ -906,6 +913,11 @@ test("AntigravityExecutor.execute bounds a persistent short-retry 429 instead of
     // Bounded: switchAuth declines same-URL retries → 2 live runtime endpoints
     // × 1 attempt each = 2 attempts total (#9351).
     assert.equal(calls.length, 2);
+    const physicalSends = telemetry.filter((line) => line.includes("[Antigravity] PhysicalSend"));
+    assert.equal(physicalSends.length, calls.length);
+    assert.match(physicalSends[0] ?? "", /RequestId: prompt194-physical-send-test/);
+    assert.match(physicalSends[0] ?? "", /PhysicalSend: 1/);
+    assert.match(physicalSends[1] ?? "", /PhysicalSend: 2/);
 
     // Tried every distinct live runtime base URL before giving up.
     const distinctHosts = new Set(calls.map((u) => new URL(u).host));

From fa23670ea2990e9c24e9e9f349bd8b2c8412719a Mon Sep 17 00:00:00 2001
From: Zeeshan Haque 
Date: Fri, 18 Sep 2026 20:04:17 -0700
Subject: [PATCH 20/33] fix(codex): forward the caller client version upstream
 instead of a pinned default (#13708)

* fix(codex): forward the caller client version upstream instead of pinning 0.144.1

The Codex provider reported a hardcoded client version (DEFAULT_CODEX_CLIENT_VERSION)
to the ChatGPT backend. Newer gated models reject older clients, e.g.:

  The 'gpt-6-astra' model requires a newer version of Codex.

so the pinned value silently rots on every CLI upgrade, and a user on the latest
CLI is still refused.

CodexExecutor.buildHeaders also dropped the clientHeaders/model/health arguments
that the base class accepts (base.ts:509), so the caller User-Agent never reached
the version resolution.

- codexClient.ts: add getCodexClientVersionFromHeaders(), which reads the version
  the caller reports in its User-Agent (codex_cli_rs/, codex_exec/) or a
  version header, validated against SAFE_HEADER_TOKEN_PATTERN. getCodexUserAgent()
  now takes an optional version override.
- codex.ts: forward clientHeaders/model/health to super.buildHeaders(), and use
  getCodexClientVersionFromHeaders(clientHeaders) ?? getCodexClientVersion().

Falls back to the existing env override / default when the caller sends nothing.

* test(codex): cover getCodexClientVersionFromHeaders and clientHeaders-aware buildHeaders

Adds unit coverage for the caller-version forwarding introduced in this PR:
real Codex CLI User-Agent parsing, the generic version header, missing/empty
headers, non-Codex User-Agent, and CRLF/oversized injection attempts safely
returning null/falling back to the default version.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Zeeshan Haque 
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---
 open-sse/config/codexClient.ts          |  58 +++++++++++-
 open-sse/executors/codex.ts             |  29 +++++-
 tests/unit/codex-client-headers.test.ts | 113 ++++++++++++++++++++++++
 3 files changed, 194 insertions(+), 6 deletions(-)
 create mode 100644 tests/unit/codex-client-headers.test.ts

diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts
index 5c71e931a5..e824f2b68b 100644
--- a/open-sse/config/codexClient.ts
+++ b/open-sse/config/codexClient.ts
@@ -33,13 +33,67 @@ export function getCodexClientVersion(): string {
   );
 }
 
-export function getCodexUserAgent(): string {
+/**
+ * Extract the Codex client version the CALLER actually reported, so OmniRoute
+ * forwards it upstream instead of substituting a pinned default. The official
+ * CLI sends it in User-Agent, e.g.
+ *   codex_cli_rs/0.154.0 (Mac OS 26.6.2; arm64) ...
+ *   codex_exec/0.154.0 (Mac OS 26.6.2; arm64) xterm-256color (codex_exec; 0.154.0)
+ * Some clients also send a `version` header.
+ *
+ * Why this matters: the ChatGPT backend gates newer models on the client
+ * version ("The 'gpt-6-astra' model requires a newer version of Codex").
+ * A pinned default silently rots every time the user upgrades their CLI.
+ *
+ * Returns null when the caller sent nothing usable, so callers can fall back
+ * to getCodexClientVersion().
+ */
+const CODEX_CLIENT_VERSION_IN_UA_PATTERN = /(?:codex[-_][A-Za-z0-9_]*|codex-cli)\/(\d+\.\d+\.\d+)/i;
+
+export function getCodexClientVersionFromHeaders(
+  clientHeaders?: Record | null
+): string | null {
+  if (!clientHeaders) return null;
+
+  const pick = (name: string): string | null => {
+    const direct = clientHeaders[name];
+    if (typeof direct === "string" && direct.trim()) return direct.trim();
+    const lower = name.toLowerCase();
+    for (const [k, v] of Object.entries(clientHeaders)) {
+      if (k.toLowerCase() === lower && typeof v === "string" && v.trim()) {
+        return v.trim();
+      }
+    }
+    return null;
+  };
+
+  const fromVersionHeader = pick("version");
+  if (fromVersionHeader && SAFE_HEADER_TOKEN_PATTERN.test(fromVersionHeader)) {
+    return fromVersionHeader;
+  }
+
+  const userAgent = pick("user-agent");
+  if (!userAgent) return null;
+
+  const match = CODEX_CLIENT_VERSION_IN_UA_PATTERN.exec(userAgent);
+  if (!match) return null;
+
+  const version = match[1];
+  return SAFE_HEADER_TOKEN_PATTERN.test(version) ? version : null;
+}
+
+export function getCodexUserAgent(versionOverride?: string | null): string {
   const override = getSafeEnvValue(CODEX_USER_AGENT_OVERRIDE_ENV, SAFE_HEADER_VALUE_PATTERN);
   if (override) {
     return override;
   }
 
-  return `codex-cli/${getCodexClientVersion()} (${DEFAULT_CODEX_USER_AGENT_PLATFORM}; ${DEFAULT_CODEX_USER_AGENT_ARCH})`;
+  const version =
+    versionOverride && SAFE_HEADER_TOKEN_PATTERN.test(versionOverride)
+      ? versionOverride
+      : getCodexClientVersion();
+
+  return `codex-cli/${version} (${DEFAULT_CODEX_USER_AGENT_PLATFORM}; ${DEFAULT_CODEX_USER_AGENT_ARCH})`;
 }
 
 export function getCodexDefaultHeaders(): Record {
diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts
index 6940ee89f5..5e6c4175ac 100644
--- a/open-sse/executors/codex.ts
+++ b/open-sse/executors/codex.ts
@@ -23,9 +23,11 @@ import { stripCodexPassthroughRejectedParams } from "./codex/stripPassthroughRej
 import {
   CODEX_CLI_RS_ORIGINATOR,
   getCodexClientVersion,
+  getCodexClientVersionFromHeaders,
   getCodexUserAgent,
   normalizeCodexSessionId,
 } from "../config/codexClient.ts";
+import type { KeyHealth } from "../services/apiKeyRotator.ts";
 import {
   applyCodexClientIdentityHeaders,
   applyCodexClientMetadata,
@@ -1117,11 +1119,30 @@ export class CodexExecutor extends BaseExecutor {
    * Always request event-stream from upstream, even when client requested stream=false.
    * Includes chatgpt-account-id header for strict workspace binding.
    */
-  buildHeaders(credentials: ProviderCredentials, stream = true) {
+  buildHeaders(
+    credentials: ProviderCredentials,
+    stream = true,
+    clientHeaders?: Record | null,
+    model?: string,
+    health?: Record
+  ) {
     const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
-    const headers = super.buildHeaders(credentials, isCompactRequest ? false : true);
-    headers.Version = getCodexClientVersion();
-    setUserAgentHeader(headers, getCodexUserAgent());
+    const headers = super.buildHeaders(
+      credentials,
+      isCompactRequest ? false : true,
+      clientHeaders,
+      model,
+      health
+    );
+
+    // Forward the CALLER's own Codex client version upstream instead of a pinned
+    // default. The ChatGPT backend gates newer models on the reported client
+    // version (e.g. "The 'gpt-6-astra' model requires a newer version of Codex"),
+    // so a hardcoded value silently rots whenever the user upgrades their CLI.
+    // Falls back to the configured/default version when the caller sends none.
+    const clientVersion = getCodexClientVersionFromHeaders(clientHeaders);
+    headers.Version = clientVersion ?? getCodexClientVersion();
+    setUserAgentHeader(headers, getCodexUserAgent(clientVersion));
 
     // Add workspace binding header if workspaceId is persisted
     const workspaceId = credentials?.providerSpecificData?.workspaceId;
diff --git a/tests/unit/codex-client-headers.test.ts b/tests/unit/codex-client-headers.test.ts
new file mode 100644
index 0000000000..02db806724
--- /dev/null
+++ b/tests/unit/codex-client-headers.test.ts
@@ -0,0 +1,113 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { getCodexClientVersionFromHeaders } from "../../open-sse/config/codexClient.ts";
+import { CodexExecutor } from "../../open-sse/executors/codex.ts";
+
+test("getCodexClientVersionFromHeaders: extracts the version from a real Codex CLI User-Agent", () => {
+  assert.equal(
+    getCodexClientVersionFromHeaders({
+      "user-agent": "codex_cli_rs/0.154.0 (Mac OS 26.6.2; arm64)",
+    }),
+    "0.154.0"
+  );
+  assert.equal(
+    getCodexClientVersionFromHeaders({
+      "user-agent":
+        "codex_exec/0.154.0 (Mac OS 26.6.2; arm64) xterm-256color (codex_exec; 0.154.0)",
+    }),
+    "0.154.0"
+  );
+});
+
+test("getCodexClientVersionFromHeaders: prefers a valid generic version header over User-Agent", () => {
+  assert.equal(
+    getCodexClientVersionFromHeaders({
+      version: "9.9.9",
+      "user-agent": "codex_cli_rs/0.154.0 (Mac OS 26.6.2; arm64)",
+    }),
+    "9.9.9"
+  );
+});
+
+test("getCodexClientVersionFromHeaders: returns null when headers are absent or empty", () => {
+  assert.equal(getCodexClientVersionFromHeaders(null), null);
+  assert.equal(getCodexClientVersionFromHeaders(undefined), null);
+  assert.equal(getCodexClientVersionFromHeaders({}), null);
+});
+
+test("getCodexClientVersionFromHeaders: returns null for a non-Codex User-Agent with no version header", () => {
+  assert.equal(getCodexClientVersionFromHeaders({ "user-agent": "curl/8.4.0" }), null);
+});
+
+test("getCodexClientVersionFromHeaders: rejects a CRLF injection attempt in the version header", () => {
+  assert.equal(getCodexClientVersionFromHeaders({ version: "1.0.0\r\nX-Injected: evil" }), null);
+});
+
+test("getCodexClientVersionFromHeaders: rejects a version header longer than the 32-char safe token limit", () => {
+  const overlong = "1.0.0-" + "a".repeat(30);
+  assert.ok(overlong.length > 32);
+  assert.equal(getCodexClientVersionFromHeaders({ version: overlong }), null);
+});
+
+test("getCodexClientVersionFromHeaders: a CRLF/oversized User-Agent injection only ever yields the captured digits", () => {
+  assert.equal(
+    getCodexClientVersionFromHeaders({
+      "user-agent": "codex_cli_rs/1.0.0\r\nX-Evil: 1",
+    }),
+    "1.0.0"
+  );
+});
+
+test("CodexExecutor.buildHeaders forwards the caller's Codex client version from clientHeaders", () => {
+  const executor = new CodexExecutor();
+
+  const fromUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+    "user-agent": "codex_cli_rs/0.160.2 (Mac OS 26.6.2; arm64)",
+  });
+  assert.equal(fromUserAgent.Version, "0.160.2");
+  assert.equal(fromUserAgent["User-Agent"], "codex-cli/0.160.2 (Windows 10.0.26200; x64)");
+
+  const fromVersionHeader = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+    version: "9.9.9",
+  });
+  assert.equal(fromVersionHeader.Version, "9.9.9");
+  assert.equal(fromVersionHeader["User-Agent"], "codex-cli/9.9.9 (Windows 10.0.26200; x64)");
+});
+
+test("CodexExecutor.buildHeaders falls back to the default client version when clientHeaders is absent, empty, or unusable", () => {
+  const executor = new CodexExecutor();
+
+  const noHeaders = executor.buildHeaders({ accessToken: "codex-token" }, true);
+  assert.equal(noHeaders.Version, "0.153.4");
+
+  const emptyHeaders = executor.buildHeaders({ accessToken: "codex-token" }, true, {});
+  assert.equal(emptyHeaders.Version, "0.153.4");
+
+  const nonCodexUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+    "user-agent": "curl/8.4.0",
+  });
+  assert.equal(nonCodexUserAgent.Version, "0.153.4");
+});
+
+test("CodexExecutor.buildHeaders rejects injection attempts in the caller's version/User-Agent headers", () => {
+  const executor = new CodexExecutor();
+
+  const crlfVersion = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+    version: "1.0.0\r\nX-Injected: evil",
+  });
+  assert.equal(crlfVersion.Version, "0.153.4");
+  assert.equal(crlfVersion["User-Agent"].includes("\r\n"), false);
+
+  const overlongVersion = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+    version: "1.0.0-" + "a".repeat(30),
+  });
+  assert.equal(overlongVersion.Version, "0.153.4");
+
+  const injectedUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+    "user-agent": "codex_cli_rs/1.0.0\r\nX-Evil: 1",
+  });
+  assert.equal(injectedUserAgent.Version, "1.0.0");
+  assert.equal(injectedUserAgent["User-Agent"].includes("\r\n"), false);
+  assert.equal(injectedUserAgent["User-Agent"], "codex-cli/1.0.0 (Windows 10.0.26200; x64)");
+});

From f6bafe4b88bf0cb385998bce47350e6ecd56fde4 Mon Sep 17 00:00:00 2001
From: Pandu dwi Putra 
Date: Sat, 19 Sep 2026 10:04:24 +0700
Subject: [PATCH 21/33] fix(resilience): resource_pressure admission gate
 actively re-samples instead of reading a stale cache (#13823)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

`admitChatRequest`'s structural admission gate (chatBodyAdmission.ts) is the
first caller in the request path to consult pressure severity, ahead of every
other code path that would otherwise call `checkResourcePressureGuard()`
(handleChatCore, checkResourcePressureBeforeProviderWork,
AdaptiveAdmissionRuntimeImpl.acquire). `defaultPressureSeverity()` read the
resourcePressure singleton's cached `state` directly
(getResourcePressureObservation) instead of driving `check()` — so once
`state.severity` flipped to "critical" (from any pressure trigger: PSI, v8
heap ratio, a worker-leak spike, etc.), every subsequent request was shed at
this cheap cached-read gate before it could ever reach the one function
capable of drawing a fresh sample and observing recovery. The gate and the
only means of clearing it were mutually exclusive once tripped: the gate's own
rejection starved the sampler that would clear the gate. Only a full process
restart cleared it.

`defaultPressureSeverity()` now calls `checkResourcePressureGuard()` first.
That call is cheap on the hot path — a synchronous `process.memoryUsage()`
read plus a timestamp comparison; the actual signal sampling
(`/proc/pressure/memory`, cgroup reads) stays asynchronous via
`scheduleRefresh()` and throttled by `staleAfterMs`, so this adds no
per-request I/O. A non-null guard is this request's authoritative "shed now"
answer and maps to "critical". A null guard means this request is not shed,
but the raw cached label can still briefly read "critical" until the async
refresh settles (or if the last real sample simply went stale), so that case
is downgraded to "high" rather than re-introducing the same problem for the
queue-wait-sizing branch that also reads this value.

Fixes #13821

Test: new tests/unit/resource-pressure-gate-recovery.test.ts drives the
resourcePressure singleton to critical through the same sustained-sample path
production uses (not the synchronous immediate-heap escape hatch), using an
injected mock clock so no scheduled refresh from setup can resolve on its own.
Confirmed red on the base commit — the recovery assertion fails with
`actual: 'critical', expected: 'normal'`, i.e. the singleton never recovers on
its own — and green with the fix.
---
 ...-resource-pressure-gate-never-resamples.md |   1 +
 src/shared/middleware/chatBodyAdmission.ts    |  40 +++++-
 .../resource-pressure-gate-recovery.test.ts   | 132 ++++++++++++++++++
 3 files changed, 171 insertions(+), 2 deletions(-)
 create mode 100644 changelog.d/fixes/13821-resource-pressure-gate-never-resamples.md
 create mode 100644 tests/unit/resource-pressure-gate-recovery.test.ts

diff --git a/changelog.d/fixes/13821-resource-pressure-gate-never-resamples.md b/changelog.d/fixes/13821-resource-pressure-gate-never-resamples.md
new file mode 100644
index 0000000000..81222520cc
--- /dev/null
+++ b/changelog.d/fixes/13821-resource-pressure-gate-never-resamples.md
@@ -0,0 +1 @@
+- fix(resilience): the chat admission gate's pressure check now actively re-samples instead of reading a passive cache, so the `resource_pressure` guard can observe recovery and stop shedding once real pressure clears, instead of requiring a full process restart
diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts
index f838e92224..ef38f4086a 100644
--- a/src/shared/middleware/chatBodyAdmission.ts
+++ b/src/shared/middleware/chatBodyAdmission.ts
@@ -38,6 +38,7 @@ import {
   type IngestBudgetAcquireResult,
 } from "./ingestByteAdmission";
 import {
+  checkResourcePressureGuard,
   getResourcePressureObservation,
   type PressureSeverity,
 } from "@omniroute/open-sse/utils/resourcePressure.ts";
@@ -217,10 +218,45 @@ export type ChatAdmissionShedReason =
   | "inflight_bytes_budget"
   | "resource_pressure";
 
-/** Read cached pressure severity; sampling failures must not cause false sheds. */
+/**
+ * Read pressure severity for admission decisions.
+ *
+ * This MUST drive an active re-sample (`checkResourcePressureGuard`), not a
+ * passive cache read of `getResourcePressureObservation`. The resource-pressure
+ * runtime only refreshes its sample and re-evaluates recovery from *inside*
+ * `check()` (via `scheduleRefresh`) — nothing else in the singleton mutates
+ * `state` or schedules a refresh. The structural admission gate that calls
+ * this function runs *before* every other code path that would otherwise call
+ * `check()` (`handleChatCore`, `checkResourcePressureBeforeProviderWork`,
+ * `AdaptiveAdmissionRuntimeImpl.acquire`) — so once `state.severity` flips to
+ * "critical", a passive read here sheds every subsequent request before any
+ * of those downstream paths can run, which means `check()` never gets called
+ * again and the guard can never observe recovery. See
+ * https://github.com/diegosouzapw/OmniRoute/issues/13821.
+ *
+ * `checkResourcePressureGuard()` is cheap on the hot path: it only does a
+ * synchronous `process.memoryUsage()` read plus a timestamp comparison per
+ * call; the actual signal sampling (`/proc/pressure/memory`, cgroup reads)
+ * happens asynchronously via `scheduleRefresh()` and is throttled by
+ * `staleAfterMs`, so calling this on every admitted request does not add
+ * per-request I/O.
+ *
+ * A non-null guard is this request's authoritative "shed now" answer and maps
+ * to "critical". A null guard means this request is not shed, but the
+ * observation's cached label can still read "critical" for a few more
+ * milliseconds until the async refresh settles (or if the last real sample
+ * merely went stale — `check()`'s own `maxStaleMs` fallback) — reporting that
+ * stale "critical" label to callers that branch on severity (e.g. the queue
+ * wait sizing at admitChatRequest's `reserve()`) would just re-introduce the
+ * same "never downgrades" problem for the "high" queueing bucket, so it is
+ * downgraded to "high" here instead.
+ */
 export function defaultPressureSeverity(): PressureSeverity {
   try {
-    return getResourcePressureObservation().state.severity;
+    const guard = checkResourcePressureGuard();
+    if (guard) return "critical";
+    const severity = getResourcePressureObservation().state.severity;
+    return severity === "critical" ? "high" : severity;
   } catch {
     return "normal";
   }
diff --git a/tests/unit/resource-pressure-gate-recovery.test.ts b/tests/unit/resource-pressure-gate-recovery.test.ts
new file mode 100644
index 0000000000..564903f5df
--- /dev/null
+++ b/tests/unit/resource-pressure-gate-recovery.test.ts
@@ -0,0 +1,132 @@
+// Regression for https://github.com/diegosouzapw/OmniRoute/issues/13821.
+//
+// The structural admission gate (chatBodyAdmission.ts, admitChatRequest) is
+// the FIRST caller in the request path to consult pressure severity, ahead of
+// every other code path that would otherwise call checkResourcePressureGuard()
+// (handleChatCore, checkResourcePressureBeforeProviderWork,
+// AdaptiveAdmissionRuntimeImpl.acquire). In production, one of those other
+// paths is what first observes a real critical condition (a request that
+// slips past the gate before it starts shedding, or the AdaptiveAdmission
+// runtime for a different combo route) and flips the singleton's cached
+// `state.severity` to "critical" via the sustained-sample tracker. From that
+// point on, the structural gate sheds every subsequent request before any of
+// those downstream paths can run again — so `check()` never gets called
+// again and the guard can never observe recovery, even after the real
+// condition clears. Only a full process restart clears it.
+//
+// This test drives the singleton to "critical" through the SAME sustained
+// sample-and-recover path production uses (classifyAdaptiveResourcePressure's
+// v8_heap_ratio + a two-sample streak, not the synchronous immediate-heap
+// escape hatch), using an injected mock clock so no scheduled refresh from
+// the setup phase can resolve on its own and contaminate the assertion. Only
+// the exact calls under test (`defaultPressureSeverity`, twice) are allowed
+// to drive anything after the singleton is latched critical.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { defaultPressureSeverity } =
+  await import("../../src/shared/middleware/chatBodyAdmission.ts");
+const { reloadResourcePressureRuntime, checkResourcePressureGuard } =
+  await import("../../open-sse/utils/resourcePressure.ts");
+
+const MiB = 1024 * 1024;
+
+function signals(observedAtMs: number, heapUsedMb: number) {
+  return {
+    observedAtMs,
+    v8: { heapUsedBytes: heapUsedMb * MiB, heapLimitBytes: 1000 * MiB },
+    process: {
+      rssBytes: 0,
+      externalBytes: 0,
+      arrayBuffersBytes: 0,
+      availableBytes: null,
+      constrainedBytes: null,
+    },
+    cgroup: { currentBytes: null, maxBytes: null, highBytes: null, fileBytes: null, events: null },
+    psi: null,
+  };
+}
+
+/** Advances the mock clock and drives one refresh cycle to completion. */
+async function tick(runtime: { whenRefreshSettled: () => Promise }): Promise {
+  checkResourcePressureGuard();
+  await runtime.whenRefreshSettled();
+}
+
+test("defaultPressureSeverity recovers to normal once the underlying pressure clears, driven only by repeated calls to itself", async () => {
+  let underPressure = true;
+  let clockMs = 0;
+  const staleAfterMs = 1_000;
+  const runtime = reloadResourcePressureRuntime({
+    heapThresholdMb: null,
+    immediateHeapUsedMb: () => 0, // never trip the synchronous escape hatch — force the sample path
+    nowMs: () => clockMs,
+    sample: async () => signals(clockMs, underPressure ? 950 : 100), // 950/1000 = 0.95 >= criticalRatio(0.92)
+    staleAfterMs,
+  });
+
+  try {
+    // Drive two sustained critical samples (default sustainedSamplesCritical
+    // is 2) via checkResourcePressureGuard directly, NOT defaultPressureSeverity —
+    // this mirrors some other request's handleChatCore call being the thing
+    // that first observes the real condition in production, not the gate
+    // itself. Each call is followed by advancing the clock past staleAfterMs
+    // so the NEXT call is the one that schedules and awaits the next sample.
+    await tick(runtime);
+    clockMs += staleAfterMs + 1;
+    await tick(runtime);
+
+    // The seed is fully settled now; nextRefreshAtMs is in the past relative
+    // to the current clock only once we advance it again below — right now
+    // there is nothing scheduled, so nothing can resolve on its own.
+    assert.equal(defaultPressureSeverity(), "critical");
+
+    // The underlying condition clears and enough time passes for the next
+    // sample to be due. From here on nothing but defaultPressureSeverity's
+    // own two calls touches the singleton.
+    underPressure = false;
+    clockMs += staleAfterMs + 1;
+
+    // This call's synchronous return still reflects the pre-refresh cached
+    // decision (matches production: check() answers instantly, the resample
+    // it schedules resolves in the background) — both the old passive read
+    // and the fixed active read must still say "critical" here.
+    assert.equal(defaultPressureSeverity(), "critical");
+
+    // Let whatever got scheduled by the call above resolve. Before the fix,
+    // defaultPressureSeverity never called check() at all, so nothing was
+    // scheduled here and this is a no-op — the singleton stays latched at
+    // "critical" forever. The fix must have scheduled and awaited a real
+    // resample from its own call above.
+    await runtime.whenRefreshSettled();
+    assert.equal(defaultPressureSeverity(), "normal");
+  } finally {
+    runtime.dispose();
+  }
+});
+
+test("defaultPressureSeverity still sheds while genuinely critical, across repeated calls", async () => {
+  let clockMs = 0;
+  const staleAfterMs = 1_000;
+  const runtime = reloadResourcePressureRuntime({
+    heapThresholdMb: null,
+    immediateHeapUsedMb: () => 0,
+    nowMs: () => clockMs,
+    sample: async () => signals(clockMs, 950),
+    staleAfterMs,
+  });
+
+  try {
+    await tick(runtime);
+    clockMs += staleAfterMs + 1;
+    await tick(runtime);
+
+    assert.equal(defaultPressureSeverity(), "critical");
+    clockMs += staleAfterMs + 1;
+    assert.equal(defaultPressureSeverity(), "critical");
+    await runtime.whenRefreshSettled();
+    assert.equal(defaultPressureSeverity(), "critical");
+  } finally {
+    runtime.dispose();
+  }
+});

From c299e6c18033003071843483ef0bb03b2971b162 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nguy=E1=BB=85n=20Vi=E1=BA=BFt=20Tu=E1=BA=A5n?=
 
Date: Sat, 19 Sep 2026 10:04:31 +0700
Subject: [PATCH 22/33] fix(translator): pair Gemini tool responses per turn to
 prevent cross-turn ID collision (#13848)

---
 .../translator/request/openai-to-gemini.ts    |  46 +++--
 .../unit/translator-openai-to-gemini.test.ts  | 158 ++++++++++++++++++
 2 files changed, 194 insertions(+), 10 deletions(-)

diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts
index 1120dfba69..8231814f57 100644
--- a/open-sse/translator/request/openai-to-gemini.ts
+++ b/open-sse/translator/request/openai-to-gemini.ts
@@ -346,7 +346,8 @@ function openaiToGeminiBase(
 
   // Convert messages
   if (messages && Array.isArray(messages)) {
-    for (const msg of messages) {
+    for (let msgIndex = 0; msgIndex < messages.length; msgIndex++) {
+      const msg = messages[msgIndex];
       const role = msg.role;
       const content = msg.content;
 
@@ -482,20 +483,47 @@ function openaiToGeminiBase(
             result.contents.push({ role: "model", parts });
           }
 
+          // Collect turn-specific tool responses: in standard OpenAI chat format, tool responses
+          // immediately follow the assistant message that requested them.
+          const turnToolResponses: Record = {};
+          for (let j = msgIndex + 1; j < messages.length; j++) {
+            const later = messages[j];
+            if (later.role === "assistant" || later.role === "user") break;
+            if (later.role === "tool" && later.tool_call_id) {
+              turnToolResponses[later.tool_call_id as string] = later.content;
+            }
+          }
+
+          // Build a turn-specific map of tool call IDs to function names from this assistant message's toolCalls.
+          // This prevents cross-turn ID collisions where an identical tool_call_id reused in a later turn
+          // would otherwise overwrite the function name and content of an earlier turn (#e59118).
+          const turnTcID2Name: Record = {};
+          for (const tc of toolCalls) {
+            const fn = tc.function as { name?: string } | undefined;
+            if (tc.type === "function" && tc.id && fn?.name) {
+              turnTcID2Name[tc.id as string] = fn.name;
+            }
+          }
+
+          const resolveToolResponse = (id: string): unknown =>
+            turnToolResponses[id] !== undefined ? turnToolResponses[id] : toolResponses[id];
+          const hasToolResponse = (id: string): boolean => resolveToolResponse(id) !== undefined;
+
           // Check if there are actual tool responses in the next messages
           const hasSignaturelessTextResponses =
             contextualizeSignaturelessToolResponses &&
             toolCalls.some((tc) => {
               const id = tc.id as string;
-              return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id];
+              return tc.type === "function" && !resolvedSignatures.has(id) && hasToolResponse(id);
             });
           const hasActualResponses =
-            toolCallIds.some((fid) => toolResponses[fid]) || hasSignaturelessTextResponses;
+            toolCallIds.some((fid) => hasToolResponse(fid)) || hasSignaturelessTextResponses;
 
           if (hasActualResponses) {
             const toolParts: GeminiPart[] = [];
             for (const fid of toolCallIds) {
-              if (!toolResponses[fid]) continue;
+              const resp = resolveToolResponse(fid);
+              if (resp === undefined) continue;
               if (
                 !toolNameOptions.supportsSignatureBypass &&
                 contextualizeSignaturelessToolResponses &&
@@ -503,7 +531,7 @@ function openaiToGeminiBase(
               )
                 continue;
 
-              let name = tcID2Name[fid];
+              let name = turnTcID2Name[fid] || tcID2Name[fid];
               if (!name) {
                 const idParts = fid.split("-");
                 if (idParts.length > 2) {
@@ -514,8 +542,6 @@ function openaiToGeminiBase(
               }
               name = sanitizeToolName(name);
 
-              const resp = toolResponses[fid];
-
               toolParts.push({
                 functionResponse: {
                   ...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }),
@@ -538,10 +564,10 @@ function openaiToGeminiBase(
               for (const tc of toolCalls) {
                 const id = tc.id as string;
                 if (tc.type !== "function" || !id) continue;
-                if (!resolvedSignatures.has(id) && toolResponses[id]) {
+                const resp = resolveToolResponse(id);
+                if (!resolvedSignatures.has(id) && resp !== undefined) {
                   const fn = tc.function as { name?: string } | undefined;
-                  const name = tcID2Name[id] || fn?.name || "unknown";
-                  const resp = toolResponses[id];
+                  const name = turnTcID2Name[id] || tcID2Name[id] || fn?.name || "unknown";
                   toolParts.push({
                     text:
                       signaturelessToolCallMode === "text"
diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts
index c860a1acf0..7073e3b969 100644
--- a/tests/unit/translator-openai-to-gemini.test.ts
+++ b/tests/unit/translator-openai-to-gemini.test.ts
@@ -1648,3 +1648,161 @@ test("OpenAI -> Gemini allows thinkingConfig for unknown model (no spec)", () =>
   assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 5000);
   assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true);
 });
+
+test("OpenAI -> Gemini pairs tool calls and responses per turn without cross-turn ID collision mismatch", () => {
+  const result = openaiToCloudCodeGeminiRequest(
+    "gemini-3.8-flash-high",
+    {
+      messages: [
+        { role: "user", content: "read file" },
+        {
+          role: "assistant",
+          content: null,
+          tool_calls: [
+            {
+              id: "call_collision_123",
+              type: "function",
+              function: { name: "read_file", arguments: '{"path":"a.txt"}' },
+            },
+          ],
+        },
+        {
+          role: "tool",
+          tool_call_id: "call_collision_123",
+          content: "file content from turn 1",
+        },
+        { role: "user", content: "now run terminal command" },
+        {
+          role: "assistant",
+          content: null,
+          tool_calls: [
+            {
+              id: "call_collision_123",
+              type: "function",
+              function: { name: "run_terminal_command", arguments: '{"command":"ls"}' },
+            },
+          ],
+        },
+        {
+          role: "tool",
+          tool_call_id: "call_collision_123",
+          content: "terminal output from turn 2",
+        },
+        { role: "user", content: "done" },
+      ],
+    },
+    false
+  ) as any;
+
+  // Verify Turn 1 functionCall and functionResponse
+  const turn1Model = result.contents.find((c: any) =>
+    c.parts?.some((p: any) => p.functionCall?.name === "read_file")
+  );
+  assert.ok(turn1Model, "Turn 1 model functionCall must be read_file");
+
+  const turn1User = result.contents.find((c: any) =>
+    c.parts?.some(
+      (p: any) =>
+        p.functionResponse?.response?.result === "file content from turn 1" ||
+        p.functionResponse?.name === "read_file"
+    )
+  );
+  assert.ok(turn1User, "Turn 1 user functionResponse must exist");
+  const turn1Resp = turn1User.parts.find((p: any) => p.functionResponse);
+  assert.equal(
+    turn1Resp.functionResponse.name,
+    "read_file",
+    "Turn 1 functionResponse name must match functionCall name, not be overwritten by turn 2"
+  );
+  assert.equal(
+    turn1Resp.functionResponse.response.result,
+    "file content from turn 1",
+    "Turn 1 functionResponse must contain turn 1 output, not turn 2 output"
+  );
+
+  // Verify Turn 2 functionCall and functionResponse
+  const turn2User = result.contents.find((c: any) =>
+    c.parts?.some(
+      (p: any) =>
+        p.functionResponse?.response?.result === "terminal output from turn 2" ||
+        p.functionResponse?.name === "run_terminal_command"
+    )
+  );
+  assert.ok(turn2User, "Turn 2 user functionResponse must exist");
+  const turn2Resp = turn2User.parts.find((p: any) => p.functionResponse);
+  assert.equal(
+    turn2Resp.functionResponse.name,
+    "run_terminal_command",
+    "Turn 2 functionResponse name must match functionCall name"
+  );
+  assert.equal(
+    turn2Resp.functionResponse.response.result,
+    "terminal output from turn 2",
+    "Turn 2 functionResponse must contain turn 2 output"
+  );
+});
+
+test("OpenAI -> Gemini pairs tool calls and responses in context mode without ID collision mismatch", () => {
+  const result = openaiToGeminiRequest(
+    "gemini-2.5-flash",
+    {
+      messages: [
+        { role: "user", content: "read file" },
+        {
+          role: "assistant",
+          content: null,
+          tool_calls: [
+            {
+              id: "call_collision_999",
+              type: "function",
+              function: { name: "read_file", arguments: '{"path":"a.txt"}' },
+            },
+          ],
+        },
+        {
+          role: "tool",
+          tool_call_id: "call_collision_999",
+          content: "file content from turn 1",
+        },
+        { role: "user", content: "now run terminal command" },
+        {
+          role: "assistant",
+          content: null,
+          tool_calls: [
+            {
+              id: "call_collision_999",
+              type: "function",
+              function: { name: "run_terminal_command", arguments: '{"command":"ls"}' },
+            },
+          ],
+        },
+        {
+          role: "tool",
+          tool_call_id: "call_collision_999",
+          content: "terminal output from turn 2",
+        },
+        { role: "user", content: "done" },
+      ],
+    },
+    false,
+    null,
+    { signaturelessToolCallMode: "context" }
+  ) as any;
+
+  // In context mode without thought signatures, tool responses are emitted as context text
+  const textParts = result.contents.flatMap((c: any) =>
+    (c.parts || []).filter((p: any) => typeof p.text === "string").map((p: any) => p.text)
+  );
+  assert.ok(
+    textParts.some(
+      (t: string) => t.includes("read_file") && t.includes("file content from turn 1")
+    ),
+    "Turn 1 context text must pair read_file with its own turn 1 output"
+  );
+  assert.ok(
+    textParts.some(
+      (t: string) => t.includes("run_terminal_command") && t.includes("terminal output from turn 2")
+    ),
+    "Turn 2 context text must pair run_terminal_command with its own turn 2 output"
+  );
+});

From 80e2ca7c392b345d759dca58e05c2b8944bb73fe Mon Sep 17 00:00:00 2001
From: Dylan Haskins 
Date: Sat, 19 Sep 2026 15:04:42 +1200
Subject: [PATCH 23/33] fix: don't blanket-disable Claude tool-name prefix for
 non-Anthropic providers (#13856)

* fix: don't blanket-disable Claude tool-name prefix for non-Anthropic providers

The Issue #199/#618 fix that disables the proxy_ tool-name prefix for
requests landing in the general (non-CC-bridge, non-passthrough)
translation branch was scoped to "targetFormat === Claude", but that
condition is true for any provider translating into Claude's wire
format, not just genuine first-party Anthropic traffic. In practice
this let ordinary third-party tool names (e.g. GitHub Copilot's own
client-executed "web_fetch" function tool) pass through unprefixed
and collide with Claude's reserved tool namespace, getting rejected
upstream ("rejected tool(s): web_fetch") for any gh/claude-* model.

#618's actual traffic was real Claude Code (provider "claude") landing
in this fallback branch, so scoping the disable to provider === "claude"
(the same check already used a few lines above in the sibling
isClaudePassthrough branch) keeps that fix intact while restoring
correct prefixing for every other provider that merely targets
Claude's wire format.

Fixes #13835.

* docs: add changelog fragment for #13856
---
 ...56-github-copilot-web-fetch-tool-prefix.md |  1 +
 open-sse/handlers/chatCore.ts                 | 15 +++++++-
 tests/unit/chatcore-translation-paths.test.ts | 34 +++++++++++++++++++
 3 files changed, 49 insertions(+), 1 deletion(-)
 create mode 100644 changelog.d/fixes/13856-github-copilot-web-fetch-tool-prefix.md

diff --git a/changelog.d/fixes/13856-github-copilot-web-fetch-tool-prefix.md b/changelog.d/fixes/13856-github-copilot-web-fetch-tool-prefix.md
new file mode 100644
index 0000000000..762205297e
--- /dev/null
+++ b/changelog.d/fixes/13856-github-copilot-web-fetch-tool-prefix.md
@@ -0,0 +1 @@
+- **fix(translator):** Third-party tool names (e.g. GitHub Copilot's own `web_fetch` function tool) are no longer sent unprefixed to Claude-wire-format providers outside genuine first-party Anthropic traffic, fixing a `rejected tool(s): web_fetch` 400 for any `gh/claude-*` model ([#13856](https://github.com/diegosouzapw/OmniRoute/pull/13856)) — thanks @dylanhaskins
diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts
index 098eb3a26d..7434983c79 100644
--- a/open-sse/handlers/chatCore.ts
+++ b/open-sse/handlers/chatCore.ts
@@ -2494,8 +2494,21 @@ export async function handleChatCore({
       // conflicts with Claude OAuth tools, but in the passthrough path the tools
       // are already in Claude format. Applying the prefix turns "Bash" into
       // "proxy_Bash", which Claude rejects ("No such tool available: proxy_Bash").
+      //
+      // #618's actual traffic was real Claude Code talking to first-party Anthropic
+      // (provider "claude") reaching this fallback branch instead of the dedicated
+      // Claude Code bridge/passthrough branches above. Scoping the disable to
+      // `provider === "claude"` keeps that fix intact while no longer blanket-applying
+      // it to every other provider that merely targets Claude's wire format — a
+      // third-party provider's own ordinary (non-Claude-native) tool names, e.g.
+      // GitHub Copilot's own client-executed "web_fetch" tool, were passing through
+      // unprefixed here and colliding with Claude's reserved tool namespace, since
+      // they were never "already in Claude format" the way this comment assumes.
+      // See #13835.
       if (targetFormat === FORMATS.CLAUDE) {
-        translatedBody._disableToolPrefix = true;
+        if (provider === "claude") {
+          translatedBody._disableToolPrefix = true;
+        }
         normalizeClaudeUpstreamMessages(translatedBody);
       }
 
diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts
index 1d5a4eea21..12bab97f0f 100644
--- a/tests/unit/chatcore-translation-paths.test.ts
+++ b/tests/unit/chatcore-translation-paths.test.ts
@@ -1832,6 +1832,40 @@ test("chatCore sets Claude tool prefix disabling, strips empty Anthropic text bl
     ["hello"]
   );
 });
+// #13835: a third-party provider's own ordinary tool name (GitHub Copilot's client-executed
+// "web_fetch" function tool) must still get the proxy_ prefix even though this request lands
+// in the same general (non-claude-passthrough) branch as the "claude" provider test above —
+// only genuine first-party Anthropic traffic (provider "claude") should skip prefixing.
+test("chatCore still prefixes ordinary third-party tool names for non-Anthropic providers targeting Claude", async () => {
+  const { call } = await invokeChatCore({
+    provider: "github",
+    model: "claude-haiku-4.5",
+    endpoint: "/v1/chat/completions",
+    credentials: { apiKey: "gh-key", providerSpecificData: {} },
+    body: {
+      model: "github/claude-haiku-4.5",
+      messages: [{ role: "user", content: "fetch a url" }],
+      tools: [
+        {
+          type: "function",
+          function: {
+            name: "web_fetch",
+            description: "Fetches a URL from the internet.",
+            parameters: {
+              type: "object",
+              properties: { url: { type: "string" } },
+              required: ["url"],
+            },
+          },
+        },
+      ],
+    },
+    responseFormat: "claude",
+  });
+
+  assert.equal(call.body.tools[0].name, "proxy_web_fetch");
+  assert.equal(call.body._toolNameMap, undefined);
+});
 test("chatCore restores prefixed Claude passthrough tool names in upstream responses", async () => {
   const { result } = await invokeChatCore({
     provider: "claude",

From 2f6b5b18e83b1efa7421f451bab56ea6b5606a38 Mon Sep 17 00:00:00 2001
From: fewensa <37804932+fewensa@users.noreply.github.com>
Date: Sat, 19 Sep 2026 11:04:50 +0800
Subject: [PATCH 24/33] fix(auth): enforce blocked models in all-access mode
 (#13861)

---
 src/lib/db/apiKeys.ts                         |  9 ++++
 src/shared/utils/apiKeyPolicy.ts              |  3 ++
 src/sse/handlers/chat/comboTargetKeyPolicy.ts |  9 +++-
 tests/unit/api-key-policy.test.ts             | 31 ++++++++++++
 ...restricted-key-target-policy-12886.test.ts | 47 +++++++++++++++++++
 5 files changed, 98 insertions(+), 1 deletion(-)

diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts
index 037f14f2a7..f3d2a814e2 100644
--- a/src/lib/db/apiKeys.ts
+++ b/src/lib/db/apiKeys.ts
@@ -375,6 +375,15 @@ async function getModelPermissionCandidates(modelId: string): Promise
   return Array.from(candidates);
 }
 
+export async function isModelBlockedByPatterns(
+  blockedModels: string[] | null | undefined,
+  modelId: string
+): Promise {
+  if (!blockedModels?.length) return false;
+  const candidates = await getModelPermissionCandidates(modelId);
+  return blockedModels.some((pattern) => modelPatternMatches(pattern, candidates));
+}
+
 async function getPublishedModelLookupTarget(
   modelId: string
 ): Promise<{ providerId: string; modelId: string } | null> {
diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts
index b100cbcc7f..713c823184 100644
--- a/src/shared/utils/apiKeyPolicy.ts
+++ b/src/shared/utils/apiKeyPolicy.ts
@@ -75,6 +75,7 @@ export interface ApiKeyMetadata {
   name?: string;
   modelAccessMode?: "all" | "restricted";
   allowedModels?: string[];
+  blockedModels?: string[];
   allowedCombos?: string[];
   allowedConnections?: string[];
   allowedQuotas?: string[];
@@ -346,6 +347,7 @@ async function validateStandardRoutingTarget(
   const hasModelRestrictions =
     apiKeyInfo.modelAccessMode === "restricted" ||
     Boolean(apiKeyInfo.allowedModels?.length) ||
+    Boolean(apiKeyInfo.blockedModels?.length) ||
     apiKeyInfo.disableNonPublicModels === true;
   if (!requestedComboName && hasModelRestrictions && modelStr.startsWith("auto/")) {
     requestedComboName = modelStr;
@@ -587,6 +589,7 @@ async function validateModelAccess(context: PolicyContext): Promise {
+  const key = await createKeyWithPolicy({
+    modelAccessMode: "all",
+    allowedModels: [],
+    blockedModels: ["gpt-6*", "*/gpt-6*"],
+  });
+  const policy = await loadPolicy("all-mode-blocked-models");
+
+  const blocked = await policy.enforceApiKeyPolicy(
+    makePolicyRequest(key.key),
+    "mbrouter/gpt-6-codex"
+  );
+  assert.equal(blocked.rejection.status, 403);
+
+  const allowed = await policy.enforceApiKeyPolicy(
+    makePolicyRequest(key.key),
+    "mbrouter/gpt-5.6-sol"
+  );
+  assert.equal(allowed.rejection, null);
+
+  const metadata = await apiKeysDb.getApiKeyMetadata(key.key);
+  assert.ok(metadata);
+  const rerouted = await policy.validateApiKeyRoutingTarget(
+    makePolicyRequest(key.key),
+    key.key,
+    metadata,
+    "gpt-6"
+  );
+  assert.equal(rerouted?.status, 403);
+});
+
 test("enforceApiKeyPolicy returns Anthropic error envelope for /v1/messages model denials", async () => {
   const restrictedKey = await createKeyWithPolicy({
     allowedModels: ["cc/*"],
diff --git a/tests/unit/combo-restricted-key-target-policy-12886.test.ts b/tests/unit/combo-restricted-key-target-policy-12886.test.ts
index 2c6635e05c..ebe288122f 100644
--- a/tests/unit/combo-restricted-key-target-policy-12886.test.ts
+++ b/tests/unit/combo-restricted-key-target-policy-12886.test.ts
@@ -77,3 +77,50 @@ test("#12886: unrestricted key skips the gate", async () => {
   assert.equal(ok, true);
   assert.equal(called, 0);
 });
+
+test("blockedModels still filters combo targets in all-access mode", async () => {
+  let called = 0;
+  const ok = await comboTargetPassesKeyModelPolicy({
+    apiKey: KEY,
+    apiKeyInfo: {
+      modelAccessMode: "all",
+      allowedModels: [],
+      blockedModels: ["deepseek/*"],
+    },
+    requestedModelStr: COMBO,
+    targetModelStr: INNER,
+    isModelAllowedForKey: async () => {
+      called += 1;
+      return false;
+    },
+  });
+  assert.equal(ok, false);
+  assert.equal(called, 0);
+});
+
+test("blockedModels takes precedence without disabling allowed combo targets", async () => {
+  const apiKeyInfo = {
+    modelAccessMode: "restricted",
+    allowedModels: [COMBO],
+    blockedModels: ["anthropic/*"],
+  };
+  const checker = allowListChecker([COMBO]);
+
+  const allowed = await comboTargetPassesKeyModelPolicy({
+    apiKey: KEY,
+    apiKeyInfo,
+    requestedModelStr: COMBO,
+    targetModelStr: INNER,
+    isModelAllowedForKey: checker,
+  });
+  const blocked = await comboTargetPassesKeyModelPolicy({
+    apiKey: KEY,
+    apiKeyInfo,
+    requestedModelStr: COMBO,
+    targetModelStr: OTHER,
+    isModelAllowedForKey: checker,
+  });
+
+  assert.equal(allowed, true);
+  assert.equal(blocked, false);
+});

From a100e2c906bd6eb139ce31d71a805380b68c0201 Mon Sep 17 00:00:00 2001
From: Ai Jeremi 
Date: Sat, 19 Sep 2026 05:05:00 +0200
Subject: [PATCH 25/33] fix(oauth): skip a second POST of a consumed Claude
 refresh token (#13874)

HealthCheck queued behind a Layer 2 refresh still presented the old
refresh_token after serializeRefresh released, which burned the family
and then nulled the row. Re-check rotation inside the lane, record
Layer 2 rotations, and keep the Claude refresh token on invalid_grant.

Co-authored-by: Cursor 
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---
 .../claude-oauth-serialized-stale-refresh.md  |   1 +
 open-sse/services/tokenRefresh.ts             |  60 +++----
 src/lib/tokenHealthCheck.ts                   |  43 +++--
 .../token-refresh-race-comprehensive.test.ts  |  24 +++
 ...-refresh-serialized-stale-rotation.test.ts | 154 ++++++++++++++++++
 tests/unit/token-refresh-service.test.ts      |   3 +-
 ...enHealthCheck-unrecoverable-reread.test.ts |  65 ++++++++
 7 files changed, 290 insertions(+), 60 deletions(-)
 create mode 100644 changelog.d/fixes/claude-oauth-serialized-stale-refresh.md
 create mode 100644 tests/unit/token-refresh-serialized-stale-rotation.test.ts
 create mode 100644 tests/unit/tokenHealthCheck-unrecoverable-reread.test.ts

diff --git a/changelog.d/fixes/claude-oauth-serialized-stale-refresh.md b/changelog.d/fixes/claude-oauth-serialized-stale-refresh.md
new file mode 100644
index 0000000000..1ed817834f
--- /dev/null
+++ b/changelog.d/fixes/claude-oauth-serialized-stale-refresh.md
@@ -0,0 +1 @@
+- **fix(oauth):** stop posting a Claude refresh token that another in-process refresh already consumed. Re-check the rotation map and DB inside `serializeRefresh` (both Layer 1 and Layer 2), record Layer 2 rotations, re-read the connection uncached on `invalid_grant`, and keep the Claude `refreshToken` instead of nulling it into sticky `no_refresh_token`.
diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts
index 65b9bf5806..8ef561f43a 100755
--- a/open-sse/services/tokenRefresh.ts
+++ b/open-sse/services/tokenRefresh.ts
@@ -583,8 +583,11 @@ export async function getAccessToken(
   // the legacy `connectionId`-less path would silently swallow the callback,
   // leaving DB rows out of sync with rotated tokens (Codex/OpenAI). We still
   // resolve the promise to all waiters with the refreshed credentials.
-  const refreshPromise = serializeRefresh(provider, () =>
-    _getAccessTokenInternal(provider, credentials, log, proxyConfig)
+  const refreshPromise = _getAccessTokenWithStalenessCheck(
+    provider,
+    credentials,
+    log,
+    proxyConfig
   )
     .then(async (result) => {
       if (result?.accessToken && effectiveOnPersist) {
@@ -620,17 +623,19 @@ export async function getAccessToken(
 }
 
 /**
- * Internal helper: performs the DB staleness check then calls the actual refresh.
- * Only called from the per-connection mutex path (Layer 1 above).
+ * Internal helper: waits for the rotation-group lane, then re-checks freshness
+ * BEFORE the network POST. Lookup/DB re-read must live inside serializeRefresh:
+ * a HealthCheck that snapshotted the old refresh_token can sit on the lane
+ * while Layer 2 consumes it; checking only before the wait still POSTs the
+ * consumed token and burns the family (Claude/Anthropic, Auth0 Codex).
  */
 async function _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) {
-  // ROTATION MAP CHECK (codex-multi-auth pattern): if this refresh_token was
-  // rotated very recently (within ROTATION_MAP_TTL_MS), reuse the cached new
-  // tokens INSTEAD of hitting upstream. Auth0 treats re-use of a rotated token
-  // as a security event and revokes the entire token family — fatal for
-  // multi-account Codex setups. The in-memory rotation map catches this even
-  // when the caller bypasses the DB staleness path (no connectionId, stale
-  // in-memory credentials in retries, etc.).
+  return serializeRefresh(provider, () =>
+    _refreshWithFreshCredentials(provider, credentials, log, proxyConfig)
+  );
+}
+
+async function _refreshWithFreshCredentials(provider, credentials, log, proxyConfig) {
   const rotated = lookupRotation(provider, credentials.refreshToken);
   if (rotated) {
     log?.info?.(
@@ -640,11 +645,6 @@ async function _getAccessTokenWithStalenessCheck(provider, credentials, log, pro
     return rotated.result;
   }
 
-  // RACE CONDITION PREVENTION:
-  // If the credentials object in memory is stale (e.g. it waited in a semaphore while another
-  // request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI)
-  // to reject it with 'refresh_token_reused' and revoke the new token family.
-  // We MUST check if the DB has a newer token before proceeding with a network refresh.
   if (credentials.connectionId) {
     try {
       const { getProviderConnectionById } = await import("@/lib/db/providers");
@@ -659,31 +659,17 @@ async function _getAccessTokenWithStalenessCheck(provider, credentials, log, pro
             `Stale token detected in memory for ${provider}. Using refreshed token from DB.`
           );
 
-          // If the DB token is not expired, we can just return it!
           if (dbExpiresAt > now + 60000) {
-            // 60 seconds buffer
             log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`);
             return {
               accessToken: dbConnection.accessToken,
               refreshToken: dbConnection.refreshToken,
-              // Return absolute expiresAt so downstream callers do NOT recompute lifetime
-              // from a relative expiresIn value (which would incorrectly extend the TTL).
-              // expiresIn intentionally omitted here.
               expiresAt: dbConnection.expiresAt,
             };
-          } else {
-            // DB token is also expired, but it's the NEWEST one. We must use it to refresh.
-            credentials.refreshToken = dbConnection.refreshToken;
-            credentials.accessToken = dbConnection.accessToken;
           }
+          credentials.refreshToken = dbConnection.refreshToken;
+          credentials.accessToken = dbConnection.accessToken;
         }
-        // NOTE: Fix F (skip when DB == memory and DB > now+60s) was intentionally
-        // removed. The caller (checkAndRefreshToken) already decided to refresh
-        // because the token is within TOKEN_EXPIRY_BUFFER_MS of expiry. Re-checking
-        // with a tighter 60-second window here would skip legitimate refreshes and
-        // let near-expired tokens hit the upstream. Layer-1 mutex (per-connection)
-        // and Layer-2 dedup (token-hash) already prevent concurrent refreshes for
-        // the import-burst scenario.
       }
     } catch (e) {
       log?.warn?.(
@@ -694,16 +680,8 @@ async function _getAccessTokenWithStalenessCheck(provider, credentials, log, pro
   }
 
   const oldRefreshToken = credentials.refreshToken;
-  // Front 1: serialize the network refresh across all connections of the same
-  // rotation group (e.g. Codex+openai share one Auth0 client) so two sibling
-  // accounts never refresh concurrently and trip Auth0 family revocation.
-  const result = await serializeRefresh(provider, () =>
-    _getAccessTokenInternal(provider, credentials, log, proxyConfig)
-  );
+  const result = await _getAccessTokenInternal(provider, credentials, log, proxyConfig);
 
-  // Record the rotation so subsequent stale callers can be redirected to the
-  // new tokens without re-hitting upstream (which would trigger Auth0 family
-  // revocation). Only records when the refresh actually rotated the token.
   if (
     result &&
     typeof result === "object" &&
diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts
index 6d27bd44d4..f9c380a95b 100644
--- a/src/lib/tokenHealthCheck.ts
+++ b/src/lib/tokenHealthCheck.ts
@@ -11,7 +11,11 @@
  * updates the DB, and logs the result.
  */
 
-import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers";
+import {
+  getProviderConnections,
+  getProviderConnectionById,
+  updateProviderConnection,
+} from "@/lib/db/providers";
 import { getCachedProviderConnectionById } from "@/lib/db/readCache";
 import { getSettings } from "@/lib/db/settings";
 import { resolveGuardedProxyConfig } from "@/lib/tokenHealthCheckProxyGuard";
@@ -42,6 +46,24 @@ const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds (restored — #77
 const DEFAULT_BATCH_SIZE = 20;
 const DEFAULT_HEALTH_CHECK_INTERVAL_MIN = 60; // default per-connection interval
 const EXPIRED_RETRY_MAX = 3; // max retry attempts for expired connections before giving up
+const ROTATING_REFRESH_PROVIDERS = new Set([
+  "codex",
+  "openai",
+  "kimi-coding",
+  "cline",
+  "kiro",
+  "amazon-q",
+  "gitlab-duo",
+  "claude",
+  "openference",
+]);
+
+export function shouldNullRefreshTokenAfterUnrecoverable(provider: unknown): boolean {
+  const id = String(provider || "").toLowerCase();
+  if (id === "claude") return false;
+  return ROTATING_REFRESH_PROVIDERS.has(id);
+}
+
 const EXPIRED_RETRY_BACKOFF_MIN = 5; // backoff between expired retries (minutes)
 
 function isBuildProcess(): boolean {
@@ -897,17 +919,6 @@ export async function checkConnection(conn) {
   // and is the root cause of "adding account B invalidates account A" reports.
   // The interval path is kept ONLY for non-rotating providers where token state can
   // drift silently (e.g. cookie-based, opaque sessions without expires_at).
-  const ROTATING_REFRESH_PROVIDERS = new Set([
-    "codex",
-    "openai",
-    "kimi-coding",
-    "cline",
-    "kiro",
-    "amazon-q",
-    "gitlab-duo",
-    "claude",
-    "openference",
-  ]);
   const isRotatingProvider = ROTATING_REFRESH_PROVIDERS.has(
     String(conn.provider || "").toLowerCase()
   );
@@ -1097,7 +1108,7 @@ export async function checkConnection(conn) {
   // Once used, the old token is permanently invalidated.
   // Retrying will never succeed → deactivate and stop the loop.
   if (isUnrecoverableRefreshError(result)) {
-    const currentConnection = await getCachedProviderConnectionById(conn.id);
+    const currentConnection = await getProviderConnectionById(conn.id);
     const credentialsChangedSinceSweep =
       !!currentConnection &&
       (currentConnection.refreshToken !== attemptedRefreshToken ||
@@ -1157,11 +1168,7 @@ export async function checkConnection(conn) {
       // gemini) the stored refresh_token is the user's only recovery
       // artifact — nulling it caused #3679 (the connection reports "No valid refresh
       // token available" and can never recover even after re-activation). Preserve it.
-      // PRESERVE_REFRESH_TOKEN_PROVIDERS (Claude) opt out too: nulling on the first
-      // failure makes the #11414 retry budget above unreachable (#13183).
-      ...(isRotatingProvider && !preservesRefreshTokenOnUnrecoverable(conn.provider)
-        ? { refreshToken: null }
-        : {}),
+      ...(shouldNullRefreshTokenAfterUnrecoverable(conn.provider) ? { refreshToken: null } : {}),
     });
     logError(
       `${LOG_PREFIX} ✗ ${conn.provider}/${getConnectionLogLabel(conn)} — ` +
diff --git a/tests/unit/token-refresh-race-comprehensive.test.ts b/tests/unit/token-refresh-race-comprehensive.test.ts
index 070cd85601..37df7ba9f4 100644
--- a/tests/unit/token-refresh-race-comprehensive.test.ts
+++ b/tests/unit/token-refresh-race-comprehensive.test.ts
@@ -176,3 +176,27 @@ test("Imports: base.ts imports runWithOnPersist from open-sse tokenRefresh", asy
   assert.match(src, /runWithOnPersist/);
   assert.match(src, /from\s+"\.\.\/services\/tokenRefresh\.ts"/);
 });
+
+
+test("serialized refresh re-checks rotation inside the lane, not before waiting", async () => {
+  const src = await read("open-sse/services/tokenRefresh.ts");
+  const start = src.indexOf("async function _getAccessTokenWithStalenessCheck");
+  const inner = src.indexOf("async function _refreshWithFreshCredentials");
+  assert.ok(start >= 0 && inner > start, "staleness helper must wrap the freshness re-check");
+  const wrapper = src.slice(start, inner);
+  assert.match(
+    wrapper,
+    /serializeRefresh\(provider,\s*\(\)\s*=>/,
+    "the network POST must stay behind serializeRefresh"
+  );
+  assert.match(wrapper, /_refreshWithFreshCredentials/);
+  assert.doesNotMatch(
+    wrapper,
+    /lookupRotation/,
+    "lookupRotation before serializeRefresh is the race that burns a Claude refresh token"
+  );
+  const body = src.slice(inner, inner + 2500);
+  assert.match(body, /lookupRotation\(/);
+  assert.match(body, /recordRotation\(/);
+  assert.match(body, /_getAccessTokenInternal\(/);
+});
diff --git a/tests/unit/token-refresh-serialized-stale-rotation.test.ts b/tests/unit/token-refresh-serialized-stale-rotation.test.ts
new file mode 100644
index 0000000000..4c9f468a43
--- /dev/null
+++ b/tests/unit/token-refresh-serialized-stale-rotation.test.ts
@@ -0,0 +1,154 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const tokenRefresh = await import("../../open-sse/services/tokenRefresh.ts");
+const { __resetRefreshSerializerForTest } = await import("../../open-sse/services/refreshSerializer.ts");
+const { lookupRotation } = await import("../../open-sse/services/tokenRefresh/rotationMap.ts");
+
+const { getAccessToken } = tokenRefresh;
+
+type LogLevel = "debug" | "info" | "warn" | "error";
+type LogEntry = { level: LogLevel; message: unknown };
+
+function createLog() {
+  const entries: LogEntry[] = [];
+  const push = (level: LogLevel, args: unknown[]) => {
+    entries.push({ level, message: args[1] });
+  };
+  return {
+    entries,
+    debug: (...args: unknown[]) => push("debug", args),
+    info: (...args: unknown[]) => push("info", args),
+    warn: (...args: unknown[]) => push("warn", args),
+    error: (...args: unknown[]) => push("error", args),
+  };
+}
+
+function jsonResponse(body: unknown, status = 200) {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { "content-type": "application/json" },
+  });
+}
+
+function bodyToString(body: BodyInit | null | undefined) {
+  if (typeof body === "string") return body;
+  if (body instanceof URLSearchParams) return body.toString();
+  return String(body ?? "");
+}
+
+function refreshTokenFromBody(body: BodyInit | null | undefined) {
+  return new URLSearchParams(bodyToString(body)).get("refresh_token");
+}
+
+async function withMockedFetch(fetchImpl: typeof fetch, fn: () => Promise) {
+  const originalFetch = globalThis.fetch;
+  globalThis.fetch = fetchImpl;
+  try {
+    return await fn();
+  } finally {
+    globalThis.fetch = originalFetch;
+  }
+}
+
+function resetRefreshState() {
+  tokenRefresh._clearTokenRotationMap();
+  __resetRefreshSerializerForTest();
+}
+
+test.beforeEach(() => {
+  resetRefreshState();
+});
+
+test("getAccessToken_Layer1QueuedBehindLayer2_DoesNotPostConsumedClaudeRefreshToken", async () => {
+  const previousSpacing = process.env.CODEX_REFRESH_SPACING_MS;
+  process.env.CODEX_REFRESH_SPACING_MS = "0";
+  const log = createLog();
+  const presented: string[] = [];
+  let firstPostEntered = false;
+  let releaseFirstPost!: () => void;
+  const holdFirstPost = new Promise((resolve) => {
+    releaseFirstPost = resolve;
+  });
+
+  try {
+    await withMockedFetch(async (_url, options = {}) => {
+      const presentedToken = refreshTokenFromBody(options.body);
+      presented.push(presentedToken || "");
+      if (presentedToken === "old-rt" && !firstPostEntered) {
+        firstPostEntered = true;
+        await holdFirstPost;
+        return jsonResponse({
+          access_token: "new-access",
+          refresh_token: "new-rt",
+          expires_in: 28800,
+        });
+      }
+      if (presentedToken === "old-rt") {
+        return jsonResponse({ error: "invalid_grant", error_description: "refresh_token_reused" }, 400);
+      }
+      throw new Error(`unexpected refresh_token ${presentedToken}`);
+    }, async () => {
+      const layer2 = getAccessToken("claude", { refreshToken: "old-rt" }, log);
+      await new Promise((resolve, reject) => {
+        const started = Date.now();
+        const tick = () => {
+          if (firstPostEntered) {
+            resolve();
+            return;
+          }
+          if (Date.now() - started > 2000) {
+            reject(new Error("Layer 2 never reached the Anthropic token endpoint"));
+            return;
+          }
+          setTimeout(tick, 5);
+        };
+        tick();
+      });
+
+      const layer1 = getAccessToken(
+        "claude",
+        { connectionId: "healthcheck-conn", refreshToken: "old-rt" },
+        log
+      );
+      await new Promise((resolve) => setTimeout(resolve, 30));
+      releaseFirstPost();
+
+      const [layer2Result, layer1Result] = await Promise.all([layer2, layer1]);
+
+      assert.deepEqual(presented, ["old-rt"], "the consumed refresh token must be POSTed once");
+      assert.equal(layer2Result?.accessToken, "new-access");
+      assert.equal(layer2Result?.refreshToken, "new-rt");
+      assert.equal(layer1Result?.accessToken, "new-access");
+      assert.equal(layer1Result?.refreshToken, "new-rt");
+      assert.notEqual(
+        (layer1Result as { error?: string } | null)?.error,
+        "unrecoverable_refresh_error",
+        "Layer 1 must reuse the rotated tokens instead of burning the family"
+      );
+    });
+  } finally {
+    if (previousSpacing === undefined) delete process.env.CODEX_REFRESH_SPACING_MS;
+    else process.env.CODEX_REFRESH_SPACING_MS = previousSpacing;
+    resetRefreshState();
+  }
+});
+
+test("getAccessToken_Layer2Refresh_RecordsRotationForTheConsumedToken", async () => {
+  const log = createLog();
+
+  await withMockedFetch(async () => {
+    return jsonResponse({
+      access_token: "layer2-access",
+      refresh_token: "layer2-new-rt",
+      expires_in: 28800,
+    });
+  }, async () => {
+    const result = await getAccessToken("claude", { refreshToken: "layer2-old-rt" }, log);
+    assert.equal(result?.refreshToken, "layer2-new-rt");
+    const cached = lookupRotation("claude", "layer2-old-rt");
+    assert.ok(cached, "Layer 2 must record the rotation so a later stale caller can skip upstream");
+    assert.equal(cached.result.refreshToken, "layer2-new-rt");
+    assert.equal(cached.result.accessToken, "layer2-access");
+  });
+});
diff --git a/tests/unit/token-refresh-service.test.ts b/tests/unit/token-refresh-service.test.ts
index aa675efb2b..ffdf97d9f8 100644
--- a/tests/unit/token-refresh-service.test.ts
+++ b/tests/unit/token-refresh-service.test.ts
@@ -962,9 +962,10 @@ test("getAccessToken cleans the in-flight cache after resolve and separates diff
             log
           );
 
-          assert.equal(fetchCount, 3);
+          assert.equal(fetchCount, 2, "same consumed refresh token is served from the rotation map");
           assert.equal(first.accessToken, "access-refresh-a");
           assert.equal(second.accessToken, "access-refresh-a");
+          assert.equal(second.refreshToken, "next-refresh-a");
           assert.equal(third.accessToken, "access-refresh-b");
         }
       );
diff --git a/tests/unit/tokenHealthCheck-unrecoverable-reread.test.ts b/tests/unit/tokenHealthCheck-unrecoverable-reread.test.ts
new file mode 100644
index 0000000000..3547d6894b
--- /dev/null
+++ b/tests/unit/tokenHealthCheck-unrecoverable-reread.test.ts
@@ -0,0 +1,65 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+
+const root = path.resolve(import.meta.dirname, "../..");
+const src = await readFile(path.join(root, "src/lib/tokenHealthCheck.ts"), "utf8");
+
+function unrecoverableSlice() {
+  const idx = src.indexOf("if (isUnrecoverableRefreshError(result))");
+  assert.ok(idx >= 0, "unrecoverable refresh branch must exist");
+  return src.slice(idx, idx + 3500);
+}
+
+test("tokenHealthCheck_UnrecoverableRefresh_RereadsConnectionUncached", () => {
+  const slice = unrecoverableSlice();
+  assert.match(
+    slice,
+    /getProviderConnectionById\(/,
+    "a concurrent Layer 2 persist can land between the sweep snapshot and invalid_grant; the cached row still holds the consumed refresh token and would skip the changed-since-sweep guard"
+  );
+  assert.doesNotMatch(
+    slice,
+    /getCachedProviderConnectionById/,
+    "the 5s connection-by-id cache is how credentialsChangedSinceSweep missed the just-persisted rotation"
+  );
+});
+
+test("tokenHealthCheck_UnrecoverableRefresh_DoesNotNullClaudeRefreshToken", () => {
+  const slice = unrecoverableSlice();
+  assert.match(
+    slice,
+    /shouldNullRefreshTokenAfterUnrecoverable/,
+    "Claude rotating tokens must not be wiped on the first invalid_grant; the live access token plus the new refresh token in DB are still recoverable"
+  );
+  assert.doesNotMatch(
+    slice,
+    /\.\.\.\(isRotatingProvider\s*\?\s*\{\s*refreshToken:\s*null\s*\}\s*:\s*\{\}\)/,
+    "the blanket rotating-provider null is what turns a dual-refresh race into sticky no_refresh_token"
+  );
+});
+
+test("shouldNullRefreshTokenAfterUnrecoverable_Claude_IsFalse", async () => {
+  const { shouldNullRefreshTokenAfterUnrecoverable, stopTokenHealthCheck } =
+    await import("../../src/lib/tokenHealthCheck.ts");
+  stopTokenHealthCheck();
+  assert.equal(shouldNullRefreshTokenAfterUnrecoverable("claude"), false);
+  assert.equal(shouldNullRefreshTokenAfterUnrecoverable("Claude"), false);
+});
+
+test("shouldNullRefreshTokenAfterUnrecoverable_Codex_IsTrue", async () => {
+  const { shouldNullRefreshTokenAfterUnrecoverable, stopTokenHealthCheck } =
+    await import("../../src/lib/tokenHealthCheck.ts");
+  stopTokenHealthCheck();
+  assert.equal(shouldNullRefreshTokenAfterUnrecoverable("codex"), true);
+  assert.equal(shouldNullRefreshTokenAfterUnrecoverable("openai"), true);
+});
+
+test("shouldNullRefreshTokenAfterUnrecoverable_Google_IsFalse", async () => {
+  const { shouldNullRefreshTokenAfterUnrecoverable, stopTokenHealthCheck } =
+    await import("../../src/lib/tokenHealthCheck.ts");
+  stopTokenHealthCheck();
+  assert.equal(shouldNullRefreshTokenAfterUnrecoverable("gemini"), false);
+  assert.equal(shouldNullRefreshTokenAfterUnrecoverable("antigravity"), false);
+});

From 736ade44994bce999f5c7221b16f57cfb4ad9c41 Mon Sep 17 00:00:00 2001
From: John Costa 
Date: Fri, 18 Sep 2026 20:05:07 -0700
Subject: [PATCH 26/33] feat(sse): learn hard request caps from 429 bodies and
 pace under them (#13895)

* feat(sse): learn hard request caps from 429 bodies and pace under them

Providers such as TokenRouter reject bursts with prose like "Maximum 5
requests within 1 minutes" and send no rate-limit headers, so the limiter
never learned the ceiling and kept racing into it. Every 429 also evicts
the limiter, and the rebuilt one carried no pacing at all.

updateFromResponseBody now parses that phrasing (plus "N requests per
minute", "N requests per M seconds" and "N RPM") into a per-window cap,
applies it to the limiter as an empty reservoir that refills N every
window with calls spread window/N apart, and records it in
learnedRateLimits. getLimiter reapplies a learned cap whenever the limiter
is rebuilt, and loadPersistedLimits restores it after a restart, unless
the connection has an explicit RPM override.

Also pins the capacity-503 classification for TokenRouter's "system
cpu/disk overloaded" bodies as model_capacity with backoff, and prunes the
no-unused-vars allowlist entry for rateLimitManager.ts that this change
made obsolete.

Fixes #13594

* fix(sse): require a cap word before rpm figures and gate every cap path on the queue budget

`RPM_RE` accepted any " rpm" so a usage readout like "current usage:
4 rpm" pinned the connection to one request every 15s. It now shares the
cap-word prefix and made/sent/used guard with the verbose pattern, so a
bare usage figure is not read as a ceiling.

The queue-budget refusal only guarded the learn path. A cap learned under
a generous maxWaitMs was still re-applied by getLimiter on every rebuild
and by loadPersistedLimits after a restart once the budget shrank, turning
every request into a local queue timeout. The check now lives in
capSettingsWithinBudget(), which all three paths call; a refused cap stays
recorded and is retried the next time the limiter is built.
---
 ...13594-learned-request-cap-from-429-body.md |   1 +
 config/quality/eslint-suppressions.json       |   5 -
 open-sse/services/rateLimitManager.ts         | 180 ++++++-
 .../services/rateLimitManager/requestCap.ts   | 102 ++++
 .../unit/rate-limit-learned-cap-13594.test.ts | 444 ++++++++++++++++++
 5 files changed, 716 insertions(+), 16 deletions(-)
 create mode 100644 changelog.d/features/13594-learned-request-cap-from-429-body.md
 create mode 100644 open-sse/services/rateLimitManager/requestCap.ts
 create mode 100644 tests/unit/rate-limit-learned-cap-13594.test.ts

diff --git a/changelog.d/features/13594-learned-request-cap-from-429-body.md b/changelog.d/features/13594-learned-request-cap-from-429-body.md
new file mode 100644
index 0000000000..4d1c274834
--- /dev/null
+++ b/changelog.d/features/13594-learned-request-cap-from-429-body.md
@@ -0,0 +1 @@
+- **feat(sse): learn hard request caps stated in 429 bodies and pace under them.** Providers such as TokenRouter reject bursts with prose like `Maximum 5 requests within 1 minutes` and no rate-limit headers, so the limiter never learned the ceiling and kept racing into it; every 429 also tore the limiter down and rebuilt it with no pacing. `updateFromResponseBody` now parses that phrasing (and `N requests per minute`, `N requests per M seconds`, `N RPM`) into a per-window cap, applies it to the limiter as an empty reservoir that refills `N` every window with calls spread `window / N` apart, and records it in `learnedRateLimits`. A learned cap is reapplied whenever the limiter is rebuilt after a 429 and when limits are restored at startup, unless the connection has an explicit RPM override. Fixes [#13594](https://github.com/diegosouzapw/OmniRoute/issues/13594).
diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json
index dd4665884d..84d016a315 100644
--- a/config/quality/eslint-suppressions.json
+++ b/config/quality/eslint-suppressions.json
@@ -560,11 +560,6 @@
       "count": 1
     }
   },
-  "open-sse/services/rateLimitManager.ts": {
-    "@typescript-eslint/no-unused-vars": {
-      "count": 1
-    }
-  },
   "open-sse/services/routing/index.ts": {
     "@typescript-eslint/no-unused-vars": {
       "count": 1
diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts
index 1546351865..8042e68655 100644
--- a/open-sse/services/rateLimitManager.ts
+++ b/open-sse/services/rateLimitManager.ts
@@ -11,6 +11,13 @@
 import Bottleneck from "bottleneck";
 import { applyBottleneckDoExpirePatch, applyBottleneckHeartbeatPatch } from "./bottleneckPatch.ts";
 import { parseRetryAfterFromBody } from "./accountFallback.ts";
+import {
+  isValidRequestCap,
+  parseRequestCapFromBody,
+  requestCapSettings,
+  type RequestCap,
+  type RequestCapSettings,
+} from "./rateLimitManager/requestCap.ts";
 import { getAntigravityQuotaFamily } from "./antigravityQuotaFamily.ts";
 import { getProviderCategory } from "../config/providerRegistry.ts";
 import { getCodexRateLimitKey } from "../executors/codex.ts";
@@ -48,6 +55,11 @@ interface LearnedLimitEntry {
   limit?: number;
   remaining?: number;
   minTime?: number;
+  // Hard cap stated in a 429 body ("Maximum N requests within M minutes").
+  // Unlike header-learned values it is applied whenever the limiter is
+  // (re)built, so it survives the eviction every 429 triggers and a restart.
+  capRequests?: number;
+  capWindowMs?: number;
 }
 
 interface LimiterUpdateSettings {
@@ -173,6 +185,49 @@ function resolveMinTime(override: number | undefined | null): number {
   return resolveOverride(override, 0);
 }
 
+function hasRpmOverride(connectionId: string): boolean {
+  const rpm = connectionRateLimitOverrides.get(connectionId)?.rpm;
+  return typeof rpm === "number" && rpm > 0;
+}
+
+// A cap learned from a 429 body spaces calls at window/N, but never closer
+// than the operator's global or per-connection minTime floor (#9763).
+function capMinTimeWithFloor(connectionId: string, capMinTime: number): number {
+  return Math.max(
+    resolveMinTime(currentRequestQueueSettings.minTimeBetweenRequestsMs),
+    resolveMinTime(connectionRateLimitOverrides.get(connectionId)?.minTime),
+    capMinTime
+  );
+}
+
+/**
+ * Limiter settings for a request cap, or null when the cap cannot be honoured:
+ * a cap that spaces calls further apart than a request may wait in the queue
+ * would turn every request into a local queue timeout. Every path that applies
+ * a cap (learning it, building a limiter, restoring persistence) goes through
+ * here, so a cap learned under a generous queue budget cannot land after the
+ * budget shrinks. A refused body-stated cap is never learned; a cap already
+ * recorded stays recorded, like one held back by an rpm override, and is
+ * retried the next time the limiter is built.
+ */
+function capSettingsWithinBudget(
+  provider: string,
+  connectionId: string,
+  cap: RequestCap,
+  source: "body-stated" | "learned" | "persisted"
+): RequestCapSettings | null {
+  const settings = requestCapSettings(cap);
+  settings.minTime = capMinTimeWithFloor(connectionId, settings.minTime);
+  const queueBudgetMs = resolveRequestQueueMaxWaitMs(provider, undefined, connectionId);
+  if (settings.minTime > queueBudgetMs) {
+    warnRateLimit(
+      `[RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — ignoring ${source} cap of ${cap.requests} request(s) per ${Math.ceil(cap.windowMs / 1000)}s: ${settings.minTime}ms between requests exceeds the ${queueBudgetMs}ms queue budget (raise the request queue maxWaitMs to honour it)`
+    );
+    return null;
+  }
+  return settings;
+}
+
 // Resolve a maxConcurrent override. 0 or missing means "effectively infinite".
 function resolveMaxConcurrent(override: number | undefined | null): number {
   return resolveOverride(override, EFFECTIVELY_INFINITE_CONCURRENCY);
@@ -484,17 +539,37 @@ export function refreshConnectionRateLimits(connectionId, overrides) {
     connectionRateLimitOverrides.set(connectionId, overrides);
   }
   clearPreservedReplacementSettings(connectionId);
+  // The operator just restated this connection's limits: forget any cap
+  // learned from a 429 body so a bad or stale one cannot outlive the change.
+  let strippedCap = false;
+  for (const [key, entry] of learnedLimits) {
+    if (entry.connectionId === connectionId && entry.capRequests) {
+      const { capRequests: _cap, capWindowMs: _window, ...rest } = entry;
+      learnedLimits.set(key, rest);
+      strippedCap = true;
+    }
+  }
+  if (strippedCap) schedulePersist();
   // Evict limiters referencing this connection so they get recreated on next use
   for (const [key, limiter] of Array.from(limiters)) {
     if (key.includes(connectionId)) {
-      limiters.delete(key);
-      limiterWatchdog.forget(limiter);
-      limiterLastUsed.delete(key);
-      trackAsyncOperation(limiter.disconnect());
+      evictLimiter(key, limiter);
     }
   }
 }
 
+// Drop a limiter from the cache so the next request builds a fresh one. Do NOT
+// call limiter.stop(): it permanently rejects future .schedule() calls.
+// disconnect() releases Bottleneck's heartbeat timer without poisoning the
+// instance for jobs still in flight.
+function evictLimiter(key: string, limiter: Bottleneck): void {
+  limiters.delete(key);
+  limiterWatchdog.forget(limiter);
+  limiterLastUsed.delete(key);
+  preservedReplacementSettings.delete(key);
+  trackAsyncOperation(limiter.disconnect());
+}
+
 /**
  * Get or create a limiter for a given provider+connection combination
  */
@@ -548,6 +623,26 @@ function getLimiter(provider, connectionId, model = null) {
         }
         // TODO: TPM/TPD integration requires separate token and request buckets.
       }
+      const learned = learnedLimits.get(key);
+      if (learned?.capRequests && learned.capWindowMs && !hasRpmOverride(connectionId)) {
+        // A cap learned from a 429 body outranks the global defaults but not an
+        // explicit per-connection override (#13594).
+        const cap = capSettingsWithinBudget(
+          provider,
+          connectionId,
+          { requests: learned.capRequests, windowMs: learned.capWindowMs },
+          "learned"
+        );
+        if (cap) {
+          defaults.minTime = cap.minTime;
+          defaults.reservoir = cap.reservoirRefreshAmount;
+          defaults.reservoirRefreshAmount = cap.reservoirRefreshAmount;
+          defaults.reservoirRefreshInterval = cap.reservoirRefreshInterval;
+          logRateLimit(
+            `📏 [RATE-LIMIT] ${key} — applying learned cap: ${learned.capRequests} request(s) per ${Math.ceil(learned.capWindowMs / 1000)}s`
+          );
+        }
+      }
       options = { ...defaults, id: key };
     }
     const limiter = limiterFactory(options);
@@ -869,11 +964,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
     // without permanently poisoning the instance for any remaining in-flight jobs.
     // Without disconnect() here, every 429 leaks a heartbeat timer until GC reclaims
     // the abandoned Bottleneck; under sustained quota pressure that is a real leak.
-    limiters.delete(limiterKey);
-    limiterWatchdog.forget(limiter);
-    limiterLastUsed.delete(limiterKey);
-    preservedReplacementSettings.delete(limiterKey);
-    trackAsyncOperation(limiter.disconnect());
+    evictLimiter(limiterKey, limiter);
     return;
   }
 
@@ -999,14 +1090,20 @@ function recordLearnedLimit(
   model: string | null = null
 ) {
   const key = getLimiterKey(provider, connectionId, model);
+  // Merge so a header-learned update does not drop a body-learned cap (or vice versa).
   learnedLimits.set(key, {
+    ...learnedLimits.get(key),
     ...limits,
     provider,
     connectionId,
     lastUpdated: Date.now(),
   });
 
-  // Debounce: save at most once per PERSIST_DEBOUNCE_MS
+  schedulePersist();
+}
+
+// Debounce: save at most once per PERSIST_DEBOUNCE_MS
+function schedulePersist(): void {
   if (!persistTimer) {
     persistTimer = setTimeout(async () => {
       persistTimer = null;
@@ -1110,6 +1207,14 @@ async function loadPersistedLimits() {
       const limit = toNumber(data.limit, 0);
       const remaining = toNumber(data.remaining, 0);
       const minTime = toNumber(data.minTime, 0);
+      const capRequests = toNumber(data.capRequests, 0);
+      const capWindowMs = toNumber(data.capWindowMs, 0);
+      const hasCap = isValidRequestCap({ requests: capRequests, windowMs: capWindowMs });
+      if (!hasCap && (data.capRequests !== undefined || data.capWindowMs !== undefined)) {
+        warnRateLimit(
+          `[RATE-LIMIT] ${key} — dropping persisted cap with invalid shape (${String(data.capRequests)} per ${String(data.capWindowMs)}ms)`
+        );
+      }
 
       learnedLimits.set(key, {
         provider,
@@ -1118,12 +1223,24 @@ async function loadPersistedLimits() {
         ...(limit > 0 ? { limit } : {}),
         ...(remaining >= 0 ? { remaining } : {}),
         ...(minTime >= 0 ? { minTime } : {}),
+        ...(hasCap ? { capRequests, capWindowMs } : {}),
       });
 
       // Apply to limiter if it exists and has rate limit enabled
       if (connectionId && enabledConnections.has(connectionId)) {
         const limiter = limiters.get(key);
-        if (limiter && limit > 0) {
+        if (limiter && hasCap && !hasRpmOverride(connectionId)) {
+          const cap = capSettingsWithinBudget(
+            provider,
+            connectionId,
+            { requests: capRequests, windowMs: capWindowMs },
+            "persisted"
+          );
+          if (cap) {
+            updateLimiterSettings(limiter, cap);
+            count++;
+          }
+        } else if (limiter && limit > 0) {
           const inferredMinTime = minTime || Math.max(0, Math.floor(60000 / limit) - 10);
           updateLimiterSettings(limiter, { minTime: inferredMinTime });
           count++;
@@ -1167,4 +1284,45 @@ export function updateFromResponseBody(provider, connectionId, responseBody, sta
       reservoirRefreshInterval: retryAfterMs,
     });
   }
+
+  if (status !== 429) return;
+
+  const cap = parseRequestCapFromBody(responseBody);
+  if (!cap) return;
+
+  // Leave a cap the queue budget cannot honour unlearned.
+  const settings = capSettingsWithinBudget(provider, connectionId, cap, "body-stated");
+  if (!settings) return;
+
+  // The 429 itself means the window is spent. Rebuild the limiter so the cap
+  // is in its constructor options and its reservoir clock starts now (an
+  // updateSettings() alone would refill on the next heartbeat), then empty the
+  // reservoir: it refills `requests` after one window and calls stay spaced.
+  const limiterKey = getLimiterKey(provider, connectionId, model);
+  const existing = limiters.get(limiterKey);
+  if (existing) {
+    evictLimiter(limiterKey, existing);
+  }
+  recordLearnedLimit(
+    provider,
+    connectionId,
+    {
+      limit: Math.max(1, Math.round((cap.requests * 60_000) / cap.windowMs)),
+      minTime: settings.minTime,
+      capRequests: cap.requests,
+      capWindowMs: cap.windowMs,
+    },
+    model
+  );
+  const limiter = getLimiter(provider, connectionId, model);
+  if (hasRpmOverride(connectionId)) {
+    // The operator's rpm override keeps its pacing; the cap stays recorded so
+    // it applies if the override is removed later. The window is still spent.
+    updateLimiterSettings(limiter, { reservoir: 0 });
+    return;
+  }
+  logRateLimit(
+    `🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — body-stated cap: ${cap.requests} request(s) per ${Math.ceil(cap.windowMs / 1000)}s, pacing at ${settings.minTime}ms`
+  );
+  updateLimiterSettings(limiter, { reservoir: 0, ...settings });
 }
diff --git a/open-sse/services/rateLimitManager/requestCap.ts b/open-sse/services/rateLimitManager/requestCap.ts
new file mode 100644
index 0000000000..84b558df2d
--- /dev/null
+++ b/open-sse/services/rateLimitManager/requestCap.ts
@@ -0,0 +1,102 @@
+/**
+ * Parse a hard request cap out of a 429 body.
+ *
+ * Some providers state their ceiling in prose instead of headers, e.g.
+ * TokenRouter: "You have reached the request limit: Maximum 5 requests within
+ * 1 minutes". Nothing else learns that number, so the limiter keeps racing into
+ * the cap. This turns the phrasing into `{ requests, windowMs }` so the
+ * learned-limit path can pace dispatch below it (#13594).
+ */
+
+const UNIT_MS: Record = {
+  second: 1_000,
+  sec: 1_000,
+  minute: 60_000,
+  min: 60_000,
+  hour: 3_600_000,
+  hr: 3_600_000,
+};
+
+// A cap word must come shortly before the figure so a bare usage statement
+// ("you made 120 requests in 1 minute", "current usage: 4 rpm") is not read as
+// a ceiling. Both patterns below share it.
+const CAP_WORD_PREFIX = String.raw`\b(?:max(?:imum)?|limit(?:ed)?|allowed|up to|at most|exceed(?:ed|s)?|quota|rate)\b(?![^.\n]{0,40}\b(?:made|sent|used)\b)[^.\n]{0,40}?`;
+// "Maximum 5 requests within 1 minutes", "Rate limit exceeded: 60 requests
+// per minute", "limit of 10 requests per 2 minutes".
+const REQUESTS_PER_WINDOW_RE = new RegExp(
+  CAP_WORD_PREFIX +
+    String.raw`(\d{1,7})\s+requests?\s+(?:within|per|in|every)\s+(?:(\d{1,5})\s*)?(second|sec|minute|min|hour|hr)s?\b`,
+  "i"
+);
+// "Rate limit: 20 RPM"
+const RPM_RE = new RegExp(CAP_WORD_PREFIX + String.raw`(\d{1,7})\s*rpm\b`, "i");
+
+const MAX_TEXT_LENGTH = 4_000;
+const MAX_WINDOW_MS = 24 * 3_600_000;
+
+export interface RequestCap {
+  requests: number;
+  windowMs: number;
+}
+
+function bodyText(body: unknown): string {
+  if (typeof body === "string") return body;
+  if (body === null || body === undefined) return "";
+  try {
+    return JSON.stringify(body);
+  } catch {
+    return "";
+  }
+}
+
+export function parseRequestCapFromBody(body: unknown): RequestCap | null {
+  const text = bodyText(body).slice(0, MAX_TEXT_LENGTH);
+  if (!text) return null;
+
+  let requests = 0;
+  let windowMs = 0;
+
+  const perWindow = REQUESTS_PER_WINDOW_RE.exec(text);
+  if (perWindow) {
+    requests = Number.parseInt(perWindow[1], 10);
+    const count = perWindow[2] ? Number.parseInt(perWindow[2], 10) : 1;
+    windowMs = count * UNIT_MS[perWindow[3].toLowerCase()];
+  } else {
+    const rpm = RPM_RE.exec(text);
+    if (!rpm) return null;
+    requests = Number.parseInt(rpm[1], 10);
+    windowMs = UNIT_MS.minute;
+  }
+
+  const cap = { requests, windowMs };
+  return isValidRequestCap(cap) ? cap : null;
+}
+
+/** A cap is usable when it is a whole number of requests over 1s..24h. */
+export function isValidRequestCap(cap: RequestCap): boolean {
+  return (
+    Number.isInteger(cap.requests) &&
+    cap.requests >= 1 &&
+    Number.isFinite(cap.windowMs) &&
+    cap.windowMs >= 1_000 &&
+    cap.windowMs <= MAX_WINDOW_MS
+  );
+}
+
+export interface RequestCapSettings {
+  minTime: number;
+  reservoirRefreshAmount: number;
+  reservoirRefreshInterval: number;
+}
+
+/**
+ * Bottleneck settings that keep dispatch under a cap: a reservoir of
+ * `requests` refilled every `windowMs`, spread evenly by `minTime`.
+ */
+export function requestCapSettings(cap: RequestCap): RequestCapSettings {
+  return {
+    minTime: Math.floor(cap.windowMs / cap.requests),
+    reservoirRefreshAmount: cap.requests,
+    reservoirRefreshInterval: cap.windowMs,
+  };
+}
diff --git a/tests/unit/rate-limit-learned-cap-13594.test.ts b/tests/unit/rate-limit-learned-cap-13594.test.ts
new file mode 100644
index 0000000000..e826fc2216
--- /dev/null
+++ b/tests/unit/rate-limit-learned-cap-13594.test.ts
@@ -0,0 +1,444 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-learned-cap-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+// Dynamic imports are required because DATA_DIR must be set before DB modules evaluate.
+await import("../../src/lib/db/core.ts");
+const rateLimitManager = await import("../../open-sse/services/rateLimitManager.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const requestCapModule = await import("../../open-sse/services/rateLimitManager/requestCap.ts");
+const { parseRequestCapFromBody } = requestCapModule;
+const { classifyErrorText } = await import("../../open-sse/services/accountFallback.ts");
+const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
+const { findMatchingErrorRule } = await import("../../open-sse/config/errorConfig.ts");
+const { STANDARD_HEADERS } = await import("../../open-sse/services/rateLimitManager/headers.ts");
+const { DEFAULT_RESILIENCE_SETTINGS } = await import("../../src/lib/resilience/settings.ts");
+const Bottleneck = (await import("bottleneck")).default;
+
+const TOKENROUTER_429 = JSON.stringify({
+  error: {
+    message: "You have reached the request limit: Maximum 5 requests within 1 minutes",
+    type: "rate_limit_error",
+  },
+});
+
+type Captured = {
+  options: Record;
+  updates: Record[];
+};
+
+function captureLimiters(): Captured[] {
+  const captured: Captured[] = [];
+  rateLimitManager.__setLimiterFactoryForTests((options) => {
+    const limiter = new Bottleneck(options);
+    const entry: Captured = { options: { ...options }, updates: [] };
+    const original = limiter.updateSettings.bind(limiter);
+    limiter.updateSettings = (updates) => {
+      entry.updates.push({ ...updates });
+      return original(updates);
+    };
+    captured.push(entry);
+    return limiter;
+  });
+  return captured;
+}
+
+test.beforeEach(async () => {
+  await rateLimitManager.__resetRateLimitManagerForTests();
+});
+
+test.after(async () => {
+  await rateLimitManager.__resetRateLimitManagerForTests();
+  fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+test("parseRequestCapFromBody reads hard request caps from 429 bodies", () => {
+  assert.deepEqual(parseRequestCapFromBody(TOKENROUTER_429), { requests: 5, windowMs: 60_000 });
+  assert.deepEqual(parseRequestCapFromBody(JSON.parse(TOKENROUTER_429)), {
+    requests: 5,
+    windowMs: 60_000,
+  });
+  assert.deepEqual(parseRequestCapFromBody("429: Maximum 100 requests within 30 seconds"), {
+    requests: 100,
+    windowMs: 30_000,
+  });
+  assert.deepEqual(parseRequestCapFromBody("Rate limit exceeded: 60 requests per minute"), {
+    requests: 60,
+    windowMs: 60_000,
+  });
+  assert.deepEqual(parseRequestCapFromBody("You hit the limit of 10 requests per 2 minutes"), {
+    requests: 10,
+    windowMs: 120_000,
+  });
+  assert.deepEqual(parseRequestCapFromBody("Rate limit: 20 RPM"), {
+    requests: 20,
+    windowMs: 60_000,
+  });
+  assert.deepEqual(parseRequestCapFromBody("Rate limit: 20 rpm, current usage: 4 rpm"), {
+    requests: 20,
+    windowMs: 60_000,
+  });
+  assert.deepEqual(parseRequestCapFromBody("quota: 1000 requests per hour"), {
+    requests: 1000,
+    windowMs: 3_600_000,
+  });
+});
+
+test("parseRequestCapFromBody ignores bodies without a request cap", () => {
+  assert.equal(parseRequestCapFromBody("Rate limit exceeded. Please retry after 20s."), null);
+  assert.equal(parseRequestCapFromBody(""), null);
+  assert.equal(parseRequestCapFromBody(null), null);
+  assert.equal(parseRequestCapFromBody({ error: { message: "overloaded" } }), null);
+  assert.equal(parseRequestCapFromBody("Maximum 0 requests within 1 minutes"), null);
+  assert.equal(parseRequestCapFromBody("processed 5 requests in 3 days"), null);
+  // usage statements are not ceilings
+  assert.equal(parseRequestCapFromBody("You made 120 requests in 1 minute; the limit is 60"), null);
+  assert.equal(parseRequestCapFromBody("Your 3 requests in 10 seconds exceeded the plan"), null);
+  assert.equal(
+    parseRequestCapFromBody("Rate limit exceeded: you sent 120 requests in 1 minute"),
+    null
+  );
+  assert.equal(parseRequestCapFromBody("Generate: 7 requests per minute"), null);
+  // the rpm shorthand needs a cap word before the figure too, or a low usage
+  // figure pins the connection; a cap word after it is deliberately not enough
+  assert.equal(parseRequestCapFromBody("Current usage: 4 rpm"), null);
+  assert.equal(parseRequestCapFromBody("Rate limit hit: you have made 3 rpm"), null);
+  assert.equal(parseRequestCapFromBody("Throttled: 20 RPM exceeded"), null);
+});
+
+test("a 429 with a request cap paces the limiter and is learned", async () => {
+  const connectionId = "tokenrouter-cap-conn";
+  const captured = captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+
+  // The pipeline records headers first (which evicts the limiter on a 429)
+  // and then the body.
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, "glm-free");
+  rateLimitManager.updateFromResponseBody(
+    "tokenrouter",
+    connectionId,
+    TOKENROUTER_429,
+    429,
+    "glm-free"
+  );
+
+  const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+  assert.ok(learned, "cap should be recorded as a learned limit");
+  assert.equal(learned.capRequests, 5);
+  assert.equal(learned.capWindowMs, 60_000);
+  assert.equal(learned.minTime, 12_000);
+  assert.equal(learned.limit, 5);
+
+  const fresh = captured.at(-1)!;
+  const capUpdate = fresh.updates.find((u) => u.reservoirRefreshAmount === 5);
+  assert.ok(capUpdate, "the rebuilt limiter should receive the cap");
+  assert.equal(capUpdate.reservoir, 0);
+  assert.equal(capUpdate.reservoirRefreshInterval, 60_000);
+  assert.equal(capUpdate.minTime, 12_000);
+});
+
+test("a learned cap survives the limiter eviction on the next 429", async () => {
+  const connectionId = "tokenrouter-evict-conn";
+  const captured = captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, "glm-free");
+  rateLimitManager.updateFromResponseBody(
+    "tokenrouter",
+    connectionId,
+    TOKENROUTER_429,
+    429,
+    "glm-free"
+  );
+  // A second 429 (say from an in-flight request) whose body says nothing useful.
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, "glm-free");
+  rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, "{}", 429, "glm-free");
+
+  const before = captured.length;
+  await rateLimitManager.withRateLimit("tokenrouter", connectionId, "glm-free", async () => "ok");
+  assert.equal(captured.length, before + 1, "the next request builds a fresh limiter");
+
+  const rebuilt = captured.at(-1)!.options;
+  assert.equal(rebuilt.reservoir, 5);
+  assert.equal(rebuilt.reservoirRefreshAmount, 5);
+  assert.equal(rebuilt.reservoirRefreshInterval, 60_000);
+  assert.equal(rebuilt.minTime, 12_000);
+});
+
+test("a learned cap is restored from persistence after a restart", async () => {
+  const connectionId = "tokenrouter-restart-conn";
+  captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+  rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+  await rateLimitManager.__flushLearnedLimitsForTests();
+
+  await rateLimitManager.__resetRateLimitManagerForTests();
+  await rateLimitManager.initializeRateLimits();
+
+  const restored = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+  assert.ok(restored, "cap should be reloaded from settings");
+  assert.equal(restored.capRequests, 5);
+  assert.equal(restored.capWindowMs, 60_000);
+
+  const captured = captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+  await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "ok");
+  const options = captured.at(-1)!.options;
+  assert.equal(options.reservoir, 5);
+  assert.equal(options.reservoirRefreshInterval, 60_000);
+  assert.equal(options.minTime, 12_000);
+});
+
+test("TokenRouter capacity 503 bodies classify as model capacity, not a provider outage", () => {
+  for (const text of ["503 system disk overloaded", "system cpu overloaded"]) {
+    assert.equal(classifyErrorText(text), RateLimitReason.MODEL_CAPACITY, text);
+    const rule = findMatchingErrorRule(503, text);
+    assert.equal(rule?.reason, "model_capacity", text);
+    assert.equal(rule?.backoff, true, text);
+  }
+});
+
+test("a cap never paces closer than the operator minTime floor", async () => {
+  const connectionId = "tokenrouter-floor-conn";
+  const captured = captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+  await rateLimitManager.applyRequestQueueSettings({
+    ...DEFAULT_RESILIENCE_SETTINGS.requestQueue,
+    minTimeBetweenRequestsMs: 200,
+  });
+  try {
+    rateLimitManager.enableRateLimitProtection(connectionId);
+    rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+    rateLimitManager.updateFromResponseBody(
+      "tokenrouter",
+      connectionId,
+      "Rate limit exceeded: 1000 requests per minute",
+      429,
+      null
+    );
+
+    const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+    assert.equal(learned.capRequests, 1000);
+    assert.equal(learned.minTime, 200, "window/N = 60ms is below the 200ms floor");
+    const capUpdate = captured.at(-1)!.updates.find((u) => u.reservoirRefreshAmount === 1000);
+    assert.equal(capUpdate?.minTime, 200);
+  } finally {
+    await rateLimitManager.applyRequestQueueSettings(DEFAULT_RESILIENCE_SETTINGS.requestQueue);
+  }
+});
+
+test("an explicit RPM override outranks a learned cap", async () => {
+  const connectionId = "tokenrouter-override-conn";
+  const captured = captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+  rateLimitManager.refreshConnectionRateLimits(connectionId, { rpm: 100 });
+
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+  rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+  await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "ok");
+
+  const rebuilt = captured.at(-1)!.options;
+  assert.equal(rebuilt.reservoir, 100, "the operator's rpm override wins at construction");
+  assert.equal(rebuilt.reservoirRefreshInterval, 60_000);
+
+  // and at runtime: the body path must not pace the live limiter with the cap
+  const bodyPathLimiter = captured.find((c) =>
+    c.updates.some((u) => u.reservoir === 0 && u.reservoirRefreshAmount === undefined)
+  );
+  assert.ok(bodyPathLimiter, "body path only spends the window under an rpm override");
+  assert.ok(
+    captured.every((c) => !c.updates.some((u) => u.reservoirRefreshAmount === 5)),
+    "no live update applied the 5-per-minute cap"
+  );
+  const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+  assert.equal(learned.capRequests, 5, "cap still recorded for when the override goes");
+});
+
+test("a header-learned update keeps the body-learned cap", async () => {
+  const connectionId = "tokenrouter-merge-conn";
+  captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+  rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+  rateLimitManager.updateFromHeaders(
+    "tokenrouter",
+    connectionId,
+    { [STANDARD_HEADERS.limit]: "100", [STANDARD_HEADERS.remaining]: "90" },
+    200,
+    null
+  );
+
+  const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+  assert.equal(learned.limit, 100, "header value recorded");
+  assert.equal(learned.capRequests, 5, "cap not dropped by the header update");
+  assert.equal(learned.capWindowMs, 60_000);
+});
+
+test("the body path rebuilds the limiter even when the header hook did not run", async () => {
+  const connectionId = "tokenrouter-body-only-conn";
+  const captured = captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+  await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "warm");
+  const before = captured.length;
+
+  rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+
+  assert.equal(captured.length, before + 1, "a fresh limiter carries the cap in its options");
+  const rebuilt = captured.at(-1)!;
+  assert.equal(rebuilt.options.reservoir, 5);
+  assert.equal(rebuilt.options.reservoirRefreshInterval, 60_000);
+  assert.ok(
+    rebuilt.updates.some((u) => u.reservoir === 0),
+    "and starts with an empty reservoir"
+  );
+});
+
+test("an operator refresh of a connection forgets its learned cap", async () => {
+  const connectionId = "tokenrouter-refresh-conn";
+  const captured = captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+  rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+  assert.equal(rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`].capRequests, 5);
+
+  rateLimitManager.refreshConnectionRateLimits(connectionId, { minTime: 50 });
+
+  const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+  assert.equal(learned.capRequests, undefined, "cap cleared");
+  assert.equal(learned.provider, "tokenrouter", "entry itself kept");
+  await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "ok");
+  assert.notEqual(captured.at(-1)!.options.reservoir, 5, "rebuilt limiter is uncapped");
+
+  // the clear reaches persistence too
+  await rateLimitManager.__flushLearnedLimitsForTests();
+  await rateLimitManager.__resetRateLimitManagerForTests();
+  await rateLimitManager.initializeRateLimits();
+  const restored = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+  assert.equal(restored?.capRequests, undefined, "cap does not come back after a restart");
+});
+
+test("a cap that cannot be honoured within the queue budget is not learned", async () => {
+  const connectionId = "tokenrouter-huge-window-conn";
+  captureLimiters();
+  rateLimitManager.enableRateLimitProtection(connectionId);
+  rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+  rateLimitManager.updateFromResponseBody(
+    "tokenrouter",
+    connectionId,
+    "Quota exceeded: limit of 1 requests per 24 hours",
+    429,
+    null
+  );
+
+  assert.equal(rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`], undefined);
+});
+
+test("a persisted cap that no longer fits the queue budget is not re-applied", async () => {
+  // A real connection row, so the restart below auto-enables it and builds its
+  // limiter before the persisted limits load, the way boot does.
+  const connection = await providersDb.createProviderConnection({
+    provider: "tokenrouter",
+    authType: "apikey",
+    name: "budget-shrank",
+    apiKey: "sk-budget-shrank",
+    isActive: true,
+  });
+  const connectionId = connection.id;
+  try {
+    captureLimiters();
+    rateLimitManager.enableRateLimitProtection(connectionId);
+    // Learned while the operator allowed requests to wait ten minutes.
+    rateLimitManager.refreshConnectionRateLimits(connectionId, { maxWaitMs: 600_000 });
+    rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+    rateLimitManager.updateFromResponseBody(
+      "tokenrouter",
+      connectionId,
+      "Quota exceeded: limit of 1 requests per 5 minutes",
+      429,
+      null
+    );
+    assert.equal(
+      rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`].minTime,
+      300_000
+    );
+    await rateLimitManager.__flushLearnedLimitsForTests();
+
+    // Restart without the override: the budget is back to the 90s default.
+    await rateLimitManager.__resetRateLimitManagerForTests();
+    const captured = captureLimiters();
+    await rateLimitManager.initializeRateLimits();
+
+    const restored = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+    assert.equal(restored?.capRequests, 1, "the cap itself is still remembered");
+    const booted = captured.at(-1)!;
+    assert.ok(booted, "boot builds the auto-enabled connection's limiter");
+    assert.ok(
+      booted.updates.every((u) => u.reservoirRefreshAmount !== 1 && u.minTime !== 300_000),
+      "the restore path must not pace the live limiter with the oversized cap"
+    );
+
+    const before = captured.length;
+    rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+    await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "ok");
+    assert.equal(captured.length, before + 1, "the 429 rebuilds the limiter");
+    const rebuilt = captured.at(-1)!.options;
+    assert.notEqual(rebuilt.reservoir, 1, "the rebuild path must not carry the oversized cap");
+    assert.notEqual(rebuilt.minTime, 300_000);
+  } finally {
+    await providersDb.deleteProviderConnection(connectionId);
+  }
+});
+
+test("a persisted cap that fits the queue budget is applied to the boot-time limiter", async () => {
+  const connection = await providersDb.createProviderConnection({
+    provider: "tokenrouter",
+    authType: "apikey",
+    name: "budget-fits",
+    apiKey: "sk-budget-fits",
+    isActive: true,
+  });
+  const connectionId = connection.id;
+  try {
+    captureLimiters();
+    rateLimitManager.enableRateLimitProtection(connectionId);
+    rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+    rateLimitManager.updateFromResponseBody(
+      "tokenrouter",
+      connectionId,
+      TOKENROUTER_429,
+      429,
+      null
+    );
+    await rateLimitManager.__flushLearnedLimitsForTests();
+
+    await rateLimitManager.__resetRateLimitManagerForTests();
+    const captured = captureLimiters();
+    await rateLimitManager.initializeRateLimits();
+
+    const booted = captured.at(-1)!;
+    assert.equal(booted.options.id, `tokenrouter:${connectionId}`);
+    assert.ok(
+      booted.updates.some((u) => u.reservoirRefreshAmount === 5 && u.minTime === 12_000),
+      "the restore path paces the boot-time limiter with the persisted cap"
+    );
+  } finally {
+    await providersDb.deleteProviderConnection(connectionId);
+  }
+});
+
+test("isValidRequestCap bounds what the restore path accepts", () => {
+  const { isValidRequestCap } = requestCapModule;
+  assert.equal(isValidRequestCap({ requests: 5, windowMs: 60_000 }), true);
+  assert.equal(isValidRequestCap({ requests: 0, windowMs: 60_000 }), false);
+  assert.equal(isValidRequestCap({ requests: 2.5, windowMs: 60_000 }), false);
+  assert.equal(isValidRequestCap({ requests: 5, windowMs: 1e12 }), false);
+  assert.equal(isValidRequestCap({ requests: 5, windowMs: 10 }), false);
+  assert.equal(isValidRequestCap({ requests: 5, windowMs: Number.NaN }), false);
+});

From b67915d9ebc91a5147267e7baabd3c8d71d0c6a2 Mon Sep 17 00:00:00 2001
From: Fouad Salkini 
Date: Sat, 19 Sep 2026 06:05:14 +0300
Subject: [PATCH 27/33] fix(combos): synchronize allowedProviders and allow
 invariant overrides on update (#13951)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(combos): synchronize allowedProviders and allow invariant overrides

* docs: add PR reference to changelog fragment #13951

* fix(combos): only widen existing allowedProviders/allowedModelFamilies restrictions on edit

The dashboard's combo edit save unconditionally set overrideAllowedProviders
and unconditionally nulled allowedModelFamilies. When a combo had NO prior
allowedProviders restriction, the PUT route unioned the (empty) current
restriction with the new step providers, synthesizing a brand-new allowlist
out of nothing — the opposite of "no restriction" — so a later add-a-provider
update would start failing COMBO_008 where it previously succeeded.

- Gate the server-side union in PUT /api/combos/[id] so it only widens an
  ALREADY non-empty allowedProviders restriction; a combo with no restriction
  stays unrestricted.
- Only clear allowedModelFamilies when a new step's family actually falls
  outside the existing restriction, instead of always nulling it on any edit.
- Derive the provider id via resolveCanonicalProviderModel instead of a naive
  model.split('/')[0], so self-aliased no-auth providers (e.g. 'opencode' ->
  'opencode-zen') resolve to their real routing provider.
- Add a PUT-route-level regression test covering both the "no prior
  restriction stays unrestricted" and "prior restriction gets unioned" cases
  (the existing combo-update-invariants test only exercised
  combosDb.updateCombo() directly, bypassing this route branch).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(combos): extract computeAllowedRestrictionSync to keep handleSave under the complexity ratchet

The #13951 gating fix pushed handleSave's cyclomatic complexity past the frozen
new-code ceiling (16 > 15). Moving the allowedProviders/allowedModelFamilies sync
into a module-level helper restores complexityNewCode=0 with no behavior change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---
 .../fixes/13951-combo-update-invariants.md    |  1 +
 src/app/(dashboard)/dashboard/combos/page.tsx | 55 ++++++++++++
 src/app/api/combos/[id]/route.ts              | 27 ++++++
 src/lib/combos/invariants.ts                  | 13 ++-
 src/shared/validation/schemas/combo.ts        |  5 +-
 .../combo-put-route-allowed-providers.test.ts | 85 ++++++++++++++++++
 tests/unit/combo-update-invariants.test.ts    | 89 +++++++++++++++++++
 7 files changed, 271 insertions(+), 4 deletions(-)
 create mode 100644 changelog.d/fixes/13951-combo-update-invariants.md
 create mode 100644 tests/unit/combo-put-route-allowed-providers.test.ts
 create mode 100644 tests/unit/combo-update-invariants.test.ts

diff --git a/changelog.d/fixes/13951-combo-update-invariants.md b/changelog.d/fixes/13951-combo-update-invariants.md
new file mode 100644
index 0000000000..1ca2676849
--- /dev/null
+++ b/changelog.d/fixes/13951-combo-update-invariants.md
@@ -0,0 +1 @@
+- fix(combos): synchronize allowedProviders and allow invariant override when updating combos from dashboard ([#13951](https://github.com/diegosouzapw/OmniRoute/pull/13951))
diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx
index fc30b8b037..d93a2d3442 100644
--- a/src/app/(dashboard)/dashboard/combos/page.tsx
+++ b/src/app/(dashboard)/dashboard/combos/page.tsx
@@ -84,6 +84,8 @@ import {
 } from "@/lib/combos/intelligentRouting";
 import { getComboStepTarget } from "@/lib/combos/steps";
 import { DEAD_COMBO_CONFIG_KEYS } from "@/lib/combos/deadConfigKeys";
+import { modelFamily } from "@/lib/combos/invariants";
+import { resolveCanonicalProviderModel } from "@omniroute/open-sse/services/model.ts";
 import { resolveServerErrorMessage } from "@/lib/api/serverErrorMessage";
 import { useTranslations } from "next-intl";
 
@@ -653,6 +655,55 @@ function normalizeModelEntry(entry) {
   };
 }
 
+/**
+ * On an existing-combo edit, work out how the dashboard save should synchronize
+ * `allowedProviders` / `allowedModelFamilies` against the combo's new step list so
+ * adding a step across providers never triggers COMBO_008 (#13951). Both restrictions
+ * are only ever WIDENED or left untouched here — never synthesized from no restriction,
+ * and never wiped just because the combo happens to have one.
+ */
+function computeAllowedRestrictionSync(
+  isEdit: boolean,
+  combo: { allowedProviders?: unknown; allowedModelFamilies?: unknown } | null | undefined,
+  models: Array<{ providerId?: string; model?: string }>
+): { allowedProviders?: string[]; allowedModelFamilies?: null; overrideAllowedProviders?: true } {
+  if (!isEdit) return {};
+  const result: {
+    allowedProviders?: string[];
+    allowedModelFamilies?: null;
+    overrideAllowedProviders?: true;
+  } = { overrideAllowedProviders: true };
+
+  const existingProviders = Array.isArray(combo?.allowedProviders) ? combo.allowedProviders : [];
+  if (existingProviders.length > 0) {
+    const stepProviders = models
+      .map((m) => {
+        if (m.providerId) return m.providerId;
+        if (typeof m.model !== "string" || !m.model.includes("/")) return "";
+        const [aliasOrProvider, ...rest] = m.model.split("/");
+        return resolveCanonicalProviderModel(aliasOrProvider, rest.join("/")).provider || "";
+      })
+      .filter((p): p is string => Boolean(p));
+    result.allowedProviders = Array.from(new Set([...existingProviders, ...stepProviders]));
+  }
+
+  // Only clear the family restriction when a new step actually violates it (#13951).
+  const existingFamilies = Array.isArray(combo?.allowedModelFamilies)
+    ? combo.allowedModelFamilies
+    : [];
+  if (existingFamilies.length > 0) {
+    const allowedFamilies = new Set(existingFamilies);
+    const stepViolatesFamilies = models.some((m) => {
+      const family = typeof m.model === "string" ? modelFamily(m.model) : null;
+      return !family || !allowedFamilies.has(family);
+    });
+    if (stepViolatesFamilies) result.allowedModelFamilies = null;
+  }
+
+  return result;
+}
+
+
 function getModelString(entry) {
   if (typeof entry === "string") return entry;
   if (entry?.kind === "combo-ref") return entry.comboName;
@@ -3027,6 +3078,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
       strategy,
     };
 
+    // When editing an existing combo from the dashboard form, synchronize allowedProviders
+    // and clear legacy family restrictions so adding steps across providers never triggers COMBO_008
+    Object.assign(saveData, computeAllowedRestrictionSync(isEdit, combo, models));
+
     // Per-combo description (#5005). Free-text, optional, persisted in combo data.
     if (description.trim()) {
       saveData.description = description.trim();
diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts
index 04f39a35b1..4c6d0d5bba 100644
--- a/src/app/api/combos/[id]/route.ts
+++ b/src/app/api/combos/[id]/route.ts
@@ -6,6 +6,7 @@ import { syncToCloud } from "@/lib/cloudSync";
 import { validateCompositeTiersConfig } from "@/lib/combos/compositeTiers";
 import { normalizeComboModels } from "@/lib/combos/steps";
 import { validateComboDAG, clampComboDepth } from "@omniroute/open-sse/services/combo.ts";
+import { resolveCanonicalProviderModel } from "@omniroute/open-sse/services/model.ts";
 import { updateComboSchema } from "@/shared/validation/schemas";
 import { requiresQuotaOnlyComboRefExecute } from "@/shared/validation/schemas/combo";
 import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -137,6 +138,32 @@ export async function PUT(request, { params }) {
           }),
         }
       : normalizedUpdate;
+
+    if (body.overrideAllowedProviders === true) {
+      delete body.overrideAllowedProviders;
+      const currentProviders = Array.isArray(currentCombo.allowedProviders)
+        ? currentCombo.allowedProviders
+        : [];
+      // Only widen an EXISTING restriction (#13951/COMBO_008). When the combo
+      // currently has no allowedProviders restriction, currentProviders is
+      // empty and unioning it with the new step providers would synthesize a
+      // brand-new allowlist out of nothing — the opposite of "no restriction".
+      if (body.models && body.allowedProviders === undefined && currentProviders.length > 0) {
+        const stepProviders = (
+          body.models as Array<{ providerId?: string; provider?: string; model?: string }>
+        )
+          .map((m) => {
+            if (m.providerId) return m.providerId;
+            if (m.provider) return m.provider;
+            if (typeof m.model !== "string" || !m.model.includes("/")) return "";
+            const [aliasOrProvider, ...rest] = m.model.split("/");
+            return resolveCanonicalProviderModel(aliasOrProvider, rest.join("/")).provider || "";
+          })
+          .filter((p): p is string => Boolean(p));
+        body.allowedProviders = Array.from(new Set([...currentProviders, ...stepProviders]));
+      }
+    }
+
     const nextComboState = {
       ...currentCombo,
       ...body,
diff --git a/src/lib/combos/invariants.ts b/src/lib/combos/invariants.ts
index 302bf61296..6759443249 100644
--- a/src/lib/combos/invariants.ts
+++ b/src/lib/combos/invariants.ts
@@ -14,10 +14,19 @@ const FAMILY_PATTERNS: ReadonlyArray<[string, RegExp]> = [
 ];
 
 function strings(value: unknown): string[] {
-  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+  return Array.isArray(value)
+    ? value.filter((item): item is string => typeof item === "string")
+    : [];
 }
 
-function modelFamily(model: string): string | null {
+/**
+ * Detect the model "family" (gpt/claude/gemini/...) from a bare or
+ * provider-prefixed model id. Exported for callers that need to know
+ * whether a candidate step would actually violate an existing
+ * `allowedModelFamilies` restriction (#13951) rather than only the
+ * `validateComboInvariant` throw path below.
+ */
+export function modelFamily(model: string): string | null {
   const bare = model.slice(model.lastIndexOf("/") + 1);
   return FAMILY_PATTERNS.find(([, pattern]) => pattern.test(bare))?.[0] ?? null;
 }
diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts
index 306b8da498..51ed4072ae 100644
--- a/src/shared/validation/schemas/combo.ts
+++ b/src/shared/validation/schemas/combo.ts
@@ -432,8 +432,9 @@ export const updateComboSchema = z
     // so the one endpoint a client can flip it through stripped the field and
     // a visibility-only update was rejected as empty. #12836
     isHidden: z.boolean().optional(),
-    allowedProviders: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
-    allowedModelFamilies: z.array(z.string().trim().min(1).max(100)).max(100).optional(),
+    allowedProviders: z.array(z.string().trim().min(1).max(200)).max(100).optional().nullable(),
+    allowedModelFamilies: z.array(z.string().trim().min(1).max(100)).max(100).optional().nullable(),
+    overrideAllowedProviders: z.boolean().optional(),
     // Nullable like `description` and `context_length` above: an absent field means
     // "leave unchanged" because updateCombo merges over the stored record, so clearing
     // one needs an explicit null for updateCombo's null-means-delete pass (#12158).
diff --git a/tests/unit/combo-put-route-allowed-providers.test.ts b/tests/unit/combo-put-route-allowed-providers.test.ts
new file mode 100644
index 0000000000..0fa837ce0c
--- /dev/null
+++ b/tests/unit/combo-put-route-allowed-providers.test.ts
@@ -0,0 +1,85 @@
+// #13951 — route-level regression coverage for the PUT /api/combos/[id]
+// overrideAllowedProviders sync path. tests/unit/combo-update-invariants.test.ts
+// only exercises combosDb.updateCombo() directly, bypassing the PUT route
+// branch this test targets.
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-put-route-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const combosDb = await import("../../src/lib/db/combos.ts");
+const comboRoute = await import("../../src/app/api/combos/[id]/route.ts");
+
+test.after(() => {
+  core.resetDbInstance();
+  fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+});
+
+function put(id: string, body: Record) {
+  return new Request(`http://localhost/api/combos/${id}`, {
+    method: "PUT",
+    headers: { "content-type": "application/json" },
+    body: JSON.stringify(body),
+  });
+}
+
+test("PUT with overrideAllowedProviders on a combo with NO prior restriction stays unrestricted", async () => {
+  const combo = await combosDb.createCombo({
+    name: "unrestricted-combo",
+    strategy: "priority",
+    models: [{ provider: "claude", model: "claude-sonnet-5" }],
+  });
+  assert.ok(combo?.id);
+  assert.equal((combo as { allowedProviders?: string[] }).allowedProviders, undefined);
+
+  const response = await comboRoute.PUT(
+    put(combo.id, {
+      name: "unrestricted-combo",
+      models: [
+        { provider: "claude", model: "claude-sonnet-5" },
+        { provider: "openai", model: "gpt-5" },
+      ],
+      overrideAllowedProviders: true,
+    }),
+    { params: Promise.resolve({ id: combo.id }) }
+  );
+  assert.equal(response.status, 200);
+
+  const stored = (await combosDb.getComboById(combo.id)) as { allowedProviders?: string[] };
+  // The combo had no restriction before the edit — it must still have none
+  // afterwards. Synthesizing allowedProviders=["claude","openai"] here would
+  // be the #13951 regression: a later add-a-provider update would start
+  // failing COMBO_008 where it previously succeeded.
+  assert.equal(stored.allowedProviders, undefined);
+});
+
+test("PUT with overrideAllowedProviders on a combo with an EXISTING restriction unions the new step providers", async () => {
+  const combo = await combosDb.createCombo({
+    name: "restricted-combo",
+    strategy: "priority",
+    allowedProviders: ["claude"],
+    models: [{ provider: "claude", model: "claude-sonnet-5" }],
+  });
+  assert.ok(combo?.id);
+
+  const response = await comboRoute.PUT(
+    put(combo.id, {
+      name: "restricted-combo",
+      models: [
+        { provider: "claude", model: "claude-sonnet-5" },
+        { provider: "openai", model: "gpt-5" },
+      ],
+      overrideAllowedProviders: true,
+    }),
+    { params: Promise.resolve({ id: combo.id }) }
+  );
+  assert.equal(response.status, 200);
+
+  const stored = (await combosDb.getComboById(combo.id)) as { allowedProviders?: string[] };
+  assert.deepEqual([...(stored.allowedProviders ?? [])].sort(), ["claude", "openai"]);
+});
diff --git a/tests/unit/combo-update-invariants.test.ts b/tests/unit/combo-update-invariants.test.ts
new file mode 100644
index 0000000000..342b397e69
--- /dev/null
+++ b/tests/unit/combo-update-invariants.test.ts
@@ -0,0 +1,89 @@
+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";
+import { updateComboSchema } from "../../src/shared/validation/schemas/combo.ts";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-invariants-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const combosDb = await import("../../src/lib/db/combos.ts");
+
+async function resetStorage() {
+  core.resetDbInstance();
+  if (fs.existsSync(TEST_DATA_DIR)) {
+    fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+  }
+  fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+test.beforeEach(async () => {
+  await resetStorage();
+});
+
+test.after(async () => {
+  core.resetDbInstance();
+  if (fs.existsSync(TEST_DATA_DIR)) {
+    fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+  }
+});
+
+test("updateComboSchema accepts nullable allowedProviders, allowedModelFamilies, and overrideAllowedProviders", () => {
+  const parsedNulls = updateComboSchema.safeParse({
+    allowedProviders: null,
+    allowedModelFamilies: null,
+    overrideAllowedProviders: true,
+  });
+  assert.equal(parsedNulls.success, true);
+  if (parsedNulls.success) {
+    assert.equal(parsedNulls.data.allowedProviders, null);
+    assert.equal(parsedNulls.data.allowedModelFamilies, null);
+    assert.equal(parsedNulls.data.overrideAllowedProviders, true);
+  }
+
+  const parsedArray = updateComboSchema.safeParse({
+    allowedProviders: ["claude", "antigravity"],
+    allowedModelFamilies: ["claude"],
+  });
+  assert.equal(parsedArray.success, true);
+});
+
+test("updateCombo allows updating allowedProviders and clearing with null", async () => {
+  const combo = await combosDb.createCombo({
+    name: "claude-combo",
+    allowedProviders: ["claude"],
+    models: [{ provider: "claude", model: "claude-sonnet-5" }],
+  });
+  assert.ok(combo?.id);
+
+  // Updating models to include a new provider with expanded allowedProviders succeeds
+  const updated = await combosDb.updateCombo(String(combo.id), {
+    allowedProviders: ["claude", "antigravity"],
+    models: [
+      { provider: "claude", model: "claude-sonnet-5" },
+      { provider: "antigravity", model: "claude-sonnet-4-6" },
+    ],
+  });
+  assert.ok(updated);
+  const typedUpdated = updated as {
+    allowedProviders?: string[];
+    models: Array<{ providerId?: string }>;
+  };
+  assert.deepEqual(typedUpdated.allowedProviders, ["claude", "antigravity"]);
+  assert.equal(typedUpdated.models.length, 2);
+
+  // Clearing allowedProviders with null succeeds and removes the invariant restriction
+  const cleared = await combosDb.updateCombo(String(combo.id), {
+    allowedProviders: null,
+    models: [{ provider: "openrouter", model: "nvidia/nemotron-3.5-lightning:free" }],
+  });
+  assert.ok(cleared);
+  const typedCleared = cleared as {
+    allowedProviders?: string[];
+    models: Array<{ providerId?: string }>;
+  };
+  assert.equal(typedCleared.allowedProviders, undefined);
+  assert.equal(typedCleared.models[0]?.providerId, "openrouter");
+});

From 233c66ffca6d26fa0a6144a0ebf6260c62026b01 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nguy=E1=BB=85n=20Vi=E1=BA=BFt=20Tu=E1=BA=A5n?=
 
Date: Sat, 19 Sep 2026 10:05:23 +0700
Subject: [PATCH 28/33] fix(sse): enforce strict OpenAI Responses API streaming
 schema compliance (#13956)

* fix(sse): include output in response.in_progress event

* fix(sse): include output in translator response.in_progress event

* fix(sse): include status in all responses output items

* fix(sse): include sequence_number in Responses API error frames

* docs(changelog): expand responses streaming schema compliance notes

* fix(sse): always include token details in Responses API usage

---------

Co-authored-by: TheDemonTuan 
---
 .../fixes/responses-in-progress-output.md     |  5 ++
 open-sse/transformer/responsesTransformer.ts  | 17 ++++-
 .../translator/response/openai-responses.ts   | 31 ++++++---
 open-sse/utils/earlyStreamKeepalive.ts        |  3 +-
 .../unit/codex-effort-model-echo-3697.test.ts | 67 ++++++++++++++++++-
 ...sponses-post-keepalive-error-frame.test.ts |  2 +
 tests/unit/responses-transformer.test.ts      | 13 ++++
 .../responses-usage-trailing-6906.test.ts     |  8 ++-
 8 files changed, 128 insertions(+), 18 deletions(-)
 create mode 100644 changelog.d/fixes/responses-in-progress-output.md

diff --git a/changelog.d/fixes/responses-in-progress-output.md b/changelog.d/fixes/responses-in-progress-output.md
new file mode 100644
index 0000000000..48ad8b2d40
--- /dev/null
+++ b/changelog.d/fixes/responses-in-progress-output.md
@@ -0,0 +1,5 @@
+- **fix(responses):** ensure full compliance with the OpenAI Responses API streaming schema for strict deserializers (e.g. OpenAI Responses SDK, Grok CLI / pager):
+  - Include `output: []`, `background: false`, and `error: null` in the `response.in_progress` lifecycle event across both the Responses transformer and response translator.
+  - Include `status` (`in_progress` or `completed`) on all emitted output items (`message`, `reasoning`, `function_call`, `custom_tool_call`) in `response.output_item.added`, `response.output_item.done`, and `response.output[]`.
+  - Include `sequence_number: 0` in in-band Responses stream error frames (`OPENAI_RESPONSES_ERROR_FRAME` and `buildResponsesErrorDataLine`) emitted after early keepalive streams commit.
+  - Ensure `input_tokens_details` (with `cached_tokens: 0`) and `output_tokens_details` (with `reasoning_tokens: 0`) are always populated in `response.usage` even when upstreams (e.g. Gemini) omit reasoning or caching tokens.
diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts
index 8b9b497464..852b68e5bf 100644
--- a/open-sse/transformer/responsesTransformer.ts
+++ b/open-sse/transformer/responsesTransformer.ts
@@ -297,6 +297,7 @@ export function createResponsesApiTransformStream(
           id: state.reasoningId,
           type: "reasoning",
           summary: [],
+          status: "in_progress",
         },
       });
 
@@ -347,6 +348,7 @@ export function createResponsesApiTransformStream(
         id: state.reasoningId,
         type: "reasoning",
         summary: [{ type: "summary_text", text: state.reasoningBuf }],
+        status: "completed",
       };
 
       emit(controller, "response.output_item.done", {
@@ -388,6 +390,7 @@ export function createResponsesApiTransformStream(
         type: "message",
         content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }],
         role: "assistant",
+        status: "completed",
       };
 
       emit(controller, "response.output_item.done", {
@@ -438,7 +441,7 @@ export function createResponsesApiTransformStream(
         ...(customTool ? { input: "" } : { arguments: "" }),
         call_id: state.funcCallIds[idx],
         name: state.funcNames[idx] || "",
-        ...(customTool ? { status: "in_progress" } : {}),
+        status: "in_progress",
       },
     });
     return true;
@@ -519,6 +522,7 @@ export function createResponsesApiTransformStream(
           arguments: args,
           call_id: callId,
           name: toolName,
+          status: "completed",
         };
       }
 
@@ -678,6 +682,9 @@ export function createResponsesApiTransformStream(
                 object: "response",
                 created_at: state.created,
                 status: "in_progress",
+                background: false,
+                error: null,
+                output: [],
               },
             });
           }
@@ -763,7 +770,13 @@ export function createResponsesApiTransformStream(
                   emit(controller, "response.output_item.added", {
                     type: "response.output_item.added",
                     output_index: msgIdx,
-                    item: { id: msgId, type: "message", content: [], role: "assistant" },
+                    item: {
+                      id: msgId,
+                      type: "message",
+                      content: [],
+                      role: "assistant",
+                      status: "in_progress",
+                    },
                   });
                 }
 
diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts
index 0a840320c6..a653028583 100644
--- a/open-sse/translator/response/openai-responses.ts
+++ b/open-sse/translator/response/openai-responses.ts
@@ -171,20 +171,24 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
     const u = chunk.usage;
     const input_tokens = u.input_tokens ?? u.prompt_tokens ?? 0;
     const output_tokens = u.output_tokens ?? u.completion_tokens ?? 0;
+    const cacheDetails = resolveResponsesCacheUsageDetails(u);
+    const rawReasoning =
+      u.output_tokens_details?.reasoning_tokens ?? u.completion_tokens_details?.reasoning_tokens;
+    const reasoningTokens =
+      typeof rawReasoning === "number" && Number.isFinite(rawReasoning) ? rawReasoning : 0;
+
     state.usage = {
       input_tokens,
+      input_tokens_details: {
+        cached_tokens: 0,
+        ...(cacheDetails || {}),
+      },
       output_tokens,
+      output_tokens_details: {
+        reasoning_tokens: reasoningTokens,
+      },
       total_tokens: u.total_tokens ?? input_tokens + output_tokens,
     };
-    const cacheDetails = resolveResponsesCacheUsageDetails(u);
-    if (cacheDetails) {
-      state.usage.input_tokens_details = cacheDetails;
-    }
-    const reasoningTokens =
-      u.output_tokens_details?.reasoning_tokens ?? u.completion_tokens_details?.reasoning_tokens;
-    if (reasoningTokens) {
-      state.usage.output_tokens_details = { reasoning_tokens: reasoningTokens };
-    }
   }
 
   if (!chunk.choices?.length) {
@@ -268,6 +272,9 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
       object: "response",
       created_at: state.created,
       status: "in_progress",
+      background: false,
+      error: null,
+      output: [],
     };
     if (state.model) inProgressResponse.model = state.model;
     emit("response.in_progress", {
@@ -395,7 +402,7 @@ function startReasoning(state, emit, idx) {
     emit("response.output_item.added", {
       type: "response.output_item.added",
       output_index: idx,
-      item: { id: state.reasoningId, type: "reasoning", summary: [] },
+      item: { id: state.reasoningId, type: "reasoning", summary: [], status: "in_progress" },
     });
 
     emit("response.reasoning_summary_part.added", {
@@ -445,6 +452,7 @@ function closeReasoning(state, emit) {
       id: state.reasoningId,
       type: "reasoning",
       summary: [{ type: "summary_text", text: state.reasoningBuf }],
+      status: "completed",
     };
 
     emit("response.output_item.done", {
@@ -465,7 +473,7 @@ function emitTextContent(state, emit, idx, content) {
     emit("response.output_item.added", {
       type: "response.output_item.added",
       output_index: idx,
-      item: { id: msgId, type: "message", content: [], role: "assistant" },
+      item: { id: msgId, type: "message", content: [], role: "assistant", status: "in_progress" },
     });
   }
 
@@ -523,6 +531,7 @@ function closeMessage(state, emit, idx) {
       type: "message",
       content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }],
       role: "assistant",
+      status: "completed",
     };
 
     emit("response.output_item.done", {
diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts
index 67b81e6f63..225adeb0b3 100644
--- a/open-sse/utils/earlyStreamKeepalive.ts
+++ b/open-sse/utils/earlyStreamKeepalive.ts
@@ -86,6 +86,7 @@ export const OPENAI_RESPONSES_ERROR_FRAME = ENCODER.encode(
     code: null,
     message: "Upstream stream failed before completion.",
     param: null,
+    sequence_number: 0,
   })}\n\n`
 );
 
@@ -124,7 +125,7 @@ function buildResponsesErrorDataLine(text: string): string {
     parsed && typeof parsed.diagnostics === "object" && parsed.diagnostics !== null
       ? { diagnostics: parsed.diagnostics }
       : {};
-  return JSON.stringify({ type: "error", code, message, param, ...extras });
+  return JSON.stringify({ type: "error", code, message, param, sequence_number: 0, ...extras });
 }
 
 export type EarlyStreamKeepaliveOptions = {
diff --git a/tests/unit/codex-effort-model-echo-3697.test.ts b/tests/unit/codex-effort-model-echo-3697.test.ts
index 0779322459..3f2207fb7f 100644
--- a/tests/unit/codex-effort-model-echo-3697.test.ts
+++ b/tests/unit/codex-effort-model-echo-3697.test.ts
@@ -7,9 +7,8 @@ import {
   echoModelInSseLine,
 } from "../../open-sse/services/responseModelEcho.ts";
 
-const { openaiToOpenAIResponsesResponse } = await import(
-  "../../open-sse/translator/response/openai-responses.ts"
-);
+const { openaiToOpenAIResponsesResponse } =
+  await import("../../open-sse/translator/response/openai-responses.ts");
 const { initState } = await import("../../open-sse/translator/index.ts");
 const { FORMATS } = await import("../../open-sse/translator/formats.ts");
 
@@ -82,6 +81,68 @@ test("OpenAI -> Responses translator omits model when the upstream never sent on
   assert.equal("model" in (completed!.data.response as Record), false);
 });
 
+test("OpenAI -> Responses translator emits response.in_progress with output: [], background: false, error: null", () => {
+  const events = collectResponsesEvents([
+    {
+      id: "chatcmpl-1",
+      model: "gpt-5.5",
+      choices: [{ index: 0, delta: { content: "hi" }, finish_reason: null }],
+    },
+    null,
+  ]);
+
+  const inProgress = events.find((e) => e.event === "response.in_progress");
+  assert.ok(inProgress, "response.in_progress must be emitted");
+  const resp = inProgress!.data.response as Record;
+  assert.ok(Array.isArray(resp.output), "output must be an array");
+  assert.deepEqual(resp.output, []);
+  assert.equal(resp.background, false);
+  assert.equal(resp.error, null);
+
+  const addedItem = events.find((e) => e.event === "response.output_item.added")?.data
+    .item as Record;
+  assert.ok(addedItem, "output_item.added must exist");
+  assert.equal(addedItem.status, "in_progress");
+
+  const completed = events.find((e) => e.event === "response.completed")?.data.response as Record<
+    string,
+    unknown
+  >;
+  assert.ok(completed, "response.completed must exist");
+  const completedOutput = completed.output as Array>;
+  assert.equal(completedOutput[0].status, "completed");
+});
+
+test("OpenAI -> Responses translator always populates input_tokens_details and output_tokens_details", () => {
+  const events = collectResponsesEvents([
+    {
+      id: "chatcmpl-1",
+      model: "gemini-3.8-flash",
+      choices: [{ index: 0, delta: { content: "hi" }, finish_reason: null }],
+      usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
+    },
+    {
+      id: "chatcmpl-1",
+      choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
+    },
+    null,
+  ]);
+
+  const completed = events.find((e) => e.event === "response.completed")?.data?.response as Record<
+    string,
+    unknown
+  >;
+  assert.ok(completed.usage, "usage must be present");
+  const usage = completed.usage as Record;
+  assert.equal(usage.input_tokens, 10);
+  assert.equal(usage.output_tokens, 5);
+  assert.equal(usage.total_tokens, 15);
+  assert.ok(usage.input_tokens_details, "input_tokens_details must be present");
+  assert.ok(usage.output_tokens_details, "output_tokens_details must be present");
+  assert.deepEqual(usage.input_tokens_details, { cached_tokens: 0 });
+  assert.deepEqual(usage.output_tokens_details, { reasoning_tokens: 0 });
+});
+
 test("full shim pipeline: bare upstream model in Responses payloads gets rewritten to the requested effort-suffixed id", () => {
   const events = collectResponsesEvents([
     {
diff --git a/tests/unit/issue-13431-responses-post-keepalive-error-frame.test.ts b/tests/unit/issue-13431-responses-post-keepalive-error-frame.test.ts
index 50d2e4e07b..3960c639b3 100644
--- a/tests/unit/issue-13431-responses-post-keepalive-error-frame.test.ts
+++ b/tests/unit/issue-13431-responses-post-keepalive-error-frame.test.ts
@@ -82,6 +82,7 @@ test("Responses route: post-keepalive JSON error body must carry a `type` field
       `instead of surfacing the real upstream error.`
   );
   assert.equal(lastPayload.type, "error");
+  assert.equal(typeof lastPayload.sequence_number, "number");
   assert.equal(lastPayload.message, 'Unknown name "encrypted" ... Cannot find field.');
   assert.equal(lastPayload.code, "bad_request");
 });
@@ -109,6 +110,7 @@ test("Responses route: non-JSON/empty post-keepalive error body falls back to a
   const lastPayload = lastDataPayload(await readAll(result));
 
   assert.equal(lastPayload.type, "error");
+  assert.equal(typeof lastPayload.sequence_number, "number");
   assert.ok(
     typeof lastPayload.message === "string" && lastPayload.message.length > 0,
     `fallback frame must never be opaque/empty; got ${JSON.stringify(lastPayload)}`
diff --git a/tests/unit/responses-transformer.test.ts b/tests/unit/responses-transformer.test.ts
index b3ae941a02..cc061b6fc5 100644
--- a/tests/unit/responses-transformer.test.ts
+++ b/tests/unit/responses-transformer.test.ts
@@ -72,9 +72,22 @@ test("createResponsesApiTransformStream converts plain chat deltas into Response
   );
   assert.ok(types.includes("response.created"));
   assert.ok(types.includes("response.in_progress"));
+
+  const inProgress = JSON.parse(
+    events.find((event) => event.event === "response.in_progress").data
+  ).response;
+  assert.ok(Array.isArray(inProgress.output), "response.in_progress must include an output array");
+  assert.deepEqual(inProgress.output, []);
+
   assert.ok(types.includes("response.output_item.added"));
+  const addedItem = JSON.parse(
+    events.find((event) => event.event === "response.output_item.added").data
+  ).item;
+  assert.equal(addedItem.status, "in_progress");
+
   assert.ok(types.includes("response.output_text.done"));
   assert.equal(completed.output[0].content[0].text, "Hello");
+  assert.equal(completed.output[0].status, "completed");
   assert.deepEqual(completed.usage, {
     input_tokens: 1,
     input_tokens_details: { cached_tokens: 0 },
diff --git a/tests/unit/responses-usage-trailing-6906.test.ts b/tests/unit/responses-usage-trailing-6906.test.ts
index 65f403cf9b..8027c96968 100644
--- a/tests/unit/responses-usage-trailing-6906.test.ts
+++ b/tests/unit/responses-usage-trailing-6906.test.ts
@@ -46,7 +46,13 @@ test("BUG #6906: live translator — response.completed carries usage when the u
   assert.ok(completedEvent, "response.completed event should be emitted");
   assert.deepEqual(
     completedEvent.data.response.usage,
-    { input_tokens: 2249, output_tokens: 123, total_tokens: 2372 },
+    {
+      input_tokens: 2249,
+      input_tokens_details: { cached_tokens: 0 },
+      output_tokens: 123,
+      output_tokens_details: { reasoning_tokens: 0 },
+      total_tokens: 2372,
+    },
     "response.completed must carry usage even when the usage-only chunk trails finish_reason"
   );
 });

From 16f8837acfb5ed493ad2b94bf8d79b4e7f1bad74 Mon Sep 17 00:00:00 2001
From: phs1997 
Date: Sat, 19 Sep 2026 05:05:34 +0200
Subject: [PATCH 29/33] fix(chatCore): only preserve tool_result blocks for
 Claude-native targets (#13972)

* fix(chatCore): only preserve tool_result blocks for Claude-native targets

When forwarding requests through the CC-bridge or Claude passthrough paths
to OpenAI-compatible upstream targets (e.g. logfare, OpenRouter), Anthropic-shape
tool_result content parts in user messages were preserved unchanged, causing
upstream gateways to reject the request with HTTP 503.

Gate preserveToolResultBlocks on targetFormat === FORMATS.CLAUDE so that
OpenAI targets receive properly normalized content strings.

Fixes #13971

* fix(chatCore): add regression coverage for the CC-bridge tool_result guard

Adds a one-line clarifying comment on the isClaudePassthrough call site (the
guard is a no-op there by construction) and two chatcore-translation-paths
tests proving the CC-bridge branch strips raw tool_result blocks for
OpenAI-compatible targets while still preserving them for Claude-native ones.

---------

Co-authored-by: phs1997 
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---
 open-sse/handlers/chatCore.ts                 | 18 ++++-
 tests/unit/chatcore-translation-paths.test.ts | 65 ++++++++++++++++++-
 2 files changed, 80 insertions(+), 3 deletions(-)

diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts
index 7434983c79..c67b2b4dd4 100644
--- a/open-sse/handlers/chatCore.ts
+++ b/open-sse/handlers/chatCore.ts
@@ -2403,7 +2403,12 @@ export async function handleChatCore({
         extractSystemRoleMessages(translatedBody);
       } else {
         // Non-CC path: full normalization including content type conversion.
-        normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true });
+        // Preserve tool_result blocks only when the upstream target speaks the
+        // Anthropic Messages format — OpenAI-compatible gateways reject them
+        // and return 503. See issue #13971.
+        normalizeClaudeUpstreamMessages(translatedBody, {
+          preserveToolResultBlocks: targetFormat === FORMATS.CLAUDE,
+        });
       }
     } else if (isClaudePassthrough) {
       // Pure passthrough: forward the body as-is without OpenAI round-trip.
@@ -2454,7 +2459,16 @@ export async function handleChatCore({
           ensureCacheControlOnLastUserMessage(translatedBody);
         }
       } else {
-        normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true });
+        // Same guard as the CC-bridge path: only preserve tool_result blocks
+        // for Anthropic-native targets. See issue #13971. This branch only runs
+        // under isClaudePassthrough (sourceFormat === targetFormat === CLAUDE,
+        // defined above), so targetFormat === FORMATS.CLAUDE always holds here —
+        // the guard is a no-op on this call site, kept for symmetry with the
+        // CC-bridge one above rather than a change to code the issue said not
+        // to touch.
+        normalizeClaudeUpstreamMessages(translatedBody, {
+          preserveToolResultBlocks: targetFormat === FORMATS.CLAUDE,
+        });
       }
 
       log?.debug?.("FORMAT", `claude passthrough (preserveCache=${preserveCacheControl})`);
diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts
index 12bab97f0f..fd6221adf2 100644
--- a/tests/unit/chatcore-translation-paths.test.ts
+++ b/tests/unit/chatcore-translation-paths.test.ts
@@ -379,6 +379,7 @@ async function invokeChatCore({
   reasoningTransportFallback = "drop",
   managedLease = null,
   cachedSettings = null,
+  modelTargetFormat = undefined,
 }: any = {}) {
   const calls: any[] = [];
 
@@ -408,7 +409,10 @@ async function invokeChatCore({
     const requestBody = structuredClone(body);
     const result = await handleChatCore({
       body: requestBody,
-      modelInfo: { provider, model, extendedContext: false },
+      modelInfo:
+        modelTargetFormat !== undefined
+          ? { provider, model, extendedContext: false, targetFormat: modelTargetFormat }
+          : { provider, model, extendedContext: false },
       credentials: credentials || {
         apiKey: "sk-test",
         // #13452/#13798: buildUrl() refuses an `*-compatible-*` node with no baseUrl
@@ -1565,6 +1569,65 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay
   // user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true)
   assert.equal(call.body.messages[2].content[0].type, "tool_result");
 });
+
+// Issue #13971: the CC-bridge unconditionally preserved raw tool_result blocks even when the
+// target speaks OpenAI-compatible (503 on those gateways). Fix: gate preserveToolResultBlocks
+// on targetFormat === FORMATS.CLAUDE. userAgent is plain (non-Claude-Code) so both requests hit
+// the CC-bridge's normalizeClaudeUpstreamMessages branch (chatCore.ts:2377-2385), not the
+// Claude-Code semantic-passthrough branch above it, which this fix does not touch.
+function ccBridgeToolResultCall(modelTargetFormat?: string) {
+  return invokeChatCore({
+    provider: "anthropic-compatible-cc-test",
+    model: "claude-sonnet-4-6",
+    endpoint: "/v1/messages",
+    credentials: {
+      apiKey: "sk-test",
+      providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" },
+    },
+    body: {
+      model: "claude-sonnet-4-6",
+      max_tokens: 64,
+      messages: [
+        {
+          role: "assistant",
+          content: [{ type: "tool_use", id: "toolu_x", name: "Read", input: {} }],
+        },
+        {
+          role: "user",
+          content: [{ type: "tool_result", tool_use_id: "toolu_x", content: "file contents" }],
+        },
+      ],
+      tools: [{ name: "Read", input_schema: { type: "object", properties: {} } }],
+    },
+    userAgent: "unit-test",
+    responseFormat: "claude",
+    modelTargetFormat,
+  });
+}
+test("chatCore strips tool_result blocks on the CC-bridge path when the target is OpenAI-compatible", async () => {
+  const { call, result } = await ccBridgeToolResultCall("openai");
+  assert.equal(result.success, true);
+  // No block may be raw tool_result/tool_use — that shape 503'd on #13971; the
+  // orphan-tool-use cleanup also drops the now-unmatched assistant turn, a stronger guard.
+  for (const message of call.body.messages) {
+    for (const block of message.content) {
+      assert.notEqual(block.type, "tool_result");
+      assert.notEqual(block.type, "tool_use");
+    }
+  }
+  const flattened = call.body.messages
+    .flatMap((m: { content: Array<{ text?: string }> }) => m.content)
+    .map((b: { text?: string }) => b.text)
+    .join("\n");
+  assert.match(flattened, /file contents/);
+});
+// Same branch, real (Claude-native) target format — tool_result stays preserved raw.
+test("chatCore still preserves tool_result blocks on the CC-bridge path when the target is Claude-native", async () => {
+  const { call, result } = await ccBridgeToolResultCall();
+  assert.equal(result.success, true);
+  assert.equal(call.body.messages[0].content[0].type, "tool_use");
+  assert.equal(call.body.messages[1].content[0].type, "tool_result");
+});
 test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => {
   await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
   invalidateCacheControlSettingsCache();

From 2e0edd22415909b79a76d1246431a25914c626a3 Mon Sep 17 00:00:00 2001
From: stormsia <157276367+stormsia@users.noreply.github.com>
Date: Sat, 19 Sep 2026 06:05:43 +0300
Subject: [PATCH 30/33] fix(grok-cli): treat rolling 24h free-usage 429 as
 quota exhaustion (#13984)

* fix(grok-cli): treat rolling 24h free-usage 429 as quota exhaustion

* docs(grok-cli): fix comment parens in rolling 24h quota fallback
---
 changelog.d/fixes/grok-cli-free-usage-429.md |   1 +
 open-sse/services/accountFallback.ts         |   4 +-
 open-sse/services/quotaTextCooldowns.ts      |  25 +++
 src/shared/utils/classify429.ts              |   6 +
 tests/unit/grok-cli-free-usage-429.test.ts   | 200 +++++++++++++++++++
 5 files changed, 235 insertions(+), 1 deletion(-)
 create mode 100644 changelog.d/fixes/grok-cli-free-usage-429.md
 create mode 100644 tests/unit/grok-cli-free-usage-429.test.ts

diff --git a/changelog.d/fixes/grok-cli-free-usage-429.md b/changelog.d/fixes/grok-cli-free-usage-429.md
new file mode 100644
index 0000000000..084b578b33
--- /dev/null
+++ b/changelog.d/fixes/grok-cli-free-usage-429.md
@@ -0,0 +1 @@
+- **fix(grok-cli):** a 429 "used all the included free usage … rolling 24-hour window" on Grok Build is quota exhaustion for that model, not a 30s rate-limit wait. Combo skips the drained grok-4.6 login and tries the next account
diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts
index db348e84bb..91e88b9e35 100644
--- a/open-sse/services/accountFallback.ts
+++ b/open-sse/services/accountFallback.ts
@@ -94,6 +94,7 @@ import {
   buildSubscriptionQuotaFallback,
   buildWeeklyQuotaFallback,
   buildSessionQuotaFallback,
+  buildRolling24hQuotaFallback,
   SUBSCRIPTION_QUOTA_COOLDOWN_MS,
 } from "./quotaTextCooldowns.ts";
 import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts";
@@ -2058,7 +2059,8 @@ export function checkFallbackError(
     // runs UNCONDITIONALLY for the same reason: apikey-category providers
     // like ollama-cloud are excluded from the oauth-only shouldUseQuotaSignal
     // gate.
-    const sessionResult = buildSessionQuotaFallback(errorStr);
+    const sessionResult =
+      buildSessionQuotaFallback(errorStr) ?? buildRolling24hQuotaFallback(errorStr);
     if (sessionResult) return sessionResult;
 
     const detectedRetryHint = detectRetryHint();
diff --git a/open-sse/services/quotaTextCooldowns.ts b/open-sse/services/quotaTextCooldowns.ts
index e5a13cf62f..f0e663ec70 100644
--- a/open-sse/services/quotaTextCooldowns.ts
+++ b/open-sse/services/quotaTextCooldowns.ts
@@ -165,3 +165,28 @@ export function buildSessionQuotaFallback(errorStr: string): QuotaTextFallback |
     reason: RateLimitReason.QUOTA_EXHAUSTED,
   };
 }
+
+// xAI Grok Build free-tier per-model rolling 24h token cap. Live 429:
+// "You've used all the included free usage for model grok-4.6 for now.
+//  Usage resets over a rolling 24-hour window — tokens (actual/limit): N/M."
+// Grok Build is passthroughModels, so this stays a model lockout (not a
+// connection park). Combo must treat it as quota_exhausted so it does not
+// wait comboCooldownWait.maxWaitMs (~30s) and retry the same login.
+const ROLLING_24H_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000;
+
+export function isRolling24hUsageLimitText(lower: string): boolean {
+  return (
+    lower.includes("used all the included free usage") ||
+    (lower.includes("rolling 24-hour window") && lower.includes("tokens (actual/limit)"))
+  );
+}
+
+export function buildRolling24hQuotaFallback(errorStr: string): QuotaTextFallback | null {
+  if (!isRolling24hUsageLimitText(errorStr.toLowerCase())) return null;
+  return {
+    shouldFallback: true,
+    cooldownMs: ROLLING_24H_QUOTA_COOLDOWN_MS,
+    reason: RateLimitReason.QUOTA_EXHAUSTED,
+    quotaResetHintMs: ROLLING_24H_QUOTA_COOLDOWN_MS,
+  };
+}
diff --git a/src/shared/utils/classify429.ts b/src/shared/utils/classify429.ts
index 4d393b5870..2ff6c6dc31 100644
--- a/src/shared/utils/classify429.ts
+++ b/src/shared/utils/classify429.ts
@@ -103,6 +103,12 @@ const QUOTA_PATTERNS: ReadonlyArray = [
   /\bTPD rate limit\b/i,
   /insufficient balance/i,
 
+  // xAI Grok Build free-tier per-model rolling 24h cap. Live body:
+  // "You've used all the included free usage for model grok-4.6 for now.
+  //  Usage resets over a rolling 24-hour window — tokens (actual/limit): N/M."
+  /used all the included free usage/i,
+  /resets over a rolling 24-hour window/i,
+
   // ── CJK quota-exhaustion patterns (#13194) ────────────────────────────
   // Chinese (simplified) providers (z.ai/GLM, Kimi/Moonshot, Qwen/DashScope,
   // MiniMax) return 429 bodies entirely in Chinese. Without these, the
diff --git a/tests/unit/grok-cli-free-usage-429.test.ts b/tests/unit/grok-cli-free-usage-429.test.ts
new file mode 100644
index 0000000000..7b6eb4e697
--- /dev/null
+++ b/tests/unit/grok-cli-free-usage-429.test.ts
@@ -0,0 +1,200 @@
+/**
+ * Grok Build free-tier rolling 24h cap is a 429, not a 402 wallet miss.
+ *
+ * Live body:
+ *   "You've used all the included free usage for model grok-4.6 for now.
+ *    Usage resets over a rolling 24-hour window — tokens (actual/limit):
+ *    513161/500000."
+ *
+ * Before this fix the classifier treated it as a short rate_limit. Combo then
+ * waited ~30s (comboCooldownWait.maxWaitMs) and retried the same grok-4.6
+ * login instead of locking that model on that connection and advancing.
+ */
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-grok-cli-429-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "grok-cli-429-test-secret";
+process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
+
+const { classify429, looksLikeQuotaExhausted } =
+  await import("../../src/shared/utils/classify429.ts");
+const accountFallback = await import("../../open-sse/services/accountFallback.ts");
+const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
+const { shouldWaitForComboCooldown } =
+  await import("../../open-sse/services/combo/comboCooldownRetry.ts");
+const { applyComboTargetExhaustion } =
+  await import("../../open-sse/services/combo/targetExhaustion.ts");
+const comboLog = { info() {}, warn() {}, error() {}, debug() {} };
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const auth = await import("../../src/sse/services/auth.ts");
+
+const GROK_FREE_USAGE_429 =
+  "You've used all the included free usage for model grok-4.6 for now. " +
+  "Usage resets over a rolling 24-hour window — tokens (actual/limit): 513161/500000. " +
+  "Upgrade to a Grok subscription for higher limits: https://grok.com/supergrok";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+async function resetStorage() {
+  core.resetDbInstance();
+  fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
+  fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+async function seedGrokCli(name: string) {
+  return providersDb.createProviderConnection({
+    provider: "grok-cli",
+    authType: "oauth",
+    name,
+    email: name,
+    accessToken: `grok-cli-${name}`,
+    isActive: true,
+    testStatus: "active",
+  });
+}
+
+test.after(() => {
+  core.resetDbInstance();
+  fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
+});
+
+test("classify429: Grok Build free-usage rolling 24h 429 is quota_exhausted", () => {
+  assert.equal(looksLikeQuotaExhausted(GROK_FREE_USAGE_429), true);
+  assert.equal(classify429({ status: 429, body: GROK_FREE_USAGE_429 }), "quota_exhausted");
+  assert.equal(
+    classify429({ status: 429, body: { error: { message: GROK_FREE_USAGE_429 } } }),
+    "quota_exhausted"
+  );
+});
+
+test("classify429: a generic Grok 429 without the 24h free-usage phrase stays rate_limit", () => {
+  assert.equal(
+    classify429({ status: 429, body: "Too many requests. Please retry shortly." }),
+    "rate_limit"
+  );
+});
+
+test("checkFallbackError: Grok Build free-usage 429 is QUOTA_EXHAUSTED with a 24h cooldown", () => {
+  const result = accountFallback.checkFallbackError(
+    429,
+    GROK_FREE_USAGE_429,
+    0,
+    "grok-4.6",
+    "grok-cli"
+  );
+  assert.equal(result.shouldFallback, true);
+  assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
+  assert.ok(
+    result.cooldownMs >= DAY_MS - 60_000,
+    `expected ~24h cooldown, got ${result.cooldownMs}`
+  );
+});
+
+test("combo must not wait 30s on this 429 — quota_exhausted is non-retryable", () => {
+  const fallback = accountFallback.checkFallbackError(
+    429,
+    GROK_FREE_USAGE_429,
+    0,
+    "grok-4.6",
+    "grok-cli"
+  );
+  const reason =
+    fallback.reason === RateLimitReason.QUOTA_EXHAUSTED ? "quota_exhausted" : "rate_limited";
+  const decision = shouldWaitForComboCooldown({
+    reason,
+    waitMs: 30_000,
+    attempt: 0,
+    budgetLeftMs: 90_000,
+    settings: { enabled: true, maxWaitMs: 30_000, maxAttempts: 2, budgetMs: 90_000 },
+  });
+  assert.equal(reason, "quota_exhausted");
+  assert.equal(decision.wait, false);
+});
+
+test("grok-cli 429 parks grok-4.6 on that login, not the whole connection", async () => {
+  await resetStorage();
+  const conn = await seedGrokCli("free@example.com");
+  const id = (conn as { id: string }).id;
+
+  const result = await auth.markAccountUnavailable(
+    id,
+    429,
+    GROK_FREE_USAGE_429,
+    "grok-cli",
+    "grok-4.6"
+  );
+  assert.equal(result.shouldFallback, true);
+
+  const after = await providersDb.getProviderConnectionById(id);
+  assert.equal(after.testStatus, "active", "passthrough 429 must stay model-scoped");
+
+  const lockout = accountFallback.getModelLockoutInfo("grok-cli", id, "grok-4.6");
+  assert.equal(lockout?.reason, "quota_exhausted");
+  assert.ok(
+    (lockout?.remainingMs ?? 0) > 60_000,
+    `lockout must outlast the 30s combo wait, got ${lockout?.remainingMs}`
+  );
+});
+
+test("a sibling grok-cli login stays eligible after another login's 24h 429", async () => {
+  await resetStorage();
+  const empty = await seedGrokCli("empty@example.com");
+  const live = await seedGrokCli("live@example.com");
+  const emptyId = (empty as { id: string }).id;
+  const liveId = (live as { id: string }).id;
+
+  await auth.markAccountUnavailable(emptyId, 429, GROK_FREE_USAGE_429, "grok-cli", "grok-4.6");
+
+  assert.equal(accountFallback.isModelLocked("grok-cli", liveId, "grok-4.6"), false);
+
+  const selected = await auth.getProviderCredentials("grok-cli", null, null, "grok-4.6");
+  assert.ok(selected);
+  assert.equal(selected.connectionId, liveId);
+});
+
+test("combo exhaustion must not skip sibling grok-cli accounts on this 429", () => {
+  const sets = {
+    exhaustedProviders: new Set(),
+    exhaustedConnections: new Set(),
+    transientRateLimitedProviders: new Set(),
+  };
+  const empty = {
+    kind: "model",
+    executionKey: "grok-cli/grok-4.6@empty",
+    provider: "grok-cli",
+    providerId: null,
+    modelStr: "grok-cli/grok-4.6",
+    connectionId: "empty",
+  } as Parameters[0];
+  const fallbackResult = accountFallback.checkFallbackError(
+    429,
+    GROK_FREE_USAGE_429,
+    0,
+    "grok-4.6",
+    "grok-cli"
+  );
+  applyComboTargetExhaustion(empty, {
+    result: { status: 429 },
+    fallbackResult,
+    errorText: GROK_FREE_USAGE_429,
+    rawModel: "grok-4.6",
+    isTokenLimitBreach: false,
+    allAccountsRateLimited: false,
+    requestScopedFailure: false,
+    sets,
+    log: comboLog,
+    tag: "COMBO",
+    exhaustedLogLevel: "info",
+  });
+  assert.equal(
+    sets.exhaustedProviders.has("grok-cli"),
+    false,
+    "passthrough per-model 429 must not exhaust the whole grok-cli provider"
+  );
+});

From ec4d1eff43a3ffde69ec4ab2009fdf7e01f09960 Mon Sep 17 00:00:00 2001
From: Diego Rodrigues de Sa e Souza 
Date: Sat, 19 Sep 2026 00:41:22 -0300
Subject: [PATCH 31/33] fix(sse): pin DNS on the three public-only image fetch
 sites (#13883) (#14032)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip.

**Reconciled before landing:** the train-3 ejection from 2026-09-18 (`tests/unit/image-generation-route.test.ts` → promptless topaz request came back 502 because `pinDns: true` bypassed the test's mocked `globalThis.fetch`) is fixed in `5fafc490` by registering the same `setPinnedFetchTestOverride()` seam the other four image tests already use, cleared in `resetStorage()`/`after`. Production keeps pinning for real.

**Evidence on the merged tree:** `image-generation-route.test.ts` 25/25; all 18 image-path test files that mock `globalThis.fetch` (handler, upscale, fal, nanobanana, agnes, alibaba, bailian, kie, magnific, minimax, pollinations, qwen, edits-multipart, fetch-timeout, route-auth, pindns-toctou-13883) 183/183; `typecheck:core` clean; `check:open-sse-typecheck` 0 errors; file-size, changelog-integrity, complexity and cognitive-complexity gates OK.

Closes #13883
---
 changelog.d/fixes/13883-image-fetch-pindns.md |  1 +
 open-sse/handlers/imageGeneration.ts          | 26 +++---
 open-sse/handlers/imageUpscale/shared.ts      | 18 ++---
 src/shared/network/remoteImageFetch.ts        | 11 +++
 .../unit/fal-image-generation-default.test.ts |  9 ++-
 tests/unit/image-generation-handler.test.ts   | 54 ++++++-------
 tests/unit/image-generation-route.test.ts     | 10 ++-
 tests/unit/image-upscale.test.ts              |  9 ++-
 tests/unit/nanobanana-image-handler.test.ts   | 10 ++-
 tests/unit/pindns-toctou-13883.test.ts        | 81 +++++++++++++++++++
 10 files changed, 173 insertions(+), 56 deletions(-)
 create mode 100644 changelog.d/fixes/13883-image-fetch-pindns.md
 create mode 100644 tests/unit/pindns-toctou-13883.test.ts

diff --git a/changelog.d/fixes/13883-image-fetch-pindns.md b/changelog.d/fixes/13883-image-fetch-pindns.md
new file mode 100644
index 0000000000..06f8c6d57e
--- /dev/null
+++ b/changelog.d/fixes/13883-image-fetch-pindns.md
@@ -0,0 +1 @@
+- **security(images):** close a DNS-rebinding TOCTOU (#13883) at the three newer public-only image download sites — `resolveImageSource` and the NanoBanana result-URL conversion in `imageGeneration.ts`, and `resolveUpscaleImageSource` in `imageUpscale/shared.ts`. All three validated a caller-supplied URL's DNS answer as public but then let the download perform an independent, un-pinned second resolution at connect time, so a host that answered differently between the two lookups (public, then loopback/LAN) could reach an internal address; they now set `pinDns: true` (reusing the existing `createPinnedFetch` helper already used by embeddings and the vision/audio/video bridges), binding the connection to the exact validated address.
diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts
index 7447fb125e..94c51576e9 100644
--- a/open-sse/handlers/imageGeneration.ts
+++ b/open-sse/handlers/imageGeneration.ts
@@ -2226,7 +2226,7 @@ function extractImageInputs(body) {
   };
 }
 
-async function resolveImageSource(source) {
+export async function resolveImageSource(source) {
   if (typeof source !== "string" || source.trim().length === 0) {
     throw new Error("Invalid image source");
   }
@@ -2243,15 +2243,11 @@ async function resolveImageSource(source) {
   }
 
   if (isHttpUrl(trimmed)) {
-    // GHSA-34rg-3pqj-35g9: this URL is caller input (`image_url` / `mask_url` / message
-    // parts) — pin `public-only` explicitly (string check + DNS validation of every
-    // resolved answer). Never let it fall back to the operator outbound policy
-    // (`getProviderOutboundGuard()`), which is `block-metadata` on a local-first default
-    // install and would let a request body make the server fetch loopback/LAN URLs and
-    // forward the bytes upstream. `pinDns` stays off on purpose: this handler's only
-    // transport is `globalThis.fetch` (no `fetchImpl` seam) and connection pinning
-    // replaces it with a raw undici fetch — same shape as the AI Horde result download.
-    const remoteImage = await fetchRemoteImage(trimmed, { guard: "public-only" });
+    // GHSA-34rg-3pqj-35g9 / #13883: caller-input URL — pin `public-only` (never the operator
+    // outbound policy, which would let a request body reach loopback/LAN) and `pinDns: true`
+    // to close the DNS-rebinding TOCTOU where a second, un-pinned resolution at connect time
+    // could answer differently than the validated lookup and bypass the guard.
+    const remoteImage = await fetchRemoteImage(trimmed, { guard: "public-only", pinDns: true });
     return {
       buffer: remoteImage.buffer,
       base64: remoteImage.buffer.toString("base64"),
@@ -3242,7 +3238,7 @@ function normalizeNanoBananaSyncPayload(data, prompt) {
   return { data: images.filter(Boolean) };
 }
 
-async function normalizeNanoBananaTaskResult(taskData, body, log) {
+export async function normalizeNanoBananaTaskResult(taskData, body, log) {
   const response = taskData?.response || {};
 
   const urlCandidates = [
@@ -3280,10 +3276,10 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) {
 
     if (urlCandidates.length > 0) {
       const firstUrl = urlCandidates[0];
-      // GHSA-34rg-3pqj-35g9: upstream-supplied result URL, not an OmniRoute-controlled
-      // host — pin `public-only` exactly like the AI Horde result download does, never
-      // the operator outbound policy (see `resolveImageSource` for why `pinDns` is off).
-      const remoteImage = await fetchRemoteImage(firstUrl, { guard: "public-only" });
+      // GHSA-34rg-3pqj-35g9 / #13883: upstream-supplied result URL, not an OmniRoute-
+      // controlled host — pin `public-only`, never the operator outbound policy, and
+      // `pinDns: true` to close the DNS-rebinding TOCTOU (see `resolveImageSource`).
+      const remoteImage = await fetchRemoteImage(firstUrl, { guard: "public-only", pinDns: true });
       const base64 = remoteImage.buffer.toString("base64");
       return [{ b64_json: base64, revised_prompt: body.prompt }];
     }
diff --git a/open-sse/handlers/imageUpscale/shared.ts b/open-sse/handlers/imageUpscale/shared.ts
index 557274b88b..7efba61065 100644
--- a/open-sse/handlers/imageUpscale/shared.ts
+++ b/open-sse/handlers/imageUpscale/shared.ts
@@ -163,15 +163,15 @@ export async function resolveUpscaleImageSource(source: string): Promise {
 });
 
 const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
+const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
 
 test("handleImageGeneration returns Fal images as base64 when response_format is omitted", async () => {
   const originalFetch = globalThis.fetch;
-  globalThis.fetch = async (url) => {
+  const mockFetchImpl = async (url) => {
     const stringUrl = String(url);
     if (stringUrl === "https://fal.run/fal-ai/flux-2-flex") {
       return new Response(
@@ -39,6 +40,11 @@ test("handleImageGeneration returns Fal images as base64 when response_format is
     }
     throw new Error(`Unexpected URL: ${stringUrl}`);
   };
+  // #13883: resolveImageSource now sets `pinDns: true`, which pins the connection via a
+  // real undici socket and would bypass this mocked globalThis.fetch — route it through
+  // the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
+  globalThis.fetch = mockFetchImpl;
+  setPinnedFetchTestOverride(mockFetchImpl);
 
   try {
     const result = await handleImageGeneration({
@@ -51,5 +57,6 @@ test("handleImageGeneration returns Fal images as base64 when response_format is
     assert.equal(result.data.data[0].url, undefined);
   } finally {
     globalThis.fetch = originalFetch;
+    setPinnedFetchTestOverride(undefined);
   }
 });
diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts
index 4092f3e807..8749c91e90 100644
--- a/tests/unit/image-generation-handler.test.ts
+++ b/tests/unit/image-generation-handler.test.ts
@@ -7,16 +7,9 @@ import { join } from "node:path";
 
 process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-images-"));
 
-// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
-// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
-// Several image-handler tests (Fal AI URL->b64 normalization, BFL polling
-// with base64 input images, NanoBanana polling with URL->b64 conversion)
-// mock globalThis.fetch with example.com URLs that don't resolve in CI; the
-// handler invokes fetchRemoteImage without exposing a `lookup` injection
-// point, so we monkey-patch dns.promises.lookup to always return a public IP
-// so the rebinding guard passes and the test exercises the mocked fetch
-// behaviour as intended. Node --test runs each file in its own process, so
-// this rebinding does not leak across files.
+// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx guard so mocked example.com URLs
+// resolve as public. #13883's `pinDns: true` pins the connection via undici, bypassing a
+// mocked globalThis.fetch — `mockFetch()` also sets the `setPinnedFetchTestOverride()` seam.
 const originalDnsLookup = dns.promises.lookup;
 (dns.promises as { lookup: unknown }).lookup = (async (
   _hostname: string,
@@ -32,6 +25,11 @@ process.on("exit", () => {
 const { IMAGE_PROVIDERS, parseImageModel, getAllImageModels } =
   await import("../../open-sse/config/imageRegistry.ts");
 const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
+const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
+function mockFetch(impl) {
+  globalThis.fetch = impl;
+  setPinnedFetchTestOverride(impl);
+}
 
 function immediateTimeout(callback, _ms, ...args) {
   if (typeof callback === "function") callback(...args);
@@ -366,7 +364,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
   const originalFetch = globalThis.fetch;
   let requestCapture;
 
-  globalThis.fetch = async (url, options = {}) => {
+  mockFetch(async (url, options = {}) => {
     const stringUrl = String(url);
     if (stringUrl === "https://fal.run/fal-ai/flux-pro/v1.1-ultra") {
       requestCapture = {
@@ -391,7 +389,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
     }
 
     throw new Error(`Unexpected URL: ${stringUrl}`);
-  };
+  });
 
   try {
     const result = await handleImageGeneration({
@@ -416,7 +414,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
     assert.equal(requestCapture.body.sync_mode, true);
     assert.equal(result.data.data[0].b64_json, "BQYH");
   } finally {
-    globalThis.fetch = originalFetch;
+    mockFetch(originalFetch);
   }
 });
 
@@ -424,7 +422,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
   const originalFetch = globalThis.fetch;
   let requestCapture;
 
-  globalThis.fetch = async (url, options = {}) => {
+  mockFetch(async (url, options = {}) => {
     const stringUrl = String(url);
     if (stringUrl === "https://example.com/stability-input.png") {
       return new Response(new Uint8Array([4, 5]), {
@@ -447,7 +445,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
     }
 
     throw new Error(`Unexpected URL: ${stringUrl}`);
-  };
+  });
 
   try {
     const result = await handleImageGeneration({
@@ -476,7 +474,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
     assert.equal((requestCapture.body.get("mask") as Blob).size, 1);
     assert.equal(result.data.data[0].b64_json, "c3RhYmlsaXR5LWltYWdl");
   } finally {
-    globalThis.fetch = originalFetch;
+    mockFetch(originalFetch);
   }
 });
 
@@ -537,7 +535,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
   let pollCapture;
 
   globalThis.setTimeout = immediateTimeout;
-  globalThis.fetch = async (url, options = {}) => {
+  mockFetch(async (url, options = {}) => {
     const stringUrl = String(url);
     if (stringUrl === "https://example.com/bfl-input.png") {
       return new Response(new Uint8Array([1, 2]), {
@@ -582,7 +580,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
     }
 
     throw new Error(`Unexpected URL: ${stringUrl}`);
-  };
+  });
 
   try {
     const result = await handleImageGeneration({
@@ -605,7 +603,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
     assert.equal(pollCapture.headers["x-key"], "bfl-key");
     assert.equal(result.data.data[0].b64_json, "CQgH");
   } finally {
-    globalThis.fetch = originalFetch;
+    mockFetch(originalFetch);
     globalThis.setTimeout = originalSetTimeout;
   }
 });
@@ -660,7 +658,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
   const originalFetch = globalThis.fetch;
   let requestCapture;
 
-  globalThis.fetch = async (url, options = {}) => {
+  mockFetch(async (url, options = {}) => {
     const stringUrl = String(url);
     if (stringUrl === "https://example.com/topaz-input.png") {
       return new Response(new Uint8Array([1, 2, 3]), {
@@ -686,7 +684,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
     }
 
     throw new Error(`Unexpected URL: ${stringUrl}`);
-  };
+  });
 
   try {
     const result = await handleImageGeneration({
@@ -709,7 +707,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
     assert.ok(requestCapture.image instanceof File);
     assert.equal(result.data.data[0].b64_json, "BwcH");
   } finally {
-    globalThis.fetch = originalFetch;
+    mockFetch(originalFetch);
   }
 });
 
@@ -1050,7 +1048,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
   const originalFetch = globalThis.fetch;
   const calls = [];
 
-  globalThis.fetch = async (url, options = {}) => {
+  mockFetch(async (url, options = {}) => {
     const stringUrl = String(url);
     calls.push(stringUrl);
 
@@ -1078,7 +1076,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
     }
 
     throw new Error(`Unexpected URL: ${stringUrl}`);
-  };
+  });
 
   try {
     const result = await handleImageGeneration({
@@ -1099,7 +1097,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
     ]);
     assert.deepEqual(result.data.data, [{ b64_json: "AQIDBA==", revised_prompt: "banana async" }]);
   } finally {
-    globalThis.fetch = originalFetch;
+    mockFetch(originalFetch);
   }
 });
 
@@ -2193,7 +2191,7 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
   const fetchedUrls = [];
   let requestCapture;
 
-  globalThis.fetch = async (url, options = {}) => {
+  mockFetch(async (url, options = {}) => {
     const stringUrl = String(url);
     fetchedUrls.push(stringUrl);
     if (stringUrl === "https://cdn.example.com/public-input.png") {
@@ -2210,7 +2208,7 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
       });
     }
     throw new Error(`Unexpected URL: ${stringUrl}`);
-  };
+  });
 
   try {
     const result = await handleImageGeneration({
@@ -2229,6 +2227,6 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
     assert.equal(fetchedUrls[0], "https://cdn.example.com/public-input.png");
     assert.equal((requestCapture.body.get("image") as Blob).size, 3);
   } finally {
-    globalThis.fetch = originalFetch;
+    mockFetch(originalFetch);
   }
 });
diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts
index 11e3dbb53b..1cae8a7eae 100644
--- a/tests/unit/image-generation-route.test.ts
+++ b/tests/unit/image-generation-route.test.ts
@@ -19,6 +19,7 @@ const providerChatRoute =
   await import("../../src/app/api/v1/providers/[provider]/chat/completions/route.ts");
 const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts");
 const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
+const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
 
 const originalFetch = globalThis.fetch;
 
@@ -72,6 +73,7 @@ function createCodexEditForm(
 
 async function resetStorage() {
   globalThis.fetch = originalFetch;
+  setPinnedFetchTestOverride(undefined);
   apiKeysDb.resetApiKeyState();
   core.resetDbInstance();
   fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
@@ -120,6 +122,7 @@ test.beforeEach(async () => {
 
 test.after(() => {
   globalThis.fetch = originalFetch;
+  setPinnedFetchTestOverride(undefined);
   apiKeysDb.resetApiKeyState();
   core.resetDbInstance();
   fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
@@ -234,7 +237,7 @@ test("v1 image models GET exposes current Codex image models and hides inactive
 test("v1 image generation POST accepts promptless requests for image-only models", async () => {
   await seedConnection("topaz", { apiKey: "topaz-key" });
 
-  globalThis.fetch = async (url, options: RequestInit = {}) => {
+  const mockFetchImpl = async (url, options: RequestInit = {}) => {
     const stringUrl = String(url);
     if (stringUrl === "https://example.com/topaz-input.png") {
       return new Response(new Uint8Array([1, 2, 3]), {
@@ -254,6 +257,11 @@ test("v1 image generation POST accepts promptless requests for image-only models
 
     throw new Error(`Unexpected URL: ${stringUrl}`);
   };
+  // #13883: resolveImageSource now sets `pinDns: true`, which pins the connection via a
+  // real undici socket and would bypass this mocked globalThis.fetch — route it through
+  // the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
+  globalThis.fetch = mockFetchImpl;
+  setPinnedFetchTestOverride(mockFetchImpl);
 
   const response = await imageRoute.POST(
     new Request("http://localhost/api/v1/images/generations", {
diff --git a/tests/unit/image-upscale.test.ts b/tests/unit/image-upscale.test.ts
index 851be6e0bc..2cbbf4038a 100644
--- a/tests/unit/image-upscale.test.ts
+++ b/tests/unit/image-upscale.test.ts
@@ -34,6 +34,7 @@ import { handleImageUpscale } from "../../open-sse/handlers/imageUpscale.ts";
 import { handleStabilityImageUpscale } from "../../open-sse/handlers/imageUpscale/stability.ts";
 import { handleTopazImageUpscale } from "../../open-sse/handlers/imageUpscale/topaz.ts";
 import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts";
+import { setPinnedFetchTestOverride } from "../../src/shared/network/remoteImageFetch.ts";
 
 // ── Fixtures ───────────────────────────────────────────────────────────────
 
@@ -758,10 +759,15 @@ for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png
 test("resolveUpscaleImageSource still downloads a public URL whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => {
   const originalFetch = globalThis.fetch;
   const fetchedUrls: string[] = [];
-  globalThis.fetch = (async (url: string | URL | Request) => {
+  const mockFetchImpl = (async (url: string | URL | Request) => {
     fetchedUrls.push(String(url));
     return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } });
   }) as unknown as typeof fetch;
+  // #13883: resolveUpscaleImageSource now sets `pinDns: true`, which pins the connection
+  // via a real undici socket and would bypass this mocked globalThis.fetch — route it
+  // through the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
+  globalThis.fetch = mockFetchImpl;
+  setPinnedFetchTestOverride(mockFetchImpl);
 
   try {
     const source = await withPublicDns(() =>
@@ -772,5 +778,6 @@ test("resolveUpscaleImageSource still downloads a public URL whose DNS resolves
     assert.deepEqual(fetchedUrls, ["https://cdn.example.com/public.png"]);
   } finally {
     globalThis.fetch = originalFetch;
+    setPinnedFetchTestOverride(undefined);
   }
 });
diff --git a/tests/unit/nanobanana-image-handler.test.ts b/tests/unit/nanobanana-image-handler.test.ts
index fc438dff46..034ba6ddd7 100644
--- a/tests/unit/nanobanana-image-handler.test.ts
+++ b/tests/unit/nanobanana-image-handler.test.ts
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
 import dns from "node:dns";
 
 import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
+import { setPinnedFetchTestOverride } from "../../src/shared/network/remoteImageFetch.ts";
 
 // Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
 // (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
@@ -93,7 +94,7 @@ test("handleImageGeneration(nanobanana): async submit+poll returns URL payload",
 test("handleImageGeneration(nanobanana): response_format=b64_json converts URL to b64", async () => {
   const originalFetch = globalThis.fetch;
 
-  globalThis.fetch = async (url) => {
+  const mockFetchImpl = async (url) => {
     const u = String(url);
 
     if (u.includes("/generate")) {
@@ -123,6 +124,12 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t
 
     throw new Error(`Unexpected URL: ${u}`);
   };
+  // #13883: resolveImageSource (used for the URL result → base64 conversion) now sets
+  // `pinDns: true`, which pins the connection via a real undici socket and would bypass
+  // this mocked globalThis.fetch — route it through the test-only pinned-fetch override
+  // instead (src/shared/network/remoteImageFetch.ts).
+  globalThis.fetch = mockFetchImpl;
+  setPinnedFetchTestOverride(mockFetchImpl);
 
   try {
     const result = await handleImageGeneration({
@@ -140,6 +147,7 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t
     assert.equal(result.data.data[0].b64_json, "iVBORw==");
   } finally {
     globalThis.fetch = originalFetch;
+    setPinnedFetchTestOverride(undefined);
   }
 });
 
diff --git a/tests/unit/pindns-toctou-13883.test.ts b/tests/unit/pindns-toctou-13883.test.ts
new file mode 100644
index 0000000000..194746d7dc
--- /dev/null
+++ b/tests/unit/pindns-toctou-13883.test.ts
@@ -0,0 +1,81 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import dns from "node:dns";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-pindns-13883-"));
+
+// #13883 — security: pinDns off at the three new public-only image fetch sites let a
+// DNS-rebinding hostname (public at validation time, private at real connect time) bypass
+// the `guard: "public-only"` check, since an un-pinned fetch performs its own, independent
+// DNS resolution. `resolveImageSource` / `normalizeNanoBananaTaskResult` (imageGeneration.ts)
+// and `resolveUpscaleImageSource` (imageUpscale/shared.ts) now all set `pinDns: true`, which
+// closes the gap by binding the connection to the single validated DNS answer instead of
+// letting the transport re-resolve it — see `src/shared/network/dnsPinnedFetch.ts`.
+//
+// None of these three call sites expose a `lookup` injection point (they always use the
+// real resolver), so this regression guard resolves a fake hostname to a public-looking,
+// deliberately unreachable TEST-NET-3 address (RFC 5737 — never routed on the public
+// internet) and asserts each site's request goes out through the real pinned undici socket
+// (and therefore fails closed against that unreachable address) rather than through a
+// mocked `globalThis.fetch`. If a future change dropped `pinDns: true` at any of these
+// sites, the un-pinned `fetch()` call would hit the mock below instead — turning this red.
+
+function withPublicDns(run: () => Promise): Promise {
+  const original = dns.promises.lookup;
+  (dns.promises as { lookup: unknown }).lookup = (async (
+    _hostname: string,
+    options?: { all?: boolean }
+  ) => {
+    const record = { address: "203.0.113.7", family: 4 }; // RFC 5737 TEST-NET-3: unreachable
+    return options && options.all ? [record] : record;
+  }) as typeof dns.promises.lookup;
+  return run().finally(() => {
+    (dns.promises as { lookup: unknown }).lookup = original;
+  });
+}
+
+const { resolveImageSource, normalizeNanoBananaTaskResult } =
+  await import("../../open-sse/handlers/imageGeneration.ts");
+const { resolveUpscaleImageSource } =
+  await import("../../open-sse/handlers/imageUpscale/shared.ts");
+
+/** Runs `attempt` with a mocked `globalThis.fetch` that must never be reached when
+ * `pinDns: true` is wired correctly, and confirms the call still fails closed (the pinned
+ * connection targets an unreachable address instead of falling back to the mock). */
+async function assertPinnedNotMocked(attempt: () => Promise): Promise {
+  let mockCalled = false;
+  const originalFetch = globalThis.fetch;
+  globalThis.fetch = (async () => {
+    mockCalled = true;
+    throw new Error("globalThis.fetch must not be reached when pinDns is active");
+  }) as typeof fetch;
+
+  try {
+    await assert.rejects(() => withPublicDns(attempt));
+    assert.equal(mockCalled, false, "pinDns must bypass globalThis.fetch, not call it");
+  } finally {
+    globalThis.fetch = originalFetch;
+  }
+}
+
+test("resolveImageSource (imageGeneration.ts) fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
+  await assertPinnedNotMocked(() => resolveImageSource("https://rebind-13883.example.com/x.png"));
+});
+
+test("normalizeNanoBananaTaskResult result-URL download fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
+  const taskData = {
+    response: { resultImageUrl: "https://rebind-13883.example.com/result.png" },
+  };
+  await assertPinnedNotMocked(() =>
+    normalizeNanoBananaTaskResult(taskData, { response_format: "b64_json" }, null)
+  );
+});
+
+test("resolveUpscaleImageSource (imageUpscale/shared.ts) fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
+  await assertPinnedNotMocked(() =>
+    resolveUpscaleImageSource("https://rebind-13883.example.com/source.png")
+  );
+});

From 3fd1265d8905c73515f67947bcc565a4e43bc067 Mon Sep 17 00:00:00 2001
From: Diego Rodrigues de Sa e Souza 
Date: Sat, 19 Sep 2026 00:41:36 -0300
Subject: [PATCH 32/33] fix(dashboard): explicit notice for size-limit-omitted
 log payloads (#14045)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip.

**Reconciled before landing:** `requestLogger.detail.payloadSizeLimitOmitted` was stamped `__MISSING__:` in 65 locales and the new-key gate rejects markers since 2026-09-17. The LAN translation backends were unavailable (codex accounts at quota for ~136h, claude connection expired, .113 instance in resource_pressure), so the single string was translated by hand in `818a1151` — `CALL_LOG_PIPELINE_MAX_SIZE_KB` kept verbatim in every locale, one line changed per catalog.

**Evidence on the merged tree:** `check-new-key-coverage` PASS (every new key reached all 65 locales); `payload-section-size-limit-notice.test.tsx` 7/7; `typecheck:core` clean; file-size, changelog-integrity, complexity and cognitive-complexity gates OK; prettier clean on all 65 catalogs.

**Inherited, not from this PR** (identical on the pure tip): `check-env-doc-sync` misses `BRIDGE_PORT`/`CERT_DIR`/`OPENWA_SERVICE_PORT`/`ROUTER_URL` in `.env.example`, and `check-key-completeness` reports 7 keys (`providers.claude*`, `sidebar.pin*`) present in `en.json` but absent from every locale.

Closes #13894
---
 .env.example                                  |   4 +-
 .../13894-log-detail-size-limit-notice.md     |   1 +
 src/i18n/messages/am.json                     |   3 +-
 src/i18n/messages/ar.json                     |   1 +
 src/i18n/messages/az.json                     |   1 +
 src/i18n/messages/bg.json                     |   1 +
 src/i18n/messages/bn.json                     |   1 +
 src/i18n/messages/cs.json                     |   1 +
 src/i18n/messages/da.json                     |   1 +
 src/i18n/messages/de.json                     |   1 +
 src/i18n/messages/el.json                     |   3 +-
 src/i18n/messages/en.json                     |   1 +
 src/i18n/messages/es.json                     |   1 +
 src/i18n/messages/et.json                     |   1 +
 src/i18n/messages/fa.json                     |   1 +
 src/i18n/messages/fi.json                     |   1 +
 src/i18n/messages/fr.json                     |   1 +
 src/i18n/messages/ga.json                     |   3 +-
 src/i18n/messages/gu.json                     |   1 +
 src/i18n/messages/ha.json                     |   3 +-
 src/i18n/messages/he.json                     |   1 +
 src/i18n/messages/hi.json                     |   1 +
 src/i18n/messages/hr.json                     |   1 +
 src/i18n/messages/hu.json                     |   1 +
 src/i18n/messages/hy.json                     |   3 +-
 src/i18n/messages/id.json                     |   1 +
 src/i18n/messages/ig.json                     |   1 +
 src/i18n/messages/it.json                     |   1 +
 src/i18n/messages/ja.json                     |   1 +
 src/i18n/messages/ka.json                     |   1 +
 src/i18n/messages/km.json                     |   3 +-
 src/i18n/messages/kn.json                     |   1 +
 src/i18n/messages/ko.json                     |   1 +
 src/i18n/messages/lt.json                     |   1 +
 src/i18n/messages/lv.json                     |   3 +-
 src/i18n/messages/ml.json                     |   1 +
 src/i18n/messages/mr.json                     |   1 +
 src/i18n/messages/ms.json                     |   1 +
 src/i18n/messages/mt.json                     |   1 +
 src/i18n/messages/my.json                     |   1 +
 src/i18n/messages/ne.json                     |   1 +
 src/i18n/messages/nl.json                     |   1 +
 src/i18n/messages/no.json                     |   1 +
 src/i18n/messages/or.json                     |   1 +
 src/i18n/messages/pa.json                     |   1 +
 src/i18n/messages/phi.json                    |   1 +
 src/i18n/messages/pl.json                     |   1 +
 src/i18n/messages/pt-BR.json                  |   1 +
 src/i18n/messages/pt.json                     |   1 +
 src/i18n/messages/ro.json                     |   1 +
 src/i18n/messages/ru.json                     |   1 +
 src/i18n/messages/si.json                     |   3 +-
 src/i18n/messages/sk.json                     |   1 +
 src/i18n/messages/sl.json                     |   1 +
 src/i18n/messages/sr.json                     |   1 +
 src/i18n/messages/sv.json                     |   1 +
 src/i18n/messages/sw.json                     |   1 +
 src/i18n/messages/ta.json                     |   1 +
 src/i18n/messages/te.json                     |   1 +
 src/i18n/messages/th.json                     |   1 +
 src/i18n/messages/tr.json                     |   1 +
 src/i18n/messages/uk-UA.json                  |   1 +
 src/i18n/messages/ur.json                     |   1 +
 src/i18n/messages/uz.json                     |   1 +
 src/i18n/messages/vi.json                     |   1 +
 src/i18n/messages/yo.json                     |   3 +-
 src/i18n/messages/zh-CN.json                  |   1 +
 src/i18n/messages/zh-TW.json                  |   1 +
 src/lib/usage/callLogArtifacts.ts             |  28 ++--
 .../RequestLoggerDetail.sections.tsx          |  77 ++++++++--
 src/shared/components/RequestLoggerDetail.tsx |  34 +++--
 .../constants/callLogSizeLimitMarkers.ts      |  40 ++++++
 ...payload-section-size-limit-notice.test.tsx | 134 ++++++++++++++++++
 73 files changed, 348 insertions(+), 54 deletions(-)
 create mode 100644 changelog.d/fixes/13894-log-detail-size-limit-notice.md
 create mode 100644 src/shared/constants/callLogSizeLimitMarkers.ts
 create mode 100644 tests/unit/dashboard/payload-section-size-limit-notice.test.tsx

diff --git a/.env.example b/.env.example
index 67775b6725..a31d2284a2 100644
--- a/.env.example
+++ b/.env.example
@@ -1890,8 +1890,8 @@ APP_LOG_TO_FILE=true
 # bodies is retained in the database.
 # Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
 # CHAT_LOG_TEXT_LIMIT=65536                  # Max string length before truncation (default: 64 KB)
-# CHAT_LOG_ARRAY_TAIL_ITEMS=128              # Number of array items retained from tail (default: 128)
-# CHAT_LOG_MAX_DEPTH=6                       # Max nesting depth before truncation (default: 6)
+# CHAT_LOG_ARRAY_TAIL_ITEMS=1000             # Number of array items retained from tail (default: 1000)
+# CHAT_LOG_MAX_DEPTH=20                      # Max nesting depth before truncation (default: 20)
 # CHAT_LOG_MAX_OBJECT_KEYS=80                # Max object keys retained (default: 80, 0 = no limit)
 # CHAT_LOG_MAX_BODY_KB=1024                  # Whole request/response body size before it's replaced by a bare
                                               # {_truncated, messageCount, ...} summary instead of the full clone
diff --git a/changelog.d/fixes/13894-log-detail-size-limit-notice.md b/changelog.d/fixes/13894-log-detail-size-limit-notice.md
new file mode 100644
index 0000000000..fe9010edc3
--- /dev/null
+++ b/changelog.d/fixes/13894-log-detail-size-limit-notice.md
@@ -0,0 +1 @@
+- **fix(dashboard):** the request-log detail view now shows an explicit "payload omitted — exceeded the call log size limit" notice for a pipeline/request/response section that was replaced by the size-limit marker (`_omniroute_truncated` / `[omitted: call log artifact size limit exceeded]`), instead of silently rendering the marker verbatim under a generic "Pipeline Error" title as if it were a real upstream error. Also fixed `.env.example` documenting stale `CHAT_LOG_ARRAY_TAIL_ITEMS=128`/`CHAT_LOG_MAX_DEPTH=6` defaults that no longer match the code's actual `1000`/`20`.
diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json
index 8f5f029db8..e5602201aa 100644
--- a/src/i18n/messages/am.json
+++ b/src/i18n/messages/am.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "የትስስር መታወቂያ፦ {id}",
       "detailedPayloadInfo": "ለአዳዲስ ጥያቄዎች ባለአራት-ደረጃ የደንበኛ/አቅራቢ ውሂብ እይታን ከፈለጉ፣ መጀመሪያ ዝርዝር ምዝገባን ያንቁ።",
       "copyAll": "ሁሉንም ቅዳ",
-      "copiedAll": "ሁሉም ተቀድቷል"
+      "copiedAll": "ሁሉም ተቀድቷል",
+      "payloadSizeLimitOmitted": "ጭነት ተትቷል — ይህ ክፍል የጥሪ ምዝግብ መጠን ገደብን (CALL_LOG_PIPELINE_MAX_SIZE_KB) አልፏል እና አልተቀመጠም፤ እውነተኛ የላይኛው ስህተት አይደለም።"
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 3f0f02f257..b91d4b0890 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "استجابة العميل",
         "pipelineError": "خطأ في خط الأنابيب"
       },
+      "payloadSizeLimitOmitted": "تم حذف الحمولة — تجاوز هذا القسم حد حجم سجل الاستدعاءات (CALL_LOG_PIPELINE_MAX_SIZE_KB) ولم يتم تخزينه؛ هذا ليس خطأً حقيقيًا من المزوّد.",
       "payloadMissing": "لا يتوفر حِمل البيانات التفصيلي بعد الآن لهذه السجل.",
       "payloadCorrupt": "تعذر تحليل الحمولة التفصيلية.",
       "notAvailable": "غير متوفر",
diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json
index 5f1588a81f..4f76783c38 100644
--- a/src/i18n/messages/az.json
+++ b/src/i18n/messages/az.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Müştəri Cavabı",
         "pipelineError": "Borular Xətası"
       },
+      "payloadSizeLimitOmitted": "Yük buraxıldı — bu bölmə çağırış jurnalının ölçü limitini (CALL_LOG_PIPELINE_MAX_SIZE_KB) aşdı və saxlanılmadı; bu, real yuxarı axın xətası deyil.",
       "payloadMissing": "Bu log girişinə aid ətraflı yük artefaktı artıq mövcud deyil.",
       "payloadCorrupt": "Ətraflı yük artefaktı təhlil oluna bilmədi.",
       "notAvailable": "Mövcud deyil",
diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json
index f5f0740e90..fb58a93d13 100644
--- a/src/i18n/messages/bg.json
+++ b/src/i18n/messages/bg.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Отговор на клиента",
         "pipelineError": "Грешка в потока"
       },
+      "payloadSizeLimitOmitted": "Съдържанието е пропуснато — тази секция надвиши лимита за размер на дневника на заявките (CALL_LOG_PIPELINE_MAX_SIZE_KB) и не беше записана; това не е реална грешка от доставчика.",
       "payloadMissing": "Подробният артефакт на полезния товар вече не е наличен за този запис на лог.",
       "payloadCorrupt": "Неуспешно парсиране на детайлен артефакт на полезния товар.",
       "notAvailable": "Няма данни",
diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json
index 378738b366..3cc50edbb1 100644
--- a/src/i18n/messages/bn.json
+++ b/src/i18n/messages/bn.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "ক্লায়েন্ট প্রতিক্রিয়া",
         "pipelineError": "পাইপলাইন ত্রুটি"
       },
+      "payloadSizeLimitOmitted": "পেলোড বাদ দেওয়া হয়েছে — এই অংশটি কল লগের আকারসীমা (CALL_LOG_PIPELINE_MAX_SIZE_KB) ছাড়িয়ে গেছে এবং সংরক্ষণ করা হয়নি; এটি প্রকৃত আপস্ট্রিম ত্রুটি নয়।",
       "payloadMissing": "এই লগ এন্ট্রির জন্য বিস্তারিত পেলোড আর্টিফ্যাক্ট আর উপলব্ধ নেই।",
       "payloadCorrupt": "বিস্তারিত পেলোড আর্টিফ্যাক্ট পার্স করা যায়নি।",
       "notAvailable": "প্রযোজ্য নয়",
diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json
index 3168a9838c..fb2ac9b876 100644
--- a/src/i18n/messages/cs.json
+++ b/src/i18n/messages/cs.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Odpověď klienta",
         "pipelineError": "Chyba v pipeline"
       },
+      "payloadSizeLimitOmitted": "Obsah vynechán — tato sekce překročila limit velikosti záznamu volání (CALL_LOG_PIPELINE_MAX_SIZE_KB) a nebyla uložena; nejde o skutečnou chybu poskytovatele.",
       "payloadMissing": "Podrobný artefakt payloadu již není k dispozici pro tento záznam protokolu.",
       "payloadCorrupt": "Podrobný payload artefakt nebyl možné analyzovat.",
       "notAvailable": "Není k dispozici",
diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json
index de39f2ea2e..e24cd8e168 100644
--- a/src/i18n/messages/da.json
+++ b/src/i18n/messages/da.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Klientrespons",
         "pipelineError": "Pipeline Fejl"
       },
+      "payloadSizeLimitOmitted": "Payload udeladt — denne sektion overskred størrelsesgrænsen for kaldloggen (CALL_LOG_PIPELINE_MAX_SIZE_KB) og blev ikke gemt; det er ikke en reel upstream-fejl.",
       "payloadMissing": "Den detaljerede payload-artifact er ikke længere tilgængelig for denne logpost.",
       "payloadCorrupt": "Den detaljerede payload-artifact kunne ikke parses.",
       "notAvailable": "Ikke tilgængelig",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 7bd41db6da..4d3a945fca 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Kundenantwort",
         "pipelineError": "Pipeline-Fehler"
       },
+      "payloadSizeLimitOmitted": "Payload ausgelassen — dieser Abschnitt hat das Größenlimit des Aufrufprotokolls (CALL_LOG_PIPELINE_MAX_SIZE_KB) überschritten und wurde nicht gespeichert; es handelt sich nicht um einen echten Upstream-Fehler.",
       "payloadMissing": "Das detaillierte Payload-Artefakt ist für diesen Protokolleintrag nicht mehr verfügbar.",
       "payloadCorrupt": "Detailliertes Payload-Artefakt konnte nicht analysiert werden.",
       "notAvailable": "Nicht verfügbar",
diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json
index 495ae6eaf8..4d367cb94e 100644
--- a/src/i18n/messages/el.json
+++ b/src/i18n/messages/el.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "Αναγνωριστικό συσχέτισης: {id}",
       "detailedPayloadInfo": "Ενεργοποιήστε πρώτα τη λεπτομερή καταγραφή εάν θέλετε την προβολή ωφέλιμου φορτίου τεσσάρων σταδίων πελάτη/παρόχου για νέα αιτήματα.",
       "copyAll": "Αντιγραφή όλων",
-      "copiedAll": "Αντιγράφηκαν όλα"
+      "copiedAll": "Αντιγράφηκαν όλα",
+      "payloadSizeLimitOmitted": "Το φορτίο παραλείφθηκε — αυτή η ενότητα υπέρβη το όριο μεγέθους του αρχείου κλήσεων (CALL_LOG_PIPELINE_MAX_SIZE_KB) και δεν αποθηκεύτηκε· δεν πρόκειται για πραγματικό σφάλμα του παρόχου."
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 18433ec10a..a5b7e344e1 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -11418,6 +11418,7 @@
         "clientResponse": "Client Response",
         "pipelineError": "Pipeline Error"
       },
+      "payloadSizeLimitOmitted": "Payload omitted — this section exceeded the call log size limit (CALL_LOG_PIPELINE_MAX_SIZE_KB) and was not stored, not a real upstream error.",
       "payloadMissing": "Detailed payload artifact is no longer available for this log entry.",
       "payloadCorrupt": "Detailed payload artifact could not be parsed.",
       "notAvailable": "N/A",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index c994343113..b42c6ee410 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Respuesta del Cliente",
         "pipelineError": "Error de Pipeline"
       },
+      "payloadSizeLimitOmitted": "Contenido omitido — esta sección superó el límite de tamaño del registro de llamadas (CALL_LOG_PIPELINE_MAX_SIZE_KB) y no se guardó; no es un error real del proveedor.",
       "payloadMissing": "El artefacto de carga detallada ya no está disponible para esta entrada de registro.",
       "payloadCorrupt": "No se pudo analizar el artefacto de carga detallada.",
       "notAvailable": "N/D",
diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json
index 2c707d5a7f..ffef4d6957 100644
--- a/src/i18n/messages/et.json
+++ b/src/i18n/messages/et.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Kliendi vastus",
         "pipelineError": "Töötluskonveieri viga"
       },
+      "payloadSizeLimitOmitted": "Sisu jäeti välja — see jaotis ületas kõnelogi suurusepiirangu (CALL_LOG_PIPELINE_MAX_SIZE_KB) ja seda ei salvestatud; tegemist ei ole tegeliku ülesvoolu veaga.",
       "payloadMissing": "Selle logikirje üksikasjaliku andmekoormuse artefakt pole enam saadaval.",
       "payloadCorrupt": "Üksikasjaliku andmekoormuse artefakti ei saanud sõeluda.",
       "notAvailable": "Pole kohaldatav",
diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json
index 05851eebf8..96661041d3 100644
--- a/src/i18n/messages/fa.json
+++ b/src/i18n/messages/fa.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "پاسخ مشتری",
         "pipelineError": "خطای پایپ‌لاین"
       },
+      "payloadSizeLimitOmitted": "محتوا حذف شد — این بخش از محدودیت اندازهٔ گزارش فراخوانی (CALL_LOG_PIPELINE_MAX_SIZE_KB) فراتر رفت و ذخیره نشد؛ این یک خطای واقعی از سمت ارائه‌دهنده نیست.",
       "payloadMissing": "آرتیفکت بارگذاری دقیق دیگر برای این ورودی لاگ در دسترس نیست.",
       "payloadCorrupt": "بارگذاری جزئیات بارگذاری نمی‌تواند تجزیه شود.",
       "notAvailable": "غیر قابل استفاده",
diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json
index 8481082f86..be5a2d51bb 100644
--- a/src/i18n/messages/fi.json
+++ b/src/i18n/messages/fi.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Asiakkaan Vastaus",
         "pipelineError": "Putkivirhe"
       },
+      "payloadSizeLimitOmitted": "Sisältö jätetty pois — tämä osio ylitti kutsulokin kokorajan (CALL_LOG_PIPELINE_MAX_SIZE_KB) eikä sitä tallennettu; kyseessä ei ole todellinen upstream-virhe.",
       "payloadMissing": "Yksityiskohtainen kuormitusartefakti ei ole enää saatavilla tälle lokimerkinnälle.",
       "payloadCorrupt": "Yksityiskohtaisia kuormitusartefakteja ei voitu jäsentää.",
       "notAvailable": "Ei saatavilla",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 2a6c4f708a..1cd48ab3a6 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Réponse du client",
         "pipelineError": "Erreur du pipeline"
       },
+      "payloadSizeLimitOmitted": "Contenu omis — cette section a dépassé la limite de taille du journal d'appels (CALL_LOG_PIPELINE_MAX_SIZE_KB) et n'a pas été enregistrée ; ce n'est pas une véritable erreur du fournisseur.",
       "payloadMissing": "L’artefact de charge utile détaillé n’est plus disponible pour cette entrée de journal.",
       "payloadCorrupt": "L’artefact de charge utile détaillé n’a pas pu être analysé.",
       "notAvailable": "N/D",
diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json
index 8cd9bea706..4b502b7a1b 100644
--- a/src/i18n/messages/ga.json
+++ b/src/i18n/messages/ga.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "Aitheantas Comhghaoil: {id}",
       "detailedPayloadInfo": "Cumasaigh logáil mhionsonraithe ar dtús más mian leat an t-amharc ceithre chéim ar ualaí cliaint/soláthraí le haghaidh iarratais nua.",
       "copyAll": "Cóipeáil go léir",
-      "copiedAll": "Cóipeáilte go léir"
+      "copiedAll": "Cóipeáilte go léir",
+      "payloadSizeLimitOmitted": "Fágadh an pálasta ar lár — sháraigh an rannán seo teorainn mhéid an logchomhaid glaonna (CALL_LOG_PIPELINE_MAX_SIZE_KB) agus níor stóráladh é; ní fíorearráid ón soláthraí é seo."
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json
index 9ff6a83a56..161bf7ce3b 100644
--- a/src/i18n/messages/gu.json
+++ b/src/i18n/messages/gu.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "ક્લાયન્ટ પ્રતિસાદ",
         "pipelineError": "પાઇપલાઇન ભૂલ"
       },
+      "payloadSizeLimitOmitted": "પેલોડ છોડી દેવાયો — આ વિભાગ કૉલ લૉગની કદ મર્યાદા (CALL_LOG_PIPELINE_MAX_SIZE_KB) ઓળંગી ગયો અને સંગ્રહાયો નથી; આ વાસ્તવિક અપસ્ટ્રીમ ભૂલ નથી.",
       "payloadMissing": "આ લોગ એન્ટ્રી માટે વિગતવાર પેઇલોડ આર્ટિફેક્ટ હવે ઉપલબ્ધ નથી.",
       "payloadCorrupt": "વિસ્તૃત પેલોડ આર્ટિફેક્ટને પાર્સ કરી શકાયું નથી.",
       "notAvailable": "લાગુ પડતું નથી",
diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json
index d61386288c..92324a9147 100644
--- a/src/i18n/messages/ha.json
+++ b/src/i18n/messages/ha.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "ID na Alaƙa: {id}",
       "detailedPayloadInfo": "Da farko, kunna yin cikakken log idan kana son ganin payload na matakai huɗu na abokin ciniki/mai bayarwa don sabbin buƙatu.",
       "copyAll": "Kwafi duka",
-      "copiedAll": "An kwafi duka"
+      "copiedAll": "An kwafi duka",
+      "payloadSizeLimitOmitted": "An bar abin da aka aika — wannan sashe ya wuce iyakar girman rajistar kira (CALL_LOG_PIPELINE_MAX_SIZE_KB) kuma ba a adana shi ba; wannan ba ainihin kuskuren mai bayarwa ba ne."
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json
index b52c5d1d7a..54fa106d02 100644
--- a/src/i18n/messages/he.json
+++ b/src/i18n/messages/he.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "תגובה מהלקוח",
         "pipelineError": "שגיאת צינור"
       },
+      "payloadSizeLimitOmitted": "התוכן הושמט — מקטע זה חרג ממגבלת הגודל של יומן הקריאות (CALL_LOG_PIPELINE_MAX_SIZE_KB) ולא נשמר; אין מדובר בשגיאה אמיתית מהספק.",
       "payloadMissing": "פרטי העמסה מפורטים אינם זמינים יותר עבור רשומת הלוג הזו.",
       "payloadCorrupt": "לא ניתן לנתח את הארטיפקט של העומס המפורט.",
       "notAvailable": "לא זמין",
diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json
index 1b109bf450..4751f721c9 100644
--- a/src/i18n/messages/hi.json
+++ b/src/i18n/messages/hi.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "क्लाइंट प्रतिक्रिया",
         "pipelineError": "पाइपलाइन त्रुटि"
       },
+      "payloadSizeLimitOmitted": "पेलोड छोड़ दिया गया — यह अनुभाग कॉल लॉग की आकार सीमा (CALL_LOG_PIPELINE_MAX_SIZE_KB) से अधिक हो गया और संग्रहीत नहीं किया गया; यह कोई वास्तविक अपस्ट्रीम त्रुटि नहीं है।",
       "payloadMissing": "इस लॉग प्रविष्टि के लिए विस्तृत पेलोड आर्टिफैक्ट अब उपलब्ध नहीं है।",
       "payloadCorrupt": "विस्तृत पेलोड आर्टिफैक्ट को पार्स नहीं किया जा सका।",
       "notAvailable": "लागू नहीं",
diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json
index 74fdd5c2d6..898a38a146 100644
--- a/src/i18n/messages/hr.json
+++ b/src/i18n/messages/hr.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Odgovor klijenta",
         "pipelineError": "Greška cjevovoda"
       },
+      "payloadSizeLimitOmitted": "Sadržaj izostavljen — ovaj odjeljak premašio je ograničenje veličine zapisnika poziva (CALL_LOG_PIPELINE_MAX_SIZE_KB) i nije pohranjen; nije riječ o stvarnoj pogrešci pružatelja.",
       "payloadMissing": "Detaljan artefakt sadržaja više nije dostupan za ovaj unos zapisa.",
       "payloadCorrupt": "Detaljan artefakt sadržaja nije bilo moguće raščlaniti.",
       "notAvailable": "Nije primjenjivo",
diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json
index 53d40f95e2..a5d4c4f116 100644
--- a/src/i18n/messages/hu.json
+++ b/src/i18n/messages/hu.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Ügyfél Válasz",
         "pipelineError": "Pipeline Hiba"
       },
+      "payloadSizeLimitOmitted": "Tartalom kihagyva — ez a szakasz túllépte a hívásnapló méretkorlátját (CALL_LOG_PIPELINE_MAX_SIZE_KB), ezért nem lett elmentve; ez nem valódi upstream hiba.",
       "payloadMissing": "A részletes payload artefaktum már nem elérhető ehhez a naplóbejegyzéshez.",
       "payloadCorrupt": "A részletes payload artefaktumot nem sikerült elemezni.",
       "notAvailable": "N/A",
diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json
index 8145760cc1..8b0fe9f2d4 100644
--- a/src/i18n/messages/hy.json
+++ b/src/i18n/messages/hy.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "Կապակցման ID՝ {id}",
       "detailedPayloadInfo": "Նոր հարցումների համար հաճախորդի/մատակարարի օգտակար բեռների քառափուլ տեսքը դիտելու նպատակով նախ միացրեք մանրամասն գրանցումը։",
       "copyAll": "Պատճենել ամբողջը",
-      "copiedAll": "Ամբողջը պատճենված է"
+      "copiedAll": "Ամբողջը պատճենված է",
+      "payloadSizeLimitOmitted": "Բովանդակությունը բաց է թողնվել — այս բաժինը գերազանցել է կանչերի մատյանի չափի սահմանը (CALL_LOG_PIPELINE_MAX_SIZE_KB) և չի պահպանվել. սա իրական վերին հոսքի սխալ չէ։"
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json
index e10e9c2f16..90d2a60902 100644
--- a/src/i18n/messages/id.json
+++ b/src/i18n/messages/id.json
@@ -11414,6 +11414,7 @@
         "clientResponse": "Tanggapan Klien",
         "pipelineError": "Kesalahan Pipeline"
       },
+      "payloadSizeLimitOmitted": "Payload dihilangkan — bagian ini melampaui batas ukuran log panggilan (CALL_LOG_PIPELINE_MAX_SIZE_KB) dan tidak disimpan; ini bukan kesalahan upstream yang sebenarnya.",
       "payloadMissing": "Artifact payload yang terperinci tidak lagi tersedia untuk entri log ini.",
       "payloadCorrupt": "Artifact payload yang rinci tidak dapat diparsing.",
       "notAvailable": "T/A",
diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json
index 1b389cf312..23b85cde89 100644
--- a/src/i18n/messages/ig.json
+++ b/src/i18n/messages/ig.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Nzaghachi Onye Ahịa",
         "pipelineError": "Njehie Pipeline"
       },
+      "payloadSizeLimitOmitted": "Ewepụrụ ihe ezigara — akụkụ a gafere oke nha ndekọ oku (CALL_LOG_PIPELINE_MAX_SIZE_KB), a chekwaghịkwa ya; ọ bụghị ezigbo njehie sitere n'aka onye na-enye ọrụ.",
       "payloadMissing": "Nkọwa payload zuru ezu adịkwaghị maka ndekọ a.",
       "payloadCorrupt": "Enweghị ike ịgụ ma nyochaa payload ahụ nwere nkọwa zuru ezu.",
       "notAvailable": "Ọ dịghị",
diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json
index d0a6f84f35..324ff86137 100644
--- a/src/i18n/messages/it.json
+++ b/src/i18n/messages/it.json
@@ -11415,6 +11415,7 @@
         "clientResponse": "Risposta del Cliente",
         "pipelineError": "Errore della Pipeline"
       },
+      "payloadSizeLimitOmitted": "Contenuto omesso — questa sezione ha superato il limite di dimensione del registro delle chiamate (CALL_LOG_PIPELINE_MAX_SIZE_KB) e non è stata salvata; non si tratta di un vero errore del provider.",
       "payloadMissing": "L'articolo del payload dettagliato non è più disponibile per questa voce di log.",
       "payloadCorrupt": "L'artifact del payload dettagliato non può essere analizzato.",
       "notAvailable": "N/D",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 5d55b1a124..d7fca9b640 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "クライアントの応答",
         "pipelineError": "パイプラインエラー"
       },
+      "payloadSizeLimitOmitted": "ペイロードは省略されました — このセクションは呼び出しログのサイズ上限(CALL_LOG_PIPELINE_MAX_SIZE_KB)を超えたため保存されませんでした。実際のアップストリームエラーではありません。",
       "payloadMissing": "このログエントリの詳細なペイロードアーティファクトはもはや利用できません。",
       "payloadCorrupt": "詳細なペイロードアーティファクトを解析できませんでした。",
       "notAvailable": "該当なし",
diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json
index 7972173aae..f7cfbcd030 100644
--- a/src/i18n/messages/ka.json
+++ b/src/i18n/messages/ka.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "კლიენტის პასუხი",
         "pipelineError": "კონვეიერის შეცდომა"
       },
+      "payloadSizeLimitOmitted": "შიგთავსი გამოტოვებულია — ამ განყოფილებამ გადააჭარბა გამოძახებების ჟურნალის ზომის ლიმიტს (CALL_LOG_PIPELINE_MAX_SIZE_KB) და არ შეინახა; ეს არ არის პროვაიდერის რეალური შეცდომა.",
       "payloadMissing": "ამ ჟურნალის ჩანაწერის დეტალური სასარგებლო მონაცემების არტეფაქტი აღარ არის ხელმისაწვდომი.",
       "payloadCorrupt": "დეტალური სასარგებლო მონაცემების არტეფაქტის გარჩევა ვერ მოხერხდა.",
       "notAvailable": "არ გამოიყენება",
diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json
index 42aced97d5..1496aaec82 100644
--- a/src/i18n/messages/km.json
+++ b/src/i18n/messages/km.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "លេខសម្គាល់ទំនាក់ទំនង៖ {id}",
       "detailedPayloadInfo": "សូមបើកការកត់ត្រាលម្អិតជាមុនសិន ប្រសិនបើអ្នកចង់មើល payload បួនដំណាក់កាលរបស់កម្មវិធីអតិថិជន/អ្នកផ្តល់សេវា សម្រាប់សំណើថ្មីៗ។",
       "copyAll": "ចម្លងទាំងអស់",
-      "copiedAll": "បានចម្លងទាំងអស់"
+      "copiedAll": "បានចម្លងទាំងអស់",
+      "payloadSizeLimitOmitted": "បានលុបខ្លឹមសារ — ផ្នែកនេះលើសដែនកំណត់ទំហំកំណត់ហេតុការហៅ (CALL_LOG_PIPELINE_MAX_SIZE_KB) ហើយមិនត្រូវបានរក្សាទុកទេ។ នេះមិនមែនជាកំហុសពិតពីអ្នកផ្តល់សេវាទេ។"
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json
index 2a076d431b..205492b0be 100644
--- a/src/i18n/messages/kn.json
+++ b/src/i18n/messages/kn.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "ಕ್ಲೈಂಟ್ ಪ್ರತಿಕ್ರಿಯೆ",
         "pipelineError": "ಪೈಪ್ಲೈನ್ ದೋಷ"
       },
+      "payloadSizeLimitOmitted": "ಪೇಲೋಡ್ ಬಿಟ್ಟುಬಿಡಲಾಗಿದೆ — ಈ ವಿಭಾಗವು ಕರೆ ಲಾಗ್ ಗಾತ್ರ ಮಿತಿಯನ್ನು (CALL_LOG_PIPELINE_MAX_SIZE_KB) ಮೀರಿದೆ ಮತ್ತು ಸಂಗ್ರಹಿಸಲಾಗಿಲ್ಲ; ಇದು ನಿಜವಾದ ಅಪ್‌ಸ್ಟ್ರೀಮ್ ದೋಷವಲ್ಲ.",
       "payloadMissing": "ಈ ಲಾಗ್ ನಮೂದಿಗೆ ವಿವರವಾದ ಪೇಲೋಡ್ ಆರ್ಟಿಫ್ಯಾಕ್ಟ್ ಇನ್ನು ಮುಂದೆ ಲಭ್ಯವಿಲ್ಲ.",
       "payloadCorrupt": "ವಿವರವಾದ ಪೇಲೋಡ್ ಆರ್ಟಿಫ್ಯಾಕ್ಟ್ ಅನ್ನು ಪಾರ್ಸ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ.",
       "notAvailable": "ಅನ್ವಯಿಸುವುದಿಲ್ಲ",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 89b351f5be..7bab3beb2f 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "클라이언트 응답",
         "pipelineError": "파이프라인 오류"
       },
+      "payloadSizeLimitOmitted": "페이로드가 생략되었습니다 — 이 섹션은 호출 로그 크기 제한(CALL_LOG_PIPELINE_MAX_SIZE_KB)을 초과하여 저장되지 않았습니다. 실제 업스트림 오류가 아닙니다.",
       "payloadMissing": "이 로그 항목에 대한 상세 페이로드 아티팩트가 더 이상 사용 가능하지 않습니다.",
       "payloadCorrupt": "상세 페이로드 아티팩트를 구문 분석할 수 없습니다.",
       "notAvailable": "해당 없음",
diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json
index 8ea3176e6d..11352f7bb1 100644
--- a/src/i18n/messages/lt.json
+++ b/src/i18n/messages/lt.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Kliento atsakymas",
         "pipelineError": "Konvejerio klaida"
       },
+      "payloadSizeLimitOmitted": "Turinys praleistas — ši dalis viršijo iškvietimų žurnalo dydžio limitą (CALL_LOG_PIPELINE_MAX_SIZE_KB) ir nebuvo išsaugota; tai nėra tikra tiekėjo klaida.",
       "payloadMissing": "Išsamus šio žurnalo įrašo duomenų artefaktas nebepasiekiamas.",
       "payloadCorrupt": "Nepavyko išanalizuoti išsamaus duomenų artefakto.",
       "notAvailable": "Nėra duomenų",
diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json
index c14afdf389..38312cc206 100644
--- a/src/i18n/messages/lv.json
+++ b/src/i18n/messages/lv.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "Korelācijas ID: {id}",
       "detailedPayloadInfo": "Vispirms iespējojiet detalizētu žurnalēšanu, ja vēlaties četru posmu klienta/nodrošinātāja derīgās slodzes skatu jauniem pieprasījumiem.",
       "copyAll": "Kopēt visu",
-      "copiedAll": "Viss nokopēts"
+      "copiedAll": "Viss nokopēts",
+      "payloadSizeLimitOmitted": "Saturs izlaists — šī sadaļa pārsniedza izsaukumu žurnāla izmēra ierobežojumu (CALL_LOG_PIPELINE_MAX_SIZE_KB) un netika saglabāta; tā nav īsta pakalpojumu sniedzēja kļūda."
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json
index 71062c5c59..77dc791e9b 100644
--- a/src/i18n/messages/ml.json
+++ b/src/i18n/messages/ml.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "ക്ലയന്റ് പ്രതികരണം",
         "pipelineError": "പൈപ്പ്ലൈൻ പിശക്"
       },
+      "payloadSizeLimitOmitted": "പേലോഡ് ഒഴിവാക്കി — ഈ വിഭാഗം കോൾ ലോഗ് വലുപ്പ പരിധി (CALL_LOG_PIPELINE_MAX_SIZE_KB) കവിഞ്ഞതിനാൽ സംഭരിച്ചിട്ടില്ല; ഇത് യഥാർത്ഥ അപ്‌സ്ട്രീം പിശകല്ല.",
       "payloadMissing": "ഈ ലോഗ് എൻട്രിക്കായുള്ള വിശദമായ പേലോഡ് ആർട്ടിഫാക്റ്റ് ഇനി ലഭ്യമല്ല.",
       "payloadCorrupt": "വിശദമായ പേലോഡ് ആർട്ടിഫാക്റ്റ് പാഴ്സ് ചെയ്യാനായില്ല.",
       "notAvailable": "ലഭ്യമല്ല",
diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json
index ef78ec0db1..9006718762 100644
--- a/src/i18n/messages/mr.json
+++ b/src/i18n/messages/mr.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "ग्राहक प्रतिसाद",
         "pipelineError": "पाइपलाइन त्रुटी"
       },
+      "payloadSizeLimitOmitted": "पेलोड वगळला — हा विभाग कॉल लॉगच्या आकार मर्यादेपेक्षा (CALL_LOG_PIPELINE_MAX_SIZE_KB) मोठा झाला आणि संग्रहित केला गेला नाही; ही खरी अपस्ट्रीम त्रुटी नाही.",
       "payloadMissing": "या लॉग नोंदीसाठी तपशीलवार पेलोड आर्टिफॅक्ट आता उपलब्ध नाही.",
       "payloadCorrupt": "तपशीलवार पेलोड आर्टिफॅक्ट पार्स केला जाऊ शकला नाही.",
       "notAvailable": "लागू नाही",
diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json
index e5dab503f5..4a7ca0a34b 100644
--- a/src/i18n/messages/ms.json
+++ b/src/i18n/messages/ms.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Tanggapan Klien",
         "pipelineError": "Ralat Saluran"
       },
+      "payloadSizeLimitOmitted": "Muatan diabaikan — bahagian ini melebihi had saiz log panggilan (CALL_LOG_PIPELINE_MAX_SIZE_KB) dan tidak disimpan; ini bukan ralat huluan yang sebenar.",
       "payloadMissing": "Artifak payload terperinci tidak lagi tersedia untuk entri log ini.",
       "payloadCorrupt": "Artifak payload terperinci tidak dapat dianalisis.",
       "notAvailable": "T/B",
diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json
index ba922fe89b..281c74597d 100644
--- a/src/i18n/messages/mt.json
+++ b/src/i18n/messages/mt.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Risposta lill-klijent",
         "pipelineError": "Żball fil-pipeline"
       },
+      "payloadSizeLimitOmitted": "Il-kontenut tħalla barra — din it-taqsima qabżet il-limitu tad-daqs tar-reġistru tas-sejħiet (CALL_LOG_PIPELINE_MAX_SIZE_KB) u ma nħażnitx; dan mhuwiex żball reali tal-fornitur.",
       "payloadMissing": "L-artifatt dettaljat tal-payload m’għadux disponibbli għal din l-entrata tal-log.",
       "payloadCorrupt": "L-artifatt dettaljat tal-payload ma setax jiġi analizzat.",
       "notAvailable": "Mhux applikabbli",
diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json
index 47d87291bd..fbb996d509 100644
--- a/src/i18n/messages/my.json
+++ b/src/i18n/messages/my.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Client တုံ့ပြန်ချက်",
         "pipelineError": "Pipeline အမှား"
       },
+      "payloadSizeLimitOmitted": "ပေးပို့ဒေတာကို ချန်လှပ်ထားသည် — ဤအပိုင်းသည် ခေါ်ဆိုမှုမှတ်တမ်း အရွယ်အစားကန့်သတ်ချက် (CALL_LOG_PIPELINE_MAX_SIZE_KB) ကို ကျော်လွန်သဖြင့် သိမ်းဆည်းမထားပါ။ ဤသည်မှာ အထက်စီးကြောင်းမှ အမှန်တကယ်အမှားမဟုတ်ပါ။",
       "payloadMissing": "ဤမှတ်တမ်းအတွက် အသေးစိတ် payload artifact ကို မရနိုင်တော့ပါ။",
       "payloadCorrupt": "အသေးစိတ် payload artifact ကို ခွဲခြမ်းဖတ်ရှု၍ မရပါ။",
       "notAvailable": "မသက်ဆိုင်ပါ",
diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json
index 89f527cbf7..4c2f27c1c7 100644
--- a/src/i18n/messages/ne.json
+++ b/src/i18n/messages/ne.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "क्लाइन्ट प्रतिक्रिया",
         "pipelineError": "पाइपलाइन त्रुटि"
       },
+      "payloadSizeLimitOmitted": "पेलोड छोडियो — यो खण्डले कल लगको आकार सीमा (CALL_LOG_PIPELINE_MAX_SIZE_KB) नाघ्यो र भण्डारण गरिएन; यो वास्तविक अपस्ट्रिम त्रुटि होइन।",
       "payloadMissing": "यो लग प्रविष्टिका लागि विस्तृत पेलोड आर्टिफ्याक्ट अब उपलब्ध छैन।",
       "payloadCorrupt": "विस्तृत पेलोड आर्टिफ्याक्ट पार्स गर्न सकिएन।",
       "notAvailable": "लागू हुँदैन",
diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json
index d99866011d..a174af5554 100644
--- a/src/i18n/messages/nl.json
+++ b/src/i18n/messages/nl.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Klantreactie",
         "pipelineError": "Pijplijnfout"
       },
+      "payloadSizeLimitOmitted": "Payload weggelaten — deze sectie overschreed de groottelimiet van het aanroeplogboek (CALL_LOG_PIPELINE_MAX_SIZE_KB) en is niet opgeslagen; dit is geen echte upstream-fout.",
       "payloadMissing": "Gedetailleerde payload-artifact is niet langer beschikbaar voor deze logvermelding.",
       "payloadCorrupt": "Gedetailleerde payload-artifact kon niet worden geparsed.",
       "notAvailable": "N.v.t.",
diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json
index b07e9b3cc8..1fba7fca2d 100644
--- a/src/i18n/messages/no.json
+++ b/src/i18n/messages/no.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Klientrespons",
         "pipelineError": "Pipeline-feil"
       },
+      "payloadSizeLimitOmitted": "Innhold utelatt — denne seksjonen overskred størrelsesgrensen for kalloggen (CALL_LOG_PIPELINE_MAX_SIZE_KB) og ble ikke lagret; dette er ikke en reell upstream-feil.",
       "payloadMissing": "Detaljert nyttelastartefakt er ikke lenger tilgjengelig for denne loggoppføringen.",
       "payloadCorrupt": "Detaljert nyttelastartefakt kunne ikke bli analysert.",
       "notAvailable": "Ikke tilgjengelig",
diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json
index 47d74d8b03..8338f6db16 100644
--- a/src/i18n/messages/or.json
+++ b/src/i18n/messages/or.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "କ୍ଲାଏଣ୍ଟ ପ୍ରତିକ୍ରିୟା",
         "pipelineError": "ପାଇପ୍ଲାଇନ୍ ତ୍ରୁଟି"
       },
+      "payloadSizeLimitOmitted": "ପେଲୋଡ୍ ଛାଡ଼ି ଦିଆଯାଇଛି — ଏହି ବିଭାଗ କଲ୍ ଲଗ୍ ଆକାର ସୀମା (CALL_LOG_PIPELINE_MAX_SIZE_KB) ଅତିକ୍ରମ କରିଛି ଏବଂ ସଂରକ୍ଷିତ ହୋଇନାହିଁ; ଏହା ପ୍ରକୃତ ଅପଷ୍ଟ୍ରିମ୍ ତ୍ରୁଟି ନୁହେଁ।",
       "payloadMissing": "ଏହି ଲଗ୍ ଏଣ୍ଟ୍ରି ପାଇଁ ବିସ୍ତୃତ ପେଲୋଡ୍ ଆର୍ଟିଫ୍ୟାକ୍ଟ ଆଉ ଉପଲବ୍ଧ ନାହିଁ।",
       "payloadCorrupt": "ବିସ୍ତୃତ ପେଲୋଡ୍ ଆର୍ଟିଫ୍ୟାକ୍ଟକୁ ପାର୍ସ କରାଯାଇପାରିଲା ନାହିଁ।",
       "notAvailable": "ପ୍ରଯୁଜ୍ୟ ନୁହେଁ",
diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json
index 3bc1974ba4..1b4e809373 100644
--- a/src/i18n/messages/pa.json
+++ b/src/i18n/messages/pa.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "ਕਲਾਇੰਟ ਜਵਾਬ",
         "pipelineError": "ਪਾਈਪਲਾਈਨ ਗਲਤੀ"
       },
+      "payloadSizeLimitOmitted": "ਪੇਲੋਡ ਛੱਡ ਦਿੱਤਾ ਗਿਆ — ਇਹ ਭਾਗ ਕਾਲ ਲੌਗ ਦੀ ਆਕਾਰ ਸੀਮਾ (CALL_LOG_PIPELINE_MAX_SIZE_KB) ਤੋਂ ਵੱਧ ਗਿਆ ਅਤੇ ਸਟੋਰ ਨਹੀਂ ਕੀਤਾ ਗਿਆ; ਇਹ ਅਸਲ ਅੱਪਸਟ੍ਰੀਮ ਗਲਤੀ ਨਹੀਂ ਹੈ।",
       "payloadMissing": "ਇਸ ਲੌਗ ਐਂਟਰੀ ਲਈ ਵਿਸਤ੍ਰਿਤ ਪੇਲੋਡ ਆਰਟੀਫੈਕਟ ਹੁਣ ਉਪਲਬਧ ਨਹੀਂ ਹੈ।",
       "payloadCorrupt": "ਵਿਸਤ੍ਰਿਤ ਪੇਲੋਡ ਆਰਟੀਫੈਕਟ ਨੂੰ ਪਾਰਸ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ।",
       "notAvailable": "ਲਾਗੂ ਨਹੀਂ",
diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json
index e027549c9f..906dd77768 100644
--- a/src/i18n/messages/phi.json
+++ b/src/i18n/messages/phi.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Tugon ng Kliyente",
         "pipelineError": "Error sa Pipeline"
       },
+      "payloadSizeLimitOmitted": "Inalis ang payload — lumampas ang seksyong ito sa limitasyon ng laki ng call log (CALL_LOG_PIPELINE_MAX_SIZE_KB) at hindi na-save; hindi ito tunay na upstream error.",
       "payloadMissing": "Ang detalyadong payload artifact ay hindi na available para sa log entry na ito.",
       "payloadCorrupt": "Hindi ma-parse ang detalyadong payload artifact.",
       "notAvailable": "Hindi naaangkop",
diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json
index 74cd55d13c..4d6bf333c2 100644
--- a/src/i18n/messages/pl.json
+++ b/src/i18n/messages/pl.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Odpowiedź Klienta",
         "pipelineError": "Błąd potoku"
       },
+      "payloadSizeLimitOmitted": "Zawartość pominięta — ta sekcja przekroczyła limit rozmiaru dziennika wywołań (CALL_LOG_PIPELINE_MAX_SIZE_KB) i nie została zapisana; to nie jest rzeczywisty błąd dostawcy.",
       "payloadMissing": "Szczegółowy ładunek artefaktu nie jest już dostępny dla tego wpisu dziennika.",
       "payloadCorrupt": "Szczegółowy ładunek artefaktu nie mógł zostać sparsowany.",
       "notAvailable": "N/D",
diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json
index 2d47bd31e1..3114c9ca26 100644
--- a/src/i18n/messages/pt-BR.json
+++ b/src/i18n/messages/pt-BR.json
@@ -11415,6 +11415,7 @@
         "clientResponse": "Resposta do Cliente",
         "pipelineError": "Erro de Pipeline"
       },
+      "payloadSizeLimitOmitted": "Payload omitido — esta seção excedeu o limite de tamanho do log de chamadas (CALL_LOG_PIPELINE_MAX_SIZE_KB) e não foi armazenada; não é um erro real do upstream.",
       "payloadMissing": "O artefato de carga detalhada não está mais disponível para esta entrada de log.",
       "payloadCorrupt": "O artefato de carga detalhada não pôde ser analisado.",
       "notAvailable": "N/D",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index d04fdc9f39..6093ca0ef3 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Resposta do Cliente",
         "pipelineError": "Erro de Pipeline"
       },
+      "payloadSizeLimitOmitted": "Conteúdo omitido — esta secção excedeu o limite de tamanho do registo de chamadas (CALL_LOG_PIPELINE_MAX_SIZE_KB) e não foi guardada; não se trata de um erro real do fornecedor.",
       "payloadMissing": "O artefato de carga detalhada já não está disponível para esta entrada de log.",
       "payloadCorrupt": "O artefato do payload detalhado não pôde ser analisado.",
       "notAvailable": "N/D",
diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json
index f3e6c00b10..ebf5f746ff 100644
--- a/src/i18n/messages/ro.json
+++ b/src/i18n/messages/ro.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Răspunsul Clientului",
         "pipelineError": "Eroare de Pipeline"
       },
+      "payloadSizeLimitOmitted": "Conținut omis — această secțiune a depășit limita de dimensiune a jurnalului de apeluri (CALL_LOG_PIPELINE_MAX_SIZE_KB) și nu a fost stocată; nu este o eroare reală a furnizorului.",
       "payloadMissing": "Artifactul detaliat al payload-ului nu mai este disponibil pentru această intrare de jurnal.",
       "payloadCorrupt": "Artifactul detaliat al payload-ului nu a putut fi analizat.",
       "notAvailable": "Indisponibil",
diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json
index d0409b2e5d..1f1b187e79 100644
--- a/src/i18n/messages/ru.json
+++ b/src/i18n/messages/ru.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Ответ клиента",
         "pipelineError": "Ошибка конвейера"
       },
+      "payloadSizeLimitOmitted": "Содержимое пропущено — этот раздел превысил ограничение размера журнала вызовов (CALL_LOG_PIPELINE_MAX_SIZE_KB) и не был сохранён; это не реальная ошибка провайдера.",
       "payloadMissing": "Подробный артефакт полезной нагрузки больше недоступен для этой записи журнала.",
       "payloadCorrupt": "Не удалось разобрать детализированный артефакт полезной нагрузки.",
       "notAvailable": "Н/Д",
diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json
index 5f3d885b47..617ee6f718 100644
--- a/src/i18n/messages/si.json
+++ b/src/i18n/messages/si.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "සහසම්බන්ධතා හැඳුනුම්කාරකය: {id}",
       "detailedPayloadInfo": "නව ඉල්ලීම් සඳහා අදියර හතරක සේවාලාභී/සපයන්නා දත්ත කොටස් දසුන අවශ්ය නම්, පළමුව සවිස්තරාත්මක ලොග් කිරීම සබල කරන්න.",
       "copyAll": "සියල්ල පිටපත් කරන්න",
-      "copiedAll": "සියල්ල පිටපත් කරන ලදී"
+      "copiedAll": "සියල්ල පිටපත් කරන ලදී",
+      "payloadSizeLimitOmitted": "අන්තර්ගතය මඟ හැරිණි — මෙම කොටස ඇමතුම් ලොග් ප්‍රමාණ සීමාව (CALL_LOG_PIPELINE_MAX_SIZE_KB) ඉක්මවා ගිය අතර ගබඩා නොකෙරිණි; මෙය සැබෑ සැපයුම්කරු දෝෂයක් නොවේ."
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json
index ebb5da0775..f4280c7127 100644
--- a/src/i18n/messages/sk.json
+++ b/src/i18n/messages/sk.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Odpoveď Klienta",
         "pipelineError": "Chyba v pipeline"
       },
+      "payloadSizeLimitOmitted": "Obsah vynechaný — táto sekcia prekročila limit veľkosti záznamu volaní (CALL_LOG_PIPELINE_MAX_SIZE_KB) a nebola uložená; nejde o skutočnú chybu poskytovateľa.",
       "payloadMissing": "Podrobný payload artefakt už nie je k dispozícii pre tento záznam protokolu.",
       "payloadCorrupt": "Podrobný payload artefakt sa nedal analyzovať.",
       "notAvailable": "N/A",
diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json
index b0c00b74b6..a666be7b0f 100644
--- a/src/i18n/messages/sl.json
+++ b/src/i18n/messages/sl.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Odgovor odjemalcu",
         "pipelineError": "Napaka obdelovalnega cevovoda"
       },
+      "payloadSizeLimitOmitted": "Vsebina izpuščena — ta razdelek je presegel omejitev velikosti dnevnika klicev (CALL_LOG_PIPELINE_MAX_SIZE_KB) in ni bil shranjen; ne gre za dejansko napako ponudnika.",
       "payloadMissing": "Artefakt s podrobno vsebino za ta vnos v dnevniku ni več na voljo.",
       "payloadCorrupt": "Artefakta s podrobno vsebino ni bilo mogoče razčleniti.",
       "notAvailable": "Ni na voljo",
diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json
index c67f0eb22c..1bc27ecf97 100644
--- a/src/i18n/messages/sr.json
+++ b/src/i18n/messages/sr.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Одговор клијенту",
         "pipelineError": "Грешка тока обраде"
       },
+      "payloadSizeLimitOmitted": "Садржај изостављен — овај одељак је премашио ограничење величине дневника позива (CALL_LOG_PIPELINE_MAX_SIZE_KB) и није сачуван; није реч о стварној грешци провајдера.",
       "payloadMissing": "Детаљни артефакт корисног садржаја више није доступан за овај унос дневника.",
       "payloadCorrupt": "Детаљни артефакт корисног садржаја није могао бити рашчлањен.",
       "notAvailable": "Н/П",
diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json
index 0fec4a0513..a0aa94fc7c 100644
--- a/src/i18n/messages/sv.json
+++ b/src/i18n/messages/sv.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Klientens Svar",
         "pipelineError": "Pipeline-fel"
       },
+      "payloadSizeLimitOmitted": "Innehåll utelämnat — det här avsnittet överskred storleksgränsen för anropsloggen (CALL_LOG_PIPELINE_MAX_SIZE_KB) och sparades inte; det är inte ett verkligt uppströmsfel.",
       "payloadMissing": "Den detaljerade nyttolasten artefakt är inte längre tillgänglig för denna loggpost.",
       "payloadCorrupt": "Detaljerad payload-artikel kunde inte tolkas.",
       "notAvailable": "Ej tillämpligt",
diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json
index 61f43b7764..9b70afd273 100644
--- a/src/i18n/messages/sw.json
+++ b/src/i18n/messages/sw.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Majibu ya Mteja",
         "pipelineError": "Kosa la Pipeline"
       },
+      "payloadSizeLimitOmitted": "Maudhui yameachwa — sehemu hii ilizidi kikomo cha ukubwa wa kumbukumbu ya miito (CALL_LOG_PIPELINE_MAX_SIZE_KB) na haikuhifadhiwa; hili si kosa halisi la mtoa huduma.",
       "payloadMissing": "Kipande cha maelezo ya mzigo hakipatikani tena kwa ajili ya kipande hiki cha kumbukumbu.",
       "payloadCorrupt": "Kipande cha mzigo kilichofafanuliwa hakiwezi kufasiriwa.",
       "notAvailable": "Haitumiki",
diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json
index 20a4322188..41d8a923c3 100644
--- a/src/i18n/messages/ta.json
+++ b/src/i18n/messages/ta.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "கிளையனின் பதில்",
         "pipelineError": "பைப்லைன் பிழை"
       },
+      "payloadSizeLimitOmitted": "பேலோடு தவிர்க்கப்பட்டது — இந்தப் பகுதி அழைப்புப் பதிவின் அளவு வரம்பை (CALL_LOG_PIPELINE_MAX_SIZE_KB) மீறியதால் சேமிக்கப்படவில்லை; இது உண்மையான அப்ஸ்ட்ரீம் பிழை அல்ல.",
       "payloadMissing": "இந்த பதிவு நுழைவுக்கு விரிவான payload கலைப்பொருள் இனி கிடைக்கவில்லை.",
       "payloadCorrupt": "விவரமான payload கலைப்பொருள் பகுப்பாய்வு செய்ய முடியவில்லை.",
       "notAvailable": "பொருந்தாது",
diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json
index 8b7a85f744..89fe9201fc 100644
--- a/src/i18n/messages/te.json
+++ b/src/i18n/messages/te.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "క్లయింట్ ప్రతిస్పందన",
         "pipelineError": "పైప్‌లైన్ లో పొరపాటు"
       },
+      "payloadSizeLimitOmitted": "పేలోడ్ వదిలివేయబడింది — ఈ విభాగం కాల్ లాగ్ పరిమాణ పరిమితిని (CALL_LOG_PIPELINE_MAX_SIZE_KB) మించిపోయినందున నిల్వ చేయబడలేదు; ఇది నిజమైన అప్‌స్ట్రీమ్ లోపం కాదు.",
       "payloadMissing": "ఈ లాగ్ ఎంట్రీకి సంబంధించి వివరమైన పేమెంట్ ఆర్టిఫాక్ట్ అందుబాటులో లేదు.",
       "payloadCorrupt": "వివరమైన పేమెంట్ ఆర్టిఫాక్ట్‌ను పార్స్ చేయలేకపోయింది.",
       "notAvailable": "వర్తించదు",
diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json
index 042d87ad3c..bafe833e75 100644
--- a/src/i18n/messages/th.json
+++ b/src/i18n/messages/th.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "การตอบสนองของลูกค้า",
         "pipelineError": "ข้อผิดพลาดของ Pipeline"
       },
+      "payloadSizeLimitOmitted": "ละเว้นเพย์โหลด — ส่วนนี้เกินขีดจำกัดขนาดของบันทึกการเรียก (CALL_LOG_PIPELINE_MAX_SIZE_KB) จึงไม่ถูกจัดเก็บ ไม่ใช่ข้อผิดพลาดจริงจากผู้ให้บริการต้นทาง",
       "payloadMissing": "ข้อมูลรายละเอียดของ payload artifact ไม่สามารถใช้งานได้อีกต่อไปสำหรับรายการบันทึกนี้.",
       "payloadCorrupt": "ไม่สามารถแยกวิเคราะห์ข้อมูลพารามิเตอร์ที่ละเอียดได้。",
       "notAvailable": "ไม่สามารถใช้ได้",
diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json
index 9cf33051d4..8007f6c2c0 100644
--- a/src/i18n/messages/tr.json
+++ b/src/i18n/messages/tr.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Müşteri Yanıtı",
         "pipelineError": "Pipeline Hatası"
       },
+      "payloadSizeLimitOmitted": "İçerik atlandı — bu bölüm çağrı günlüğü boyut sınırını (CALL_LOG_PIPELINE_MAX_SIZE_KB) aştı ve kaydedilmedi; bu gerçek bir sağlayıcı hatası değildir.",
       "payloadMissing": "Bu günlük girişi için ayrıntılı yük nesnesi artık mevcut değil.",
       "payloadCorrupt": "Ayrıntılı yük nesnesi ayrıştırılamadı.",
       "notAvailable": "Yok",
diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json
index 905f24cf88..f73c30a786 100644
--- a/src/i18n/messages/uk-UA.json
+++ b/src/i18n/messages/uk-UA.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Відповідь клієнта",
         "pipelineError": "Помилка конвеєра"
       },
+      "payloadSizeLimitOmitted": "Вміст пропущено — цей розділ перевищив обмеження розміру журналу викликів (CALL_LOG_PIPELINE_MAX_SIZE_KB) і не був збережений; це не справжня помилка провайдера.",
       "payloadMissing": "Детальний артефакт корисного навантаження більше недоступний для цього запису журналу.",
       "payloadCorrupt": "Не вдалося розібрати детальний артефакт корисного навантаження.",
       "notAvailable": "Н/Д",
diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json
index 19a0010436..39bc2f467c 100644
--- a/src/i18n/messages/ur.json
+++ b/src/i18n/messages/ur.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "کلائنٹ کا جواب",
         "pipelineError": "پائپ لائن کی خرابی"
       },
+      "payloadSizeLimitOmitted": "پے لوڈ چھوڑ دیا گیا — یہ حصہ کال لاگ کی سائز حد (CALL_LOG_PIPELINE_MAX_SIZE_KB) سے تجاوز کر گیا اور محفوظ نہیں کیا گیا؛ یہ حقیقی اپ اسٹریم خرابی نہیں ہے۔",
       "payloadMissing": "اس لاگ اندراج کے لیے تفصیلی پیلوڈ آرٹيفیکٹ اب دستیاب نہیں ہے۔",
       "payloadCorrupt": "تفصیلی پیلوڈ آرٹفیکٹ کو پارس نہیں کیا جا سکا۔",
       "notAvailable": "دستیاب نہیں",
diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json
index ecd45c966f..bf3c7a9dea 100644
--- a/src/i18n/messages/uz.json
+++ b/src/i18n/messages/uz.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Mijozga javob",
         "pipelineError": "Konveyer xatosi"
       },
+      "payloadSizeLimitOmitted": "Kontent tashlab ketildi — bu boʻlim chaqiruvlar jurnalining hajm chegarasidan (CALL_LOG_PIPELINE_MAX_SIZE_KB) oshib ketdi va saqlanmadi; bu haqiqiy provayder xatosi emas.",
       "payloadMissing": "Bu jurnal yozuvi uchun batafsil foydali yuk artefakti endi mavjud emas.",
       "payloadCorrupt": "Batafsil foydali yuk artefaktini tahlil qilib boʻlmadi.",
       "notAvailable": "Mavjud emas",
diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json
index d56aac4081..526c9a28ed 100644
--- a/src/i18n/messages/vi.json
+++ b/src/i18n/messages/vi.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "Phản hồi client",
         "pipelineError": "Lỗi Pipeline"
       },
+      "payloadSizeLimitOmitted": "Đã bỏ qua payload — phần này vượt quá giới hạn kích thước nhật ký cuộc gọi (CALL_LOG_PIPELINE_MAX_SIZE_KB) nên không được lưu; đây không phải lỗi thực sự từ upstream.",
       "payloadMissing": "Không còn artifact payload chi tiết cho mục nhật ký này.",
       "payloadCorrupt": "Không thể phân tích artifact payload chi tiết.",
       "notAvailable": "Không áp dụng",
diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json
index 9544c193d6..d17dff5864 100644
--- a/src/i18n/messages/yo.json
+++ b/src/i18n/messages/yo.json
@@ -11464,7 +11464,8 @@
       "correlationIdValue": "ID Ìbámu: {id}",
       "detailedPayloadInfo": "Kọ́kọ́ mú ìforúkọsílẹ̀ alálàyé ṣiṣẹ́ bí o bá fẹ́ àfihàn àkóónú client/provider onípele mẹ́rin fún àwọn ìbéèrè tuntun.",
       "copyAll": "Ṣe àdàkọ gbogbo rẹ̀",
-      "copiedAll": "A ti ṣe àdàkọ gbogbo rẹ̀"
+      "copiedAll": "A ti ṣe àdàkọ gbogbo rẹ̀",
+      "payloadSizeLimitOmitted": "A fo ẹrù-ìsọfúnni sílẹ̀ — apá yìí kọjá ààlà ìwọ̀n àkọsílẹ̀ ìpè (CALL_LOG_PIPELINE_MAX_SIZE_KB), a kò sì tọ́jú rẹ̀; kì í ṣe àṣìṣe gidi láti ọ̀dọ̀ olùpèsè."
     }
   },
   "proxyLogger": {
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 163ba54af2..04a182d854 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "客户端响应",
         "pipelineError": "管道错误"
       },
+      "payloadSizeLimitOmitted": "已省略负载 — 此部分超出了调用日志大小限制(CALL_LOG_PIPELINE_MAX_SIZE_KB),未被存储;这不是真正的上游错误。",
       "payloadMissing": "此日志条目的详细有效负载工件不再可用。",
       "payloadCorrupt": "无法解析详细的有效负载工件。",
       "notAvailable": "不适用",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 110c9d2cbe..589394fe29 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -11411,6 +11411,7 @@
         "clientResponse": "客戶回應",
         "pipelineError": "管道錯誤"
       },
+      "payloadSizeLimitOmitted": "已省略承載內容 — 此區段超出了呼叫日誌大小上限(CALL_LOG_PIPELINE_MAX_SIZE_KB),因此未被儲存;這並非真正的上游錯誤。",
       "payloadMissing": "此日誌條目的詳細有效負載工件不再可用。",
       "payloadCorrupt": "無法解析詳細的有效負載工件。",
       "notAvailable": "不適用",
diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts
index 1fe14b98e7..c1a5d263ad 100644
--- a/src/lib/usage/callLogArtifacts.ts
+++ b/src/lib/usage/callLogArtifacts.ts
@@ -3,6 +3,12 @@ import path from "node:path";
 import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
 import { resolveDataDir } from "../dataPaths";
 import { getCallLogPipelineMaxSizeBytes, isChatDebugFileEnabled } from "../logEnv";
+import {
+  CALL_LOG_SIZE_LIMIT_REASON as SIZE_LIMIT_EXCEEDED_REASON,
+  CALL_LOG_BODY_OMITTED_FOR_SIZE_LIMIT as OMITTED_FOR_SIZE_LIMIT,
+  CALL_LOG_STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT as STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT,
+  isSizeLimitOmissionMarker,
+} from "@/shared/constants/callLogSizeLimitMarkers";
 
 const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
 const isBuildPhase =
@@ -12,21 +18,11 @@ const DATA_DIR = resolveDataDir({ isCloud });
 export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs");
 export const MAX_CALL_LOG_ARTIFACT_BYTES = 512 * 1024;
 
-const SIZE_LIMIT_EXCEEDED_REASON = "call_log_artifact_size_limit_exceeded";
-const OMITTED_FOR_SIZE_LIMIT = "[omitted: call log artifact size limit exceeded]";
-const STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT =
-  "[stream chunks omitted: call log artifact size limit exceeded]";
-
-/**
- * True for a placeholder a size-limit fallback wrote in place of a real
- * payload. Consumers that fall back from one artifact field to another
- * (`maybeEnrichCompletedDetail`) must treat a marker as absent: it is a
- * non-empty string, so a bare truthiness check happily "recovers" it and
- * overwrites the real value it was meant to stand in for.
- */
-export function isSizeLimitOmissionMarker(value: unknown): boolean {
-  return value === OMITTED_FOR_SIZE_LIMIT || value === STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT;
-}
+// Re-exported for backward compatibility: consumers (completedRequestDetails.ts)
+// import this marker check from here. Definition now lives in the shared
+// constants module so the client-side detail view can use the exact same check
+// without importing this fs/path-dependent, server-only module (see #13894).
+export { isSizeLimitOmissionMarker };
 
 // The error is the only field that says *why* a request failed, and it is
 // typically ~90 bytes next to the multi-hundred-KB bodies that trip the cap.
@@ -54,7 +50,7 @@ function preserveErrorForSizeLimit(error: unknown): unknown {
   if (error === null || error === undefined) return null;
   let serialized: string;
   try {
-    serialized = typeof error === "string" ? error : JSON.stringify(error) ?? String(error);
+    serialized = typeof error === "string" ? error : (JSON.stringify(error) ?? String(error));
   } catch {
     // A circular or unserializable error must not take the whole artifact down.
     serialized = String(error);
diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx
index cbdce62a01..d5d5521a38 100644
--- a/src/shared/components/RequestLoggerDetail.sections.tsx
+++ b/src/shared/components/RequestLoggerDetail.sections.tsx
@@ -13,6 +13,47 @@ import {
 } from "@/shared/hooks/useTimestampTitles";
 import { JsonTreeExpandControls } from "@/shared/components/JsonTreeExpandControls";
 import { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore";
+import {
+  isPipelineSizeLimitMarker,
+  isSizeLimitOmissionMarker,
+} from "@/shared/constants/callLogSizeLimitMarkers";
+
+// ─── Size-limit omission detection (#13894) ─────────────────────────────────
+// A size-limited call-log artifact does not simply drop a payload -- it writes
+// an explicit marker in its place (see callLogArtifacts.ts's
+// omitOversizedPipeline()/buildMinimalArtifactForSizeLimit()). Before this fix
+// the detail view fed that marker straight into the generic JSON/`
`
+// renderer, so a size-limit omission was indistinguishable from a real
+// upstream error or a genuinely empty payload -- a silent fallback. These
+// helpers turn the marker into an explicit, labeled notice instead.
+
+/** Builds the pipeline payload sections, replacing the `error` marker object
+ * left by a size-limited pipeline capture with an explicit notice entry
+ * instead of letting it render as if it were a real pipeline error. */
+export function buildPipelinePayloadSections(entries, pipelinePayloads) {
+  return entries
+    .map(([key, title]) => {
+      const value = pipelinePayloads?.[key];
+      if (key === "error" && isPipelineSizeLimitMarker(value)) {
+        return { key, title, json: null, notice: true };
+      }
+      if (value === null || value === undefined) return { key, title, json: null, notice: false };
+      let json;
+      try {
+        json = JSON.stringify(value, null, 2);
+      } catch {
+        json = String(value);
+      }
+      return { key, title, json, notice: false };
+    })
+    .filter((section) => section.json || section.notice);
+}
+
+/** True when a top-level requestBody/responseBody was replaced by the
+ * size-limit omission placeholder string rather than genuinely absent. */
+export function isBodySizeLimitOmission(value) {
+  return isSizeLimitOmissionMarker(value);
+}
 
 // ─── Payload Code Block ─────────────────────────────────────────────────────
 // Renders parsed payloads as a collapsible JSON tree (react18-json-view) so
@@ -21,11 +62,15 @@ import { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore";
 // the plain 
 dump for anything that isn't valid JSON (e.g. a captured
 // error string), since json is display text sourced from JSON.stringify with
 // a String() fallback on failure -- it is not guaranteed parseable.
+// `notice`, when true, takes over rendering entirely: it means `json` is not a
+// real payload but a size-limit omission marker (#13894) that must be shown as
+// an explicit, labeled notice rather than a generic JSON/error dump.
 
 export function PayloadSection({
   title,
   sectionId,
   json,
+  notice = false,
   onCopy,
   collapsible = true,
   defaultOpen = true,
@@ -78,20 +123,28 @@ export function PayloadSection({
           )}
         
         
- + {!notice && ( + + )} {parsedJson !== null && }
- {open && parsedJson !== null && ( + {open && notice && ( +
+ warning + {t("payloadSizeLimitOmitted")} +
+ )} + {open && !notice && parsedJson !== null && (
)} - {open && parsedJson === null && ( + {open && !notice && parsedJson === null && (
           {json}
         
diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index 0721a34e58..acc5fd0035 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -20,6 +20,8 @@ import { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore"; import { PayloadSection, ConversationContextSection, + buildPipelinePayloadSections, + isBodySizeLimitOmission, } from "@/shared/components/RequestLoggerDetail.sections"; // ─── Copy-all composition ──────────────────────────────────────────────────── @@ -470,22 +472,21 @@ export default function RequestLoggerDetail({ const pipelinePayloads = detail?.pipelinePayloads || null; const payloadSections = pipelinePayloads - ? [ - ["clientRawRequest", t("payload.clientRawRequest")], - ["clientRequest", t("payload.clientRequest")], - ["openaiRequest", t("payload.openaiRequest")], - ["providerRequest", t("payload.providerRequest")], - ["providerResponse", t("payload.providerResponse")], - ["clientResponse", t("payload.clientResponse")], - ["error", t("payload.pipelineError")], - ] - .map(([key, title]) => ({ - key, - title, - json: toPrettyJson(pipelinePayloads[key]), - })) - .filter((section) => section.json) + ? buildPipelinePayloadSections( + [ + ["clientRawRequest", t("payload.clientRawRequest")], + ["clientRequest", t("payload.clientRequest")], + ["openaiRequest", t("payload.openaiRequest")], + ["providerRequest", t("payload.providerRequest")], + ["providerResponse", t("payload.providerResponse")], + ["clientResponse", t("payload.clientResponse")], + ["error", t("payload.pipelineError")], + ], + pipelinePayloads + ) : []; + const requestBodyOmitted = isBodySizeLimitOmission(detail?.requestBody); + const responseBodyOmitted = isBodySizeLimitOmission(detail?.responseBody); const requestJson = detail?.requestBody ? toPrettyJson(detail.requestBody) : null; const responseJson = detail?.responseBody ? toPrettyJson(detail.responseBody) : null; const streamChunks = (() => { @@ -1155,6 +1156,7 @@ export default function RequestLoggerDetail({ title={section.title} sectionId={section.key} json={section.json} + notice={section.notice} onCopy={() => onCopy(section.json)} /> ))} @@ -1164,6 +1166,7 @@ export default function RequestLoggerDetail({ title={t("responsePayloadLegacy")} sectionId="responsePayloadLegacy" json={responseJson} + notice={responseBodyOmitted} onCopy={() => onCopy(responseJson)} /> )} @@ -1173,6 +1176,7 @@ export default function RequestLoggerDetail({ title={t("requestPayloadLegacy")} sectionId="requestPayloadLegacy" json={requestJson} + notice={requestBodyOmitted} onCopy={() => onCopy(requestJson)} /> )} diff --git a/src/shared/constants/callLogSizeLimitMarkers.ts b/src/shared/constants/callLogSizeLimitMarkers.ts new file mode 100644 index 0000000000..1a794114ea --- /dev/null +++ b/src/shared/constants/callLogSizeLimitMarkers.ts @@ -0,0 +1,40 @@ +// Sentinel markers written by src/lib/usage/callLogArtifacts.ts when a call-log +// artifact's request/response body or pipeline payload had to be dropped because +// it exceeded the configured size cap (CALL_LOG_PIPELINE_MAX_SIZE_KB / +// MAX_CALL_LOG_ARTIFACT_BYTES). Kept here — not inside callLogArtifacts.ts, which +// pulls in `fs`/`path` and cannot be imported by a client component — so the +// artifact writer and the request-log detail view (RequestLoggerDetail.tsx) share +// one definition of "this is a size-limit omission" instead of each guessing at +// the shape independently (see issue #13894: the previous frontend rendered the +// pipeline marker verbatim as if it were a real upstream error). + +export const CALL_LOG_SIZE_LIMIT_REASON = "call_log_artifact_size_limit_exceeded"; + +export const CALL_LOG_BODY_OMITTED_FOR_SIZE_LIMIT = + "[omitted: call log artifact size limit exceeded]"; + +export const CALL_LOG_STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT = + "[stream chunks omitted: call log artifact size limit exceeded]"; + +/** + * True for a placeholder a size-limit fallback wrote in place of a real + * requestBody/responseBody/stream-chunk payload. + */ +export function isSizeLimitOmissionMarker(value: unknown): boolean { + return ( + value === CALL_LOG_BODY_OMITTED_FOR_SIZE_LIMIT || + value === CALL_LOG_STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT + ); +} + +/** + * True for the `pipeline.error` marker object omitOversizedPipeline() writes in + * place of the real pipeline payload once it exceeds CALL_LOG_PIPELINE_MAX_SIZE_KB. + * Checked by shape (not just truthiness) so a real upstream error that happens to + * be named `error` is never mistaken for the size-limit marker. + */ +export function isPipelineSizeLimitMarker(pipelineError: unknown): boolean { + if (!pipelineError || typeof pipelineError !== "object") return false; + const candidate = pipelineError as { _omniroute_truncated?: unknown; reason?: unknown }; + return candidate._omniroute_truncated === true && candidate.reason === CALL_LOG_SIZE_LIMIT_REASON; +} diff --git a/tests/unit/dashboard/payload-section-size-limit-notice.test.tsx b/tests/unit/dashboard/payload-section-size-limit-notice.test.tsx new file mode 100644 index 0000000000..42510e1c10 --- /dev/null +++ b/tests/unit/dashboard/payload-section-size-limit-notice.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +// +// Regression guard for #13894: a size-limited call-log artifact does not +// simply drop a payload — callLogArtifacts.ts writes an explicit marker in +// its place (`{ error: { _omniroute_truncated: true, reason: ... } }` for the +// pipeline, or the `[omitted: call log artifact size limit exceeded]` string +// for requestBody/responseBody). Before this fix, RequestLoggerDetail fed +// that marker straight into the generic JSON/`
` renderer under a
+// generically-titled "Pipeline Error" section, so a size-limit omission was
+// silently indistinguishable from a real upstream error. PayloadSection must
+// now render an explicit, labeled notice instead whenever `notice` is set,
+// and buildPipelinePayloadSections()/isBodySizeLimitOmission() must detect
+// the marker shapes and set it.
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+vi.mock("next-intl", () => ({
+  useTranslations: () => (key: string) => key,
+}));
+
+vi.mock("@/shared/hooks/useTheme", () => ({
+  useTheme: () => ({ isDark: false }),
+}));
+
+const { PayloadSection, buildPipelinePayloadSections, isBodySizeLimitOmission } =
+  await import("../../../src/shared/components/RequestLoggerDetail.sections.tsx");
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+  container = document.createElement("div");
+  document.body.appendChild(container);
+  root = createRoot(container);
+});
+
+afterEach(() => {
+  act(() => root.unmount());
+  container.remove();
+  vi.clearAllMocks();
+});
+
+describe("PayloadSection size-limit omission notice (#13894)", () => {
+  it("renders an explicit notice instead of a JSON dump when notice=true, even if json is set", () => {
+    act(() => {
+      root.render(
+        
+      );
+    });
+
+    expect(container.querySelector("pre")).toBeNull();
+    expect(container.textContent).toContain("payloadSizeLimitOmitted");
+    expect(container.textContent).not.toContain("_omniroute_truncated");
+  });
+
+  it("renders the normal JSON tree when notice is not set", () => {
+    act(() => {
+      root.render(
+        
+      );
+    });
+
+    expect(container.textContent).not.toContain("payloadSizeLimitOmitted");
+    expect(container.textContent).toContain("status");
+  });
+
+  describe("buildPipelinePayloadSections()", () => {
+    const entries: Array<[string, string]> = [
+      ["providerResponse", "Provider Response"],
+      ["error", "Pipeline Error"],
+    ];
+
+    it("flags the pipeline.error size-limit marker with notice=true instead of dumping it as JSON", () => {
+      const pipelinePayloads = {
+        providerResponse: { status: 200 },
+        error: { _omniroute_truncated: true, reason: "call_log_artifact_size_limit_exceeded" },
+      };
+
+      const sections = buildPipelinePayloadSections(entries, pipelinePayloads);
+      const errorSection = sections.find((s) => s.key === "error");
+
+      expect(errorSection).toBeDefined();
+      expect(errorSection.notice).toBe(true);
+      expect(errorSection.json).toBeNull();
+    });
+
+    it("does NOT flag a real upstream error object shaped like {error: {...}} as a size-limit marker", () => {
+      const pipelinePayloads = {
+        providerResponse: { status: 500 },
+        error: { message: "upstream 500", code: "internal_error" },
+      };
+
+      const sections = buildPipelinePayloadSections(entries, pipelinePayloads);
+      const errorSection = sections.find((s) => s.key === "error");
+
+      expect(errorSection).toBeDefined();
+      expect(errorSection.notice).toBe(false);
+      expect(errorSection.json).toContain("upstream 500");
+    });
+
+    it("does not include a section for a key with no payload at all", () => {
+      const pipelinePayloads = { providerResponse: { status: 200 } };
+      const sections = buildPipelinePayloadSections(entries, pipelinePayloads);
+      expect(sections.map((s) => s.key)).toEqual(["providerResponse"]);
+    });
+  });
+
+  describe("isBodySizeLimitOmission()", () => {
+    it("is true for the requestBody/responseBody omission placeholder string", () => {
+      expect(isBodySizeLimitOmission("[omitted: call log artifact size limit exceeded]")).toBe(
+        true
+      );
+    });
+
+    it("is false for a real body value, including one that merely contains similar text", () => {
+      expect(isBodySizeLimitOmission({ messages: [{ role: "user", content: "hi" }] })).toBe(false);
+      expect(isBodySizeLimitOmission("call log artifact size limit exceeded (mentioned)")).toBe(
+        false
+      );
+      expect(isBodySizeLimitOmission(null)).toBe(false);
+      expect(isBodySizeLimitOmission(undefined)).toBe(false);
+    });
+  });
+});

From cd4c6f6947e59b328b17f334ef0169e96e17d7d1 Mon Sep 17 00:00:00 2001
From: Diego Rodrigues de Sa e Souza 
Date: Sat, 19 Sep 2026 01:24:39 -0300
Subject: [PATCH 33/33] fix(combo): auto-resume pinned native Codex turns
 (re-land of #13180) (#14162)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Re-land of #13180 by @mdigitalbh81 (their commits carried with authorship intact), merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip.

**Reconciled before landing (three maintainer commits on top of the re-land):**
- `81d770b3` — `check:api-typecheck` / `check:open-sse-typecheck` were red on the PR's own head with `TS2339 'reason' does not exist on type 'AutoResumeDecision'` ×3 in `open-sse/services/combo.ts` (green on the tip). Without `strictNullChecks` the truthiness test on the `eligible` discriminant does not narrow the else-branch; `=== true` does.
- `66e57cfe` — `tests/unit/native-codex-auto-resume.test.ts` was 1540 lines, above the 1200-line new-test cap. The three pure guard cases (`hasUnresolvedToolCalls`, `hasProviderSpecificUnsafeContinuationState`, `MAX_AUTORESUMES_PER_TURN`) moved verbatim to `native-codex-auto-resume-guards.test.ts`; the combo-flow file is now 1131 lines.
- `c2f1937f` — `executeTargetAttempt.ts` 1258→1273 rebaselined with a dated justification (own growth: generation advance + log at the pin site).

**Evidence on the merged tree:** `native-codex-auto-resume{,-guards}.test.ts` 15/15; `native-codex-turn-pin-model-scoped-fallback`, `native-codex-turn-pin-10379`, `chatgpt-web-codex-turn-pin` 33/33 total; broader combo sweep (`combo-responses-sse-failure-fallback`, `combo-context-window-filter`, `perf-waterfall-elimination`, `chatgpt-web-codex`, `combo-context-overflow-compression-probe`) 70/70; api/open-sse typecheck clean for the PR's files; file-size, changelog-integrity, complexity and cognitive-complexity gates OK.

**Inherited, not from this PR** (reproduced on the pure tip): the 23 unit reds in the fast-path shards (vi locale parity, pack-artifact allowlists, `.env.example` sync, casing, budget fallback, etc.), the 5 `no-unused-vars` lint errors (`cliRuntime.ts`, `arena-elo-sync-redesign`, `compressionAnalyticsWriterFlatRate`, `waitForServer-slow-first-response`), the `omni-version-manager` generated-skill drift, `tinycmsDomMocks.ts` / `rerankProviderNodes.ts` / `antigravity.ts` typecheck errors, and the 4 env vars missing from `.env.example`.

Supersedes #13180. Follow-up to #12240.
---
 .../13180-native-codex-turn-auto-resume.md    |    1 +
 config/quality/file-size-baseline.json        |    5 +-
 open-sse/services/combo.ts                    |   85 +-
 open-sse/services/combo/attemptLoopTypes.ts   |    1 +
 .../services/combo/executeTargetAttempt.ts    |   29 +-
 open-sse/services/combo/nativeCodexTurnPin.ts |  499 +++++++-
 .../native-codex-auto-resume-guards.test.ts   |  416 ++++++
 tests/unit/native-codex-auto-resume.test.ts   | 1131 +++++++++++++++++
 8 files changed, 2131 insertions(+), 36 deletions(-)
 create mode 100644 changelog.d/fixes/13180-native-codex-turn-auto-resume.md
 create mode 100644 tests/unit/native-codex-auto-resume-guards.test.ts
 create mode 100644 tests/unit/native-codex-auto-resume.test.ts

diff --git a/changelog.d/fixes/13180-native-codex-turn-auto-resume.md b/changelog.d/fixes/13180-native-codex-turn-auto-resume.md
new file mode 100644
index 0000000000..e0618cc419
--- /dev/null
+++ b/changelog.d/fixes/13180-native-codex-turn-auto-resume.md
@@ -0,0 +1 @@
+- **fix(combo):** auto-resume a pinned native Codex turn on a healthy sibling connection or model when the pinned provider becomes unavailable for a model-scoped reason (quota, model lockout) instead of failing the turn outright — provider-wide circuit-breaker/cooldown state, pending tool calls, opaque continuation state, and partial streams still block resume, and at most one auto-resume happens per logical turn ([#13180](https://github.com/diegosouzapw/OmniRoute/pull/13180)) — thanks @mdigitalbh81
diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json
index d3691e7cde..ea136a9e16 100644
--- a/config/quality/file-size-baseline.json
+++ b/config/quality/file-size-baseline.json
@@ -481,7 +481,7 @@
     "open-sse/services/accountFallback.ts": 2517,
     "open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
     "open-sse/services/combo.ts": 4080,
-    "open-sse/services/combo/executeTargetAttempt.ts": 1258,
+    "open-sse/services/combo/executeTargetAttempt.ts": 1273,
     "open-sse/translator/response/openai-responses.ts": 1518,
     "open-sse/utils/cursorAgentProtobuf.ts": 1588,
     "open-sse/utils/proxyFetch.ts": 1276,
@@ -719,5 +719,6 @@
   "_rebaseline_2026_09_17_11742_log_boundary_hardening": "PR #11742 (rebase para release/v3.8.51): open-sse/handlers/chatCore.ts 6219->6287. O crescimento e a unica parte da PR que sobreviveu ao tip: endurecimento da fronteira de LOG (mais amplo que a Hard Rule #12, que cobre respostas). Sao 136 linhas adicionadas, das quais ~40 sao chamadas diretas de sanitizacao — sanitizeErrorMessage em erro de plugin (onError), em timeout de semaforo e na failureMessage antes de ela chegar ao console.log e ao call-log; sanitizeUpstreamDetails no log de resposta malformada; getSafeErrorMetadata + try/catch nos pontos onde metadata hostil (Proxy) podia lançar. O resto da PR foi descartado por ja estar no tip (#12506/#12945/#13635 error boundaries, #12429 wreq-js, #11754 aposentadoria do ChatGPT Web) — open-sse/utils/ difere do tip por UMA linha (registro do identificador publico lmarena_stream_error).",
   "_rebaseline_2026_09_17_13670_allow_auto_combos": "PR #13670 (@fouadSalkini): per-key allowAutoCombos para gatear os combos auto/* embutidos. src/app/api/v1/models/catalog.ts 2075->2117 e src/lib/db/apiKeys.ts 1625->1659. Crescimento e 100% proprio da PR, nao herdado: medido no tip puro, catalog.ts esta em 2074 (abaixo do teto 2075) e apiKeys.ts em 1620 (abaixo de 1625). O aumento e a propria feature — o campo de permissao por chave precisa ser lido, validado e propagado ate o filtro do catalogo, e cada ponto e chamada explicita, nao extraivel sem esconder o gate. Coberto pelos 25 testes da PR. As demais violacoes desta arvore (chatHelpers.ts, chatCore.ts e tests/unit/chatcore-translation-paths.test.ts) sao base-red herdado do tip e nao foram tocadas aqui.",
   "_rebaseline_2026_09_17c_chatcore_translation_paths_test": "tests/unit/chatcore-translation-paths.test.ts 3447->3449 (#13173, prefixos de cache de meio de conversa do Fable — as assercoes novas do caso). Ultimo teto remanescente da leva de merges de 2026-09-17; os outros dois (chatHelpers.ts e chatCore.ts) foram absorvidos pelos rebaselines das proprias PRs que mergearam depois. Medido no tip limpo.",
-  "_rebaseline_2026_09_18_13929_antigravity_account_lease_merge": "PR #13929 (Re-land of #10011, @Ardem2025 via @diegosouzapw): the Antigravity account lease, merged onto the current release/v3.8.51 tip (which had independently moved chat.ts to 2520 and auth.ts to 3557 via unrelated PRs). Combined ceiling after merge: src/sse/handlers/chat.ts->2541, src/sse/services/auth.ts->3577. The lease registry, its lifecycle glue and its selection glue were extracted into three NEW modules (src/sse/services/antigravityRoutingState.ts, antigravityLeaseLifecycle.ts, antigravityLeaseSelection.ts) precisely to keep this growth to the call sites; what remains in chat.ts/auth.ts is the wiring itself, which cannot be moved out of the selection loop and the dispatch path. Every added hunk is inert unless ANTIGRAVITY_ACCOUNT_LEASE_ENABLED (default false) is on. Covered by tests/unit/antigravity-routing-state.test.ts, antigravity-lease-lifecycle.test.ts and antigravity-account-lease-flag.test.ts. UPDATE (re-sync 2026-09-18 after trains 3b/4d moved the tip): auth.ts 3577->3582 (same +29 own growth over a tip now at 3552). open-sse/executors/base.ts 1753->1754 is NOT this PR's growth — it is release-tip drift from train 3b (#13002 +5 / #13705 -4 net +1, both merged without a baseline entry); absorbed here by the captain session under the owner-approved train-rebaseline policy so the tip stops failing check:file-size for every PR boarding after it."
+  "_rebaseline_2026_09_18_13929_antigravity_account_lease_merge": "PR #13929 (Re-land of #10011, @Ardem2025 via @diegosouzapw): the Antigravity account lease, merged onto the current release/v3.8.51 tip (which had independently moved chat.ts to 2520 and auth.ts to 3557 via unrelated PRs). Combined ceiling after merge: src/sse/handlers/chat.ts->2541, src/sse/services/auth.ts->3577. The lease registry, its lifecycle glue and its selection glue were extracted into three NEW modules (src/sse/services/antigravityRoutingState.ts, antigravityLeaseLifecycle.ts, antigravityLeaseSelection.ts) precisely to keep this growth to the call sites; what remains in chat.ts/auth.ts is the wiring itself, which cannot be moved out of the selection loop and the dispatch path. Every added hunk is inert unless ANTIGRAVITY_ACCOUNT_LEASE_ENABLED (default false) is on. Covered by tests/unit/antigravity-routing-state.test.ts, antigravity-lease-lifecycle.test.ts and antigravity-account-lease-flag.test.ts. UPDATE (re-sync 2026-09-18 after trains 3b/4d moved the tip): auth.ts 3577->3582 (same +29 own growth over a tip now at 3552). open-sse/executors/base.ts 1753->1754 is NOT this PR's growth — it is release-tip drift from train 3b (#13002 +5 / #13705 -4 net +1, both merged without a baseline entry); absorbed here by the captain session under the owner-approved train-rebaseline policy so the tip stops failing check:file-size for every PR boarding after it.",
+  "_rebaseline_2026_09_19_14162_native_codex_auto_resume": "PR #14162 (re-land of #13180, @mdigitalbh81 via @diegosouzapw): native Codex turn auto-resume. open-sse/services/combo/executeTargetAttempt.ts 1258->1273 (+15). Growth is 100% the PR's own, measured against the clean tip (1258 there, gate green): the pin step now advances the logical turn generation and logs the resumed provider/model when the attempt is an auto-resume dispatch, and the generation is passed into pinNativeCodexTurn — the branch has to sit at the pin site because that is the only place the winning target and effective connection are known. Covered by tests/unit/native-codex-auto-resume.test.ts + native-codex-auto-resume-guards.test.ts (15/15) and #13564's native-codex-turn-pin-model-scoped-fallback.test.ts (7/7)."
 }
diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts
index 5dad34a275..1913b7e5c1 100644
--- a/open-sse/services/combo.ts
+++ b/open-sse/services/combo.ts
@@ -90,6 +90,8 @@ export {
 import {
   applyNativeCodexTurnPin,
   areAllPinnedTargetsModelScopedUnusable,
+  canAutoResumeNativeCodexTurn,
+  createPinnedModelUnavailableResponse,
   getNativeCodexTurnPin,
   releaseNativeCodexTurnPin,
 } from "./combo/nativeCodexTurnPin.ts";
@@ -131,6 +133,22 @@ import { executeTargetAttempt } from "./combo/executeTargetAttempt.ts";
 import type { AttemptLoopDeps, AttemptLoopState } from "./combo/attemptLoopTypes.ts";
 import { clearStaleLKGP } from "./combo/staleLkgpClear.ts";
 
+// Native Codex auto-resume (#13180) rejection reasons that mean the turn either carries
+// state unsafe to hand to an untested alternate model (pending tool calls, opaque
+// provider-specific continuation state) or has already used its one allowed resume for
+// this logical turn. These must terminate the turn rather than fall through to #13564's
+// plain "release pin and route naturally" fallback. Every other reason (e.g. the request
+// does not use the Responses-API input/messages shape #13180's eligibility check needs)
+// falls through unchanged so non-native-turn-shaped Codex requests keep working exactly
+// as before #13180.
+const NATIVE_CODEX_AUTO_RESUME_UNSAFE_REASONS = new Set([
+  "pending_tool_call",
+  "unsafe_provider_state",
+  "no_alternate_target",
+  "no_healthy_alternate_target",
+  "max_resumes_exceeded",
+]);
+
 export { RESET_WINDOW_NAMES, QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty };
 export { scoreAutoTargets, expandAutoComboCandidatePool };
 export type { SingleModelTarget, ResolvedComboTarget };
@@ -776,9 +794,10 @@ async function handleComboChatInner({
   });
   if (runtimeUnitDispatch) return runtimeUnitDispatch;
 
-  const activeNativeTurnPin = clientManagedResponsesContext
+  let activeNativeTurnPin = clientManagedResponsesContext
     ? getNativeCodexTurnPin(body, combo.name)
     : null;
+  let isAutoResuming = false;
 
   // Route new round-robin turns to the specialized handler. A native Codex
   // continuation with an established provider/account pin must use the common
@@ -858,15 +877,60 @@ async function handleComboChatInner({
         isModelAvailable,
       });
       if (allPinnedUnusable) {
-        // All pinned provider+model targets are model-scoped unusable — release
-        // the pin and fall through to full combo routing so the turn can try
-        // other models in the combo pool. This matches Claude Code's behavior
-        // where no turn pin allows natural multi-model fallback.
-        releaseNativeCodexTurnPin(body as Record, combo.name);
-        log.warn(
-          "COMBO",
-          `Native Codex turn pin released: pinned model ${activeNativeTurnPin.modelStr} model-scoped unavailable; falling back to full combo routing`
-        );
+        const autoResumeEligibility = await canAutoResumeNativeCodexTurn({
+          body: body as Record,
+          comboName: combo.name,
+          activePin: activeNativeTurnPin,
+          allTargets: orderedTargets,
+          resilienceSettings,
+          quotaCutoffResetWindowConfig,
+          isModelAvailable,
+          log,
+        });
+
+        if (autoResumeEligibility.eligible === true) {
+          const selectedAlternate = autoResumeEligibility.selectedTarget;
+          log.info(
+            "COMBO",
+            `Native Codex auto-resume eligible: previous provider/model=${activeNativeTurnPin.provider}/${activeNativeTurnPin.modelStr}, previous logical turn generation=${autoResumeEligibility.previousPin.generation ?? 0}, reason=model_scoped_unavailable`
+          );
+          log.info(
+            "COMBO",
+            `Native Codex auto-resume started: previous provider/model=${activeNativeTurnPin.provider}/${activeNativeTurnPin.modelStr}, target provider/model=${selectedAlternate.provider}/${selectedAlternate.modelStr}, target generation=${autoResumeEligibility.nextGeneration}`
+          );
+          const alternateTargets = orderedTargets.filter(
+            (t) =>
+              t.modelStr === selectedAlternate.modelStr && t.provider === selectedAlternate.provider
+          );
+          orderedTargets = alternateTargets;
+          activeNativeTurnPin = null;
+          isAutoResuming = true;
+        } else if (NATIVE_CODEX_AUTO_RESUME_UNSAFE_REASONS.has(autoResumeEligibility.reason)) {
+          // These specific rejection reasons mean the turn carries state (pending
+          // tool calls, opaque provider-specific continuation state) or has
+          // already exhausted its resume budget, so handing it to an untested
+          // alternate model via natural combo routing (#13564's plain fallback)
+          // would be unsafe or would violate #13180's "at most one auto-resume
+          // per logical turn" bound. Terminate instead of falling through.
+          targetResolution.quotaShareRelease?.();
+          log.warn(
+            "COMBO",
+            `Native Codex turn cannot continue: pinned model ${activeNativeTurnPin.modelStr} is unavailable (model-scoped); auto-resume rejected (${autoResumeEligibility.reason}); preserving turn pin and terminating turn`
+          );
+          return createPinnedModelUnavailableResponse();
+        } else {
+          // Every other rejection reason (e.g. the request body does not carry
+          // the Responses-API `input`/`messages` shape #13180's eligibility
+          // check needs) means auto-resume simply cannot be evaluated — it says
+          // nothing about the request being unsafe. Fall back to the plain
+          // release-and-route-naturally behavior (#13564) so non-native-turn or
+          // legacy-shaped Codex requests keep working exactly as before #13180.
+          releaseNativeCodexTurnPin(body as Record, combo.name);
+          log.warn(
+            "COMBO",
+            `Native Codex turn pin released: pinned model ${activeNativeTurnPin.modelStr} model-scoped unavailable; auto-resume not eligible (${autoResumeEligibility.reason}); falling back to full combo routing`
+          );
+        }
       } else {
         orderedTargets = pinnedTargets;
         log.info(
@@ -989,6 +1053,7 @@ async function handleComboChatInner({
     releaseStickyPinOnFailure,
     clearStaleLKGP,
     clientManagedResponsesContext,
+    nativeCodexAutoResume: isAutoResuming,
     reasoningTokenBufferEnabled,
     stickyWeightedLimit,
     getWeightedStepKeyForTarget,
diff --git a/open-sse/services/combo/attemptLoopTypes.ts b/open-sse/services/combo/attemptLoopTypes.ts
index c60fa50c5e..d43ffd51c6 100644
--- a/open-sse/services/combo/attemptLoopTypes.ts
+++ b/open-sse/services/combo/attemptLoopTypes.ts
@@ -102,6 +102,7 @@ export type AttemptLoopDeps = {
    * gate tests keep compiling; attempt uses defaults when absent.
    */
   clientManagedResponsesContext?: boolean;
+  nativeCodexAutoResume?: boolean;
   reasoningTokenBufferEnabled?: boolean;
   stickyWeightedLimit?: number;
   getWeightedStepKeyForTarget?: (target: ResolvedComboTarget) => string | null;
diff --git a/open-sse/services/combo/executeTargetAttempt.ts b/open-sse/services/combo/executeTargetAttempt.ts
index 2c0a261421..21252fb777 100644
--- a/open-sse/services/combo/executeTargetAttempt.ts
+++ b/open-sse/services/combo/executeTargetAttempt.ts
@@ -71,7 +71,7 @@ import {
   isModelScoped400,
 } from "./comboPredicates.ts";
 import { applyComboTargetExhaustion } from "./targetExhaustion.ts";
-import { pinNativeCodexTurn } from "./nativeCodexTurnPin.ts";
+import { advanceNativeCodexTurnGeneration, pinNativeCodexTurn } from "./nativeCodexTurnPin.ts";
 import { recordComboDecision } from "./decisionTrace.ts";
 import { recordProviderCooldown } from "../providerCooldownTracker.ts";
 import {
@@ -469,12 +469,27 @@ export async function executeTargetAttempt(opts: {
       }
 
       if (Boolean(deps.clientManagedResponsesContext) && effectiveConnectionId) {
-        pinNativeCodexTurn({
-          body: deps.body,
-          comboName: deps.combo.name,
-          target,
-          connectionId: effectiveConnectionId,
-        });
+        if (deps.nativeCodexAutoResume) {
+          const nextGen = advanceNativeCodexTurnGeneration(deps.body, deps.combo.name);
+          deps.log.info(
+            "COMBO",
+            `Native Codex auto-resume routed: new provider/model=${target.modelStr} on connection ${effectiveConnectionId.slice(0, 8)} (logical turn generation ${nextGen})`
+          );
+          pinNativeCodexTurn({
+            body: deps.body,
+            comboName: deps.combo.name,
+            target,
+            connectionId: effectiveConnectionId,
+            generation: nextGen ?? undefined,
+          });
+        } else {
+          pinNativeCodexTurn({
+            body: deps.body,
+            comboName: deps.combo.name,
+            target,
+            connectionId: effectiveConnectionId,
+          });
+        }
       }
 
       // Success decay: a healthy response walks the model's lockout failure
diff --git a/open-sse/services/combo/nativeCodexTurnPin.ts b/open-sse/services/combo/nativeCodexTurnPin.ts
index b030d0a47e..20567171f4 100644
--- a/open-sse/services/combo/nativeCodexTurnPin.ts
+++ b/open-sse/services/combo/nativeCodexTurnPin.ts
@@ -13,18 +13,30 @@ import type { ComboLogger, IsModelAvailable } from "./types.ts";
 
 import type { ResolvedComboTarget } from "./types.ts";
 
-type NativeTurnPin = {
+export type NativeTurnPin = {
   comboName: string;
   modelStr: string;
   provider: string;
   connectionId: string;
   createdAt: number;
   expiresAt: number;
+  generation?: number;
 };
 
+export interface NativeCodexTurnRecord {
+  comboName: string;
+  threadId: string;
+  turnId: string;
+  activeGeneration: number;
+  pins: Map;
+  createdAt: number;
+  expiresAt: number;
+}
+
+export const MAX_AUTORESUMES_PER_TURN = 1;
 const TTL_MS = 45 * 60_000;
 const MAX_PINS = 1_000;
-const pins = new Map();
+const turns = new Map();
 
 function record(value: unknown): Record | undefined {
   return value && typeof value === "object" && !Array.isArray(value)
@@ -57,21 +69,51 @@ export function nativeCodexTurnKey(
 }
 
 function prune(now = Date.now()): void {
-  for (const [key, pin] of pins) if (pin.expiresAt <= now) pins.delete(key);
-  while (pins.size > MAX_PINS) {
-    const oldest = pins.keys().next().value as string | undefined;
+  for (const [key, rec] of turns) if (rec.expiresAt <= now) turns.delete(key);
+  while (turns.size > MAX_PINS) {
+    const oldest = turns.keys().next().value as string | undefined;
     if (!oldest) break;
-    pins.delete(oldest);
+    turns.delete(oldest);
   }
 }
 
 export function getNativeCodexTurnPin(
   body: Record,
-  comboName: string
+  comboName: string,
+  generation?: number
 ): NativeTurnPin | null {
   prune();
   const key = nativeCodexTurnKey(body, comboName);
-  return key ? (pins.get(key) ?? null) : null;
+  if (!key) return null;
+  const rec = turns.get(key);
+  if (!rec) return null;
+  const gen = generation !== undefined ? generation : rec.activeGeneration;
+  return rec.pins.get(gen) ?? null;
+}
+
+export function getNativeCodexTurnActiveGeneration(
+  body: Record,
+  comboName: string
+): number {
+  prune();
+  const key = nativeCodexTurnKey(body, comboName);
+  if (!key) return 0;
+  return turns.get(key)?.activeGeneration ?? 0;
+}
+
+export function advanceNativeCodexTurnGeneration(
+  body: Record,
+  comboName: string
+): number | null {
+  prune();
+  const key = nativeCodexTurnKey(body, comboName);
+  if (!key) return null;
+  const rec = turns.get(key);
+  if (!rec) return null;
+  rec.activeGeneration += 1;
+  const now = Date.now();
+  rec.expiresAt = now + TTL_MS;
+  return rec.activeGeneration;
 }
 
 export function pinNativeCodexTurn(args: {
@@ -79,10 +121,27 @@ export function pinNativeCodexTurn(args: {
   comboName: string;
   target: ResolvedComboTarget;
   connectionId: string;
+  generation?: number;
 }): void {
   const key = nativeCodexTurnKey(args.body, args.comboName);
   if (!key || !args.connectionId) return;
-  const existing = pins.get(key);
+  let rec = turns.get(key);
+  const now = Date.now();
+  if (!rec) {
+    const metadata = turnMetadata(args.body);
+    rec = {
+      comboName: args.comboName,
+      threadId: typeof metadata?.thread_id === "string" ? metadata.thread_id : "",
+      turnId: typeof metadata?.turn_id === "string" ? metadata.turn_id : "",
+      activeGeneration: 0,
+      pins: new Map(),
+      createdAt: now,
+      expiresAt: now + TTL_MS,
+    };
+    turns.set(key, rec);
+  }
+  const gen = args.generation !== undefined ? args.generation : rec.activeGeneration;
+  const existing = rec.pins.get(gen);
   if (
     existing &&
     (existing.modelStr !== args.target.modelStr || existing.provider !== args.target.provider)
@@ -91,15 +150,16 @@ export function pinNativeCodexTurn(args: {
   }
   // ConnectionId changes are allowed (failover to sibling connection)
   // as long as provider + model stay the same.
-  const now = Date.now();
-  pins.set(key, {
+  rec.pins.set(gen, {
     comboName: args.comboName,
     modelStr: args.target.modelStr,
     provider: args.target.provider,
     connectionId: args.connectionId,
     createdAt: existing?.createdAt ?? now,
     expiresAt: now + TTL_MS,
+    generation: gen,
   });
+  rec.expiresAt = now + TTL_MS;
   prune(now);
 }
 
@@ -153,10 +213,16 @@ export function applyNativeCodexTurnPin(
 
 export function revokeNativeCodexTurnPinsForConnection(connectionId: string): number {
   let revoked = 0;
-  for (const [key, pin] of pins) {
-    if (pin.connectionId !== connectionId) continue;
-    pins.delete(key);
-    revoked += 1;
+  for (const [key, rec] of turns) {
+    for (const [gen, pin] of rec.pins) {
+      if (pin.connectionId === connectionId) {
+        rec.pins.delete(gen);
+        revoked += 1;
+      }
+    }
+    if (rec.pins.size === 0) {
+      turns.delete(key);
+    }
   }
   return revoked;
 }
@@ -282,9 +348,408 @@ export async function areAllPinnedTargetsModelScopedUnusable(
 
 export function releaseNativeCodexTurnPin(body: Record, comboName: string): void {
   const key = nativeCodexTurnKey(body, comboName);
-  if (key) pins.delete(key);
+  if (key) turns.delete(key);
 }
 
 export function clearNativeCodexTurnPinsForTests(): void {
-  pins.clear();
+  turns.clear();
+}
+
+export function hasUnresolvedToolCalls(body: Record): boolean {
+  const input: unknown[] = Array.isArray(body.input)
+    ? body.input
+    : Array.isArray(body.messages)
+      ? body.messages
+      : [];
+  if (input.length === 0) return false;
+
+  const callCounts = new Map();
+  const outputCounts = new Map();
+
+  const processPart = (part: unknown): boolean => {
+    if (!part || typeof part !== "object") return true;
+    const rec = part as Record;
+    const type = typeof rec.type === "string" ? rec.type : "";
+
+    if (type === "function_call" || type === "custom_tool_call") {
+      const callId =
+        (typeof rec.call_id === "string" && rec.call_id.trim() ? rec.call_id.trim() : "") ||
+        (typeof rec.id === "string" && rec.id.trim() ? rec.id.trim() : "");
+      if (!callId) return false;
+      callCounts.set(callId, (callCounts.get(callId) ?? 0) + 1);
+    } else if (type === "function_call_output" || type === "custom_tool_call_output") {
+      const callId =
+        (typeof rec.call_id === "string" && rec.call_id.trim() ? rec.call_id.trim() : "") ||
+        (typeof rec.id === "string" && rec.id.trim() ? rec.id.trim() : "");
+      if (!callId) return false;
+      outputCounts.set(callId, (outputCounts.get(callId) ?? 0) + 1);
+    } else if (type === "tool_use") {
+      const callId = typeof rec.id === "string" && rec.id.trim() ? rec.id.trim() : "";
+      if (!callId) return false;
+      callCounts.set(callId, (callCounts.get(callId) ?? 0) + 1);
+    } else if (type === "tool_result") {
+      const callId =
+        (typeof rec.tool_use_id === "string" && rec.tool_use_id.trim()
+          ? rec.tool_use_id.trim()
+          : "") || (typeof rec.id === "string" && rec.id.trim() ? rec.id.trim() : "");
+      if (!callId) return false;
+      outputCounts.set(callId, (outputCounts.get(callId) ?? 0) + 1);
+    }
+    return true;
+  };
+
+  for (const item of input) {
+    if (!item || typeof item !== "object") continue;
+    const rec = item as Record;
+    const role = typeof rec.role === "string" ? rec.role : "";
+
+    if (rec.function_call && typeof rec.function_call === "object" && role === "assistant") {
+      return true;
+    }
+    if (role === "function") {
+      return true;
+    }
+
+    if (!processPart(rec)) return true;
+
+    if (role === "assistant" && Array.isArray(rec.tool_calls)) {
+      for (const tc of rec.tool_calls) {
+        if (!tc || typeof tc !== "object") return true;
+        const id =
+          typeof (tc as Record).id === "string"
+            ? ((tc as Record).id as string).trim()
+            : "";
+        if (!id) return true;
+        callCounts.set(id, (callCounts.get(id) ?? 0) + 1);
+      }
+    } else if (role === "tool") {
+      const toolCallId =
+        (typeof rec.tool_call_id === "string" && rec.tool_call_id.trim()
+          ? rec.tool_call_id.trim()
+          : "") ||
+        (typeof rec.call_id === "string" && rec.call_id.trim() ? rec.call_id.trim() : "") ||
+        (typeof rec.id === "string" && rec.id.trim() ? rec.id.trim() : "");
+      if (!toolCallId) return true;
+      outputCounts.set(toolCallId, (outputCounts.get(toolCallId) ?? 0) + 1);
+    }
+
+    if (Array.isArray(rec.content)) {
+      for (const part of rec.content) {
+        if (!processPart(part)) return true;
+      }
+    }
+    if (Array.isArray(rec.output)) {
+      for (const part of rec.output) {
+        if (!processPart(part)) return true;
+      }
+    }
+  }
+
+  if (callCounts.size === 0 && outputCounts.size === 0) return false;
+  if (callCounts.size !== outputCounts.size) return true;
+  for (const [id, count] of callCounts) {
+    if (count !== 1) return true;
+    if (outputCounts.get(id) !== 1) return true;
+  }
+  for (const [id, count] of outputCounts) {
+    if (count !== 1) return true;
+    if (!callCounts.has(id)) return true;
+  }
+
+  return false;
+}
+
+function isUnsafeItemOrPart(rec: Record): boolean {
+  const type = typeof rec.type === "string" ? rec.type : "";
+  if (type === "item_reference" || type === "redacted_thinking") return true;
+  if (type === "encrypted_content") return true;
+  if (typeof rec.previous_response_id === "string" && rec.previous_response_id.trim() !== "")
+    return true;
+  if (typeof rec.previousResponseId === "string" && rec.previousResponseId.trim() !== "")
+    return true;
+  if (typeof rec.continuation_token === "string" && rec.continuation_token.trim() !== "")
+    return true;
+  if (typeof rec.continuationToken === "string" && rec.continuationToken.trim() !== "") return true;
+  if (typeof rec.encrypted_content === "string" && rec.encrypted_content.trim() !== "") return true;
+  if (typeof rec.encryptedContent === "string" && rec.encryptedContent.trim() !== "") return true;
+  if (typeof rec.encrypted_reasoning === "string" && rec.encrypted_reasoning.trim() !== "")
+    return true;
+  if (typeof rec.encryptedReasoning === "string" && rec.encryptedReasoning.trim() !== "")
+    return true;
+  if (typeof rec.thought_signature === "string" && rec.thought_signature.trim() !== "") return true;
+  if (typeof rec.thoughtSignature === "string" && rec.thoughtSignature.trim() !== "") return true;
+  if (typeof rec.signature === "string" && rec.signature.trim() !== "") return true;
+  if (
+    rec.provider_metadata &&
+    typeof rec.provider_metadata === "object" &&
+    Object.keys(rec.provider_metadata as object).length > 0
+  ) {
+    return true;
+  }
+  if (
+    rec.providerMetadata &&
+    typeof rec.providerMetadata === "object" &&
+    Object.keys(rec.providerMetadata as object).length > 0
+  ) {
+    return true;
+  }
+  if (
+    rec.provider_data &&
+    typeof rec.provider_data === "object" &&
+    Object.keys(rec.provider_data as object).length > 0
+  ) {
+    return true;
+  }
+  if (
+    rec.providerData &&
+    typeof rec.providerData === "object" &&
+    Object.keys(rec.providerData as object).length > 0
+  ) {
+    return true;
+  }
+  return false;
+}
+
+export function hasProviderSpecificUnsafeContinuationState(
+  body: Record,
+  _activePin?: NativeTurnPin
+): boolean {
+  if (typeof body.previous_response_id === "string" && body.previous_response_id.trim() !== "") {
+    return true;
+  }
+  if (typeof body.previousResponseId === "string" && body.previousResponseId.trim() !== "") {
+    return true;
+  }
+  if (typeof body.continuation_token === "string" && body.continuation_token.trim() !== "") {
+    return true;
+  }
+  if (typeof body.continuationToken === "string" && body.continuationToken.trim() !== "") {
+    return true;
+  }
+  if (typeof body.response_id === "string" && body.response_id.trim() !== "") {
+    return true;
+  }
+  if (typeof body.responseId === "string" && body.responseId.trim() !== "") {
+    return true;
+  }
+  if (typeof body.parent_response_id === "string" && body.parent_response_id.trim() !== "") {
+    return true;
+  }
+  if (typeof body.parentResponseId === "string" && body.parentResponseId.trim() !== "") {
+    return true;
+  }
+  if (typeof body.thought_signature === "string" && body.thought_signature.trim() !== "") {
+    return true;
+  }
+  if (typeof body.thoughtSignature === "string" && body.thoughtSignature.trim() !== "") {
+    return true;
+  }
+  if (typeof body.signature === "string" && body.signature.trim() !== "") {
+    return true;
+  }
+  if (typeof body.conversation_id === "string" && body.conversation_id.trim() !== "") {
+    return true;
+  }
+  if (typeof body.conversationId === "string" && body.conversationId.trim() !== "") {
+    return true;
+  }
+  if (
+    body.conversation &&
+    typeof body.conversation === "object" &&
+    Object.keys(body.conversation as object).length > 0
+  ) {
+    return true;
+  }
+  if (
+    body.provider_metadata &&
+    typeof body.provider_metadata === "object" &&
+    Object.keys(body.provider_metadata as object).length > 0
+  ) {
+    return true;
+  }
+  if (
+    body.providerMetadata &&
+    typeof body.providerMetadata === "object" &&
+    Object.keys(body.providerMetadata as object).length > 0
+  ) {
+    return true;
+  }
+  if (
+    body.provider_data &&
+    typeof body.provider_data === "object" &&
+    Object.keys(body.provider_data as object).length > 0
+  ) {
+    return true;
+  }
+  if (
+    body.providerData &&
+    typeof body.providerData === "object" &&
+    Object.keys(body.providerData as object).length > 0
+  ) {
+    return true;
+  }
+
+  const input: unknown[] = Array.isArray(body.input)
+    ? body.input
+    : Array.isArray(body.messages)
+      ? body.messages
+      : [];
+
+  for (const item of input) {
+    if (!item || typeof item !== "object") continue;
+    const rec = item as Record;
+    if (isUnsafeItemOrPart(rec)) return true;
+
+    if (Array.isArray(rec.content)) {
+      for (const part of rec.content) {
+        if (
+          part &&
+          typeof part === "object" &&
+          isUnsafeItemOrPart(part as Record)
+        ) {
+          return true;
+        }
+      }
+    }
+    if (Array.isArray(rec.output)) {
+      for (const outItem of rec.output) {
+        if (
+          outItem &&
+          typeof outItem === "object" &&
+          isUnsafeItemOrPart(outItem as Record)
+        ) {
+          return true;
+        }
+      }
+    }
+    if (Array.isArray(rec.summary)) {
+      for (const sumItem of rec.summary) {
+        if (
+          sumItem &&
+          typeof sumItem === "object" &&
+          isUnsafeItemOrPart(sumItem as Record)
+        ) {
+          return true;
+        }
+      }
+    }
+    if (Array.isArray(rec.tool_calls)) {
+      for (const tc of rec.tool_calls) {
+        if (tc && typeof tc === "object" && isUnsafeItemOrPart(tc as Record)) {
+          return true;
+        }
+      }
+    }
+  }
+
+  return false;
+}
+
+export interface CanAutoResumeNativeCodexTurnOptions {
+  body: Record;
+  comboName: string;
+  activePin: NativeTurnPin;
+  allTargets: ResolvedComboTarget[];
+  resilienceSettings?: ResilienceSettings | null;
+  quotaCutoffResetWindowConfig?: ResetWindowConfig;
+  isModelAvailable?: IsModelAvailable;
+  log?: ComboLogger;
+}
+
+export type AutoResumeDecision =
+  | {
+      eligible: true;
+      nextGeneration: number;
+      previousPin: NativeTurnPin;
+      selectedTarget: ResolvedComboTarget;
+    }
+  | {
+      eligible: false;
+      reason: string;
+      details?: Record;
+    };
+
+export async function canAutoResumeNativeCodexTurn(
+  options: CanAutoResumeNativeCodexTurnOptions
+): Promise {
+  const {
+    body,
+    comboName,
+    activePin,
+    allTargets,
+    resilienceSettings,
+    quotaCutoffResetWindowConfig,
+    isModelAvailable,
+  } = options;
+
+  const key = nativeCodexTurnKey(body, comboName);
+  if (!key) return { eligible: false, reason: "invalid_turn_key" };
+
+  const rec = turns.get(key);
+  const currentGen = rec?.activeGeneration ?? 0;
+  if (currentGen >= MAX_AUTORESUMES_PER_TURN) {
+    return { eligible: false, reason: "max_resumes_exceeded" };
+  }
+
+  const inputList = Array.isArray(body.input)
+    ? body.input
+    : Array.isArray(body.messages)
+      ? body.messages
+      : null;
+  if (!inputList || inputList.length === 0) {
+    return { eligible: false, reason: "missing_or_empty_input" };
+  }
+
+  if (hasUnresolvedToolCalls(body)) {
+    return { eligible: false, reason: "pending_tool_call" };
+  }
+
+  if (hasProviderSpecificUnsafeContinuationState(body, activePin)) {
+    return { eligible: false, reason: "unsafe_provider_state" };
+  }
+
+  const alternateTargets = allTargets.filter(
+    (t) => t.modelStr !== activePin.modelStr || t.provider !== activePin.provider
+  );
+  if (alternateTargets.length === 0) {
+    return { eligible: false, reason: "no_alternate_target" };
+  }
+
+  let selectedTarget: ResolvedComboTarget | null = null;
+  for (const alt of alternateTargets) {
+    if (alt.provider && alt.provider !== "unknown") {
+      const cb = getCircuitBreaker(alt.provider);
+      if (cb.getStatus().state === "OPEN") continue;
+    }
+    if (
+      resilienceSettings?.providerCooldown?.enabled &&
+      (isProviderInCooldown(alt.provider, alt.connectionId || undefined, resilienceSettings) ||
+        isProviderInCooldown(alt.provider, undefined, resilienceSettings))
+    ) {
+      continue;
+    }
+    const unusable = await isPinnedTargetModelScopedUnusable({
+      target: alt,
+      resilienceSettings,
+      quotaCutoffResetWindowConfig,
+      comboName,
+      body,
+      isModelAvailable,
+    });
+    if (!unusable) {
+      selectedTarget = alt;
+      break;
+    }
+  }
+
+  if (!selectedTarget) {
+    return { eligible: false, reason: "no_healthy_alternate_target" };
+  }
+
+  return {
+    eligible: true,
+    nextGeneration: currentGen + 1,
+    previousPin: activePin,
+    selectedTarget,
+  };
 }
diff --git a/tests/unit/native-codex-auto-resume-guards.test.ts b/tests/unit/native-codex-auto-resume-guards.test.ts
new file mode 100644
index 0000000000..16d7d89eb5
--- /dev/null
+++ b/tests/unit/native-codex-auto-resume-guards.test.ts
@@ -0,0 +1,416 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+// Pure guard predicates of the native Codex auto-resume path (#13180). Split out of
+// tests/unit/native-codex-auto-resume.test.ts (which exercises the full combo flow
+// against a scratch DB) so each file stays under the 1200-line test cap; these three
+// cases need no DB, no combo config and no fixtures.
+const {
+  hasUnresolvedToolCalls,
+  hasProviderSpecificUnsafeContinuationState,
+  MAX_AUTORESUMES_PER_TURN,
+} = await import("../../open-sse/services/combo/nativeCodexTurnPin.ts");
+
+test("MAX_AUTORESUMES_PER_TURN constant is 1", () => {
+  assert.equal(MAX_AUTORESUMES_PER_TURN, 1);
+});
+
+test("hasUnresolvedToolCalls correctly validates 1:1 call-output pairs and rejects duplicates/orphans/nested", () => {
+  // Empty input: no tool calls
+  assert.equal(hasUnresolvedToolCalls({}), false);
+  assert.equal(hasUnresolvedToolCalls({ input: [] }), false);
+
+  // Nested output array with unresolved tool_use is unsafe (true)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        {
+          type: "function_call_output",
+          call_id: "c1",
+          output: [{ type: "tool_use", id: "tu-nested", name: "bash" }],
+        },
+      ],
+    }),
+    true
+  );
+
+  // Two calls with same call_id and two outputs with same call_id (count=2 != 1) is unsafe (true)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        { type: "function_call", call_id: "c-dup2", name: "cat", arguments: "{}" },
+        { type: "function_call", call_id: "c-dup2", name: "cat", arguments: "{}" },
+        { type: "function_call_output", call_id: "c-dup2", output: "out1" },
+        { type: "function_call_output", call_id: "c-dup2", output: "out2" },
+      ],
+    }),
+    true
+  );
+
+  // Two distinct calls with two distinct matching outputs is safe (false)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        { type: "function_call", call_id: "c-1", name: "cat", arguments: "{}" },
+        { type: "function_call", call_id: "c-2", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "c-1", output: "out1" },
+        { type: "function_call_output", call_id: "c-2", output: "out2" },
+      ],
+    }),
+    false
+  );
+
+  // Resolved function call (1 call, 1 matching output)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        { type: "message", role: "user", content: "read file" },
+        { type: "function_call", call_id: "call-1", name: "cat", arguments: "{}" },
+        { type: "function_call_output", call_id: "call-1", output: "hello world" },
+      ],
+    }),
+    false
+  );
+
+  // Unresolved function call (call with no output)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        { type: "message", role: "user", content: "read file" },
+        { type: "function_call", call_id: "call-1", name: "cat", arguments: "{}" },
+      ],
+    }),
+    true
+  );
+
+  // Resolved custom tool call
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        { type: "message", role: "user", content: "patch file" },
+        { type: "custom_tool_call", call_id: "call-2", name: "apply_patch", input: "diff" },
+        { type: "custom_tool_call_output", call_id: "call-2", output: "ok" },
+      ],
+    }),
+    false
+  );
+
+  // Unresolved custom tool call
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        { type: "message", role: "user", content: "patch file" },
+        { type: "custom_tool_call", call_id: "call-2", name: "apply_patch", input: "diff" },
+      ],
+    }),
+    true
+  );
+
+  // Anthropic tool_use and tool_result in content array (resolved)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      messages: [
+        {
+          role: "assistant",
+          content: [{ type: "tool_use", id: "tu-1", name: "bash", input: {} }],
+        },
+        {
+          role: "user",
+          content: [{ type: "tool_result", tool_use_id: "tu-1", content: "done" }],
+        },
+      ],
+    }),
+    false
+  );
+
+  // Anthropic tool_use in content array (unresolved)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      messages: [
+        {
+          role: "assistant",
+          content: [{ type: "tool_use", id: "tu-1", name: "bash", input: {} }],
+        },
+      ],
+    }),
+    true
+  );
+
+  // Assistant message tool_calls format (resolved)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        {
+          type: "message",
+          role: "assistant",
+          tool_calls: [{ id: "call-3", type: "function", function: { name: "shell" } }],
+        },
+        { type: "message", role: "tool", tool_call_id: "call-3", content: "done" },
+      ],
+    }),
+    false
+  );
+
+  // Assistant message tool_calls format (unresolved)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        {
+          type: "message",
+          role: "assistant",
+          tool_calls: [{ id: "call-3", type: "function", function: { name: "shell" } }],
+        },
+      ],
+    }),
+    true
+  );
+
+  // Duplicate tool call ID: two calls with same ID, one output -> unsafe (true)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        { type: "function_call", call_id: "call-dup", name: "cat", arguments: "{}" },
+        { type: "function_call", call_id: "call-dup", name: "cat", arguments: "{}" },
+        { type: "function_call_output", call_id: "call-dup", output: "res" },
+      ],
+    }),
+    true
+  );
+
+  // Duplicate tool output ID: one call, two outputs with same ID -> unsafe (true)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [
+        { type: "function_call", call_id: "call-dup-out", name: "cat", arguments: "{}" },
+        { type: "function_call_output", call_id: "call-dup-out", output: "res1" },
+        { type: "function_call_output", call_id: "call-dup-out", output: "res2" },
+      ],
+    }),
+    true
+  );
+
+  // Orphaned tool output: output without matching call -> unsafe (true)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [{ type: "function_call_output", call_id: "orphan-call", output: "res" }],
+    }),
+    true
+  );
+
+  // Malformed tool call with empty call_id -> unsafe (true)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [{ type: "function_call", call_id: "", name: "cat", arguments: "{}" }],
+    }),
+    true
+  );
+
+  // Legacy unidentifiable function_call -> unsafe (true)
+  assert.equal(
+    hasUnresolvedToolCalls({
+      input: [{ role: "assistant", function_call: { name: "test", arguments: "{}" } }],
+    }),
+    true
+  );
+});
+
+test("hasProviderSpecificUnsafeContinuationState detects opaque provider state at all levels", () => {
+  // Clean input: safe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    false
+  );
+
+  // conversation_id at root: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      conversation_id: "conv_12345",
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    true
+  );
+
+  // conversation object at root: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      conversation: { id: "conv_67890" },
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    true
+  );
+
+  // Item with item-level previous_response_id: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [
+        {
+          type: "message",
+          role: "assistant",
+          previous_response_id: "resp_nested_prev",
+          content: "hello",
+        },
+      ],
+    }),
+    true
+  );
+
+  // Item with item-level continuation_token: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [
+        {
+          type: "message",
+          role: "assistant",
+          continuation_token: "tok_nested_cont",
+          content: "hello",
+        },
+      ],
+    }),
+    true
+  );
+
+  // previous_response_id: unsafe (binds to upstream response store)
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      previous_response_id: "resp_12345_upstream",
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    true
+  );
+
+  // continuation_token: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      continuation_token: "tok_opaque_blob",
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    true
+  );
+
+  // response_id: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      response_id: "resp_999",
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    true
+  );
+
+  // provider_metadata at root: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      provider_metadata: { openai: { message_id: "m1" } },
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    true
+  );
+
+  // item_reference: unsafe (server-side item ID)
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [{ type: "item_reference", id: "item_abc123" }],
+    }),
+    true
+  );
+
+  // reasoning item with encrypted_content: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [
+        { type: "reasoning", encrypted_content: "enc_blob_xyz" },
+        { type: "message", role: "user", content: "hello" },
+      ],
+    }),
+    true
+  );
+
+  // thinking item with thought_signature: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [
+        { type: "thinking", thought_signature: "sig_gemini_blob" },
+        { type: "message", role: "user", content: "hello" },
+      ],
+    }),
+    true
+  );
+
+  // redacted_thinking item: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [{ type: "redacted_thinking", data: "redacted" }],
+    }),
+    true
+  );
+
+  // Nested thinking part inside content array with signature: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      messages: [
+        {
+          role: "assistant",
+          content: [
+            { type: "thinking", thinking: "deep thought", signature: "sig-xyz" },
+            { type: "text", text: "hello" },
+          ],
+        },
+      ],
+    }),
+    true
+  );
+
+  // encrypted_content item: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [{ type: "encrypted_content", encrypted_content: "enc_123" }],
+    }),
+    true
+  );
+
+  // Root body thoughtSignature (camelCase): unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      thoughtSignature: "sig_camel_case",
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    true
+  );
+
+  // Root body provider_data: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      provider_data: { gemini: { candidate_token_count: 50 } },
+      input: [{ type: "message", role: "user", content: "hello" }],
+    }),
+    true
+  );
+
+  // Nested output array with encrypted_content: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [
+        {
+          type: "function_call_output",
+          call_id: "c1",
+          output: [{ type: "encrypted_content", encrypted_content: "enc_blob" }],
+        },
+      ],
+    }),
+    true
+  );
+
+  // Nested summary array with thought_signature: unsafe
+  assert.equal(
+    hasProviderSpecificUnsafeContinuationState({
+      input: [
+        {
+          type: "reasoning",
+          summary: [{ type: "summary_text", text: "...", thought_signature: "sig" }],
+        },
+      ],
+    }),
+    true
+  );
+});
diff --git a/tests/unit/native-codex-auto-resume.test.ts b/tests/unit/native-codex-auto-resume.test.ts
new file mode 100644
index 0000000000..dac462bff4
--- /dev/null
+++ b/tests/unit/native-codex-auto-resume.test.ts
@@ -0,0 +1,1131 @@
+import test, { describe, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-autoresume-test-"));
+const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const { handleComboChat } = await import("../../open-sse/services/combo.ts");
+const { lockExactModel, clearAllModelLockouts } =
+  await import("../../open-sse/services/accountFallback.ts");
+const {
+  pinNativeCodexTurn,
+  advanceNativeCodexTurnGeneration,
+  getNativeCodexTurnPin,
+  getNativeCodexTurnActiveGeneration,
+  clearNativeCodexTurnPinsForTests,
+  revokeNativeCodexTurnPinsForConnection,
+  NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE,
+} = await import("../../open-sse/services/combo/nativeCodexTurnPin.ts");
+const { recordProviderCooldown, isProviderInCooldown, clearCooldownState } =
+  await import("../../open-sse/services/providerCooldownTracker.ts");
+const { PROVIDER_PROFILES } = await import("../../open-sse/config/constants.ts");
+const { getCircuitBreaker, resetAllCircuitBreakers } =
+  await import("../../src/shared/utils/circuitBreaker.ts");
+const { resolveResilienceSettings } = await import("../../src/lib/resilience/settings.ts");
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+
+const testSettings = {
+  resilienceSettings: {
+    providerCooldown: {
+      enabled: true,
+      minRetryCooldownMs: 5000,
+      maxRetryCooldownMs: 300000,
+    },
+    comboCooldownWait: { enabled: false },
+  },
+};
+const settings = resolveResilienceSettings(testSettings);
+
+function createLog(entries: Array<{ level: string; tag: string; msg: string }> = []) {
+  return {
+    info: (tag: string, msg: string) => entries.push({ level: "info", tag, msg }),
+    warn: (tag: string, msg: string) => entries.push({ level: "warn", tag, msg }),
+    error: (tag: string, msg: string) => entries.push({ level: "error", tag, msg }),
+    debug: (tag: string, msg: string) => entries.push({ level: "debug", tag, msg }),
+    entries,
+  };
+}
+
+async function cleanupTestDataDir() {
+  let lastError: unknown;
+  for (let attempt = 0; attempt < 5; attempt += 1) {
+    try {
+      core.resetDbInstance();
+      fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+      return;
+    } catch (error) {
+      lastError = error;
+      await new Promise((resolve) => setTimeout(resolve, 25));
+    }
+  }
+  if (lastError) throw lastError;
+}
+
+test.after(async () => {
+  await cleanupTestDataDir();
+  process.env.DATA_DIR = ORIGINAL_DATA_DIR;
+});
+
+beforeEach(async () => {
+  clearAllModelLockouts();
+  clearCooldownState();
+  resetAllCircuitBreakers();
+  clearNativeCodexTurnPinsForTests();
+});
+
+describe("Native Codex Safe Auto-Resume", () => {
+  const comboName = "Codex";
+  const opusModel = "antigravity/claude-opus-4-6-thinking";
+  const geminiModel = "antigravity/gemini-3.7-flash-high";
+  const codexModel = "codex/gpt-5.5-high";
+
+  const comboConfig = {
+    name: comboName,
+    strategy: "fill-first" as const,
+    models: [opusModel, geminiModel, codexModel],
+    config: {
+      maxRetries: 0,
+      concurrencyPerModel: 1,
+      queueTimeoutMs: 1000,
+    },
+  };
+
+  test("5-Phase Production Scenario: Opus 429 Model Lockout -> Safe Auto-Resume to Gemini -> Subsequent Pinned to Gemini", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+    const conn2 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 2",
+    });
+    await providersDb.createProviderConnection({
+      provider: "codex",
+      authType: "apikey",
+      name: "Codex Key",
+      apiKey: "sk-codex-test",
+    });
+    const conn1Id = conn1.id;
+    const conn2Id = conn2.id;
+    const attemptedModels: string[] = [];
+
+    const baseTurnMetadata = {
+      thread_id: "thread-autoresume-123",
+      turn_id: "turn-autoresume-456",
+    };
+
+    // PHASE 1: Opus succeeds for turn-autoresume-456, pin created for generation 0
+    const phase1Body = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+      },
+      input: [{ type: "message", role: "user", content: "list files then edit" }],
+    };
+
+    const phase1Result = await handleComboChat({
+      body: phase1Body,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_body, modelStr) => {
+        attemptedModels.push(modelStr);
+        return new Response(
+          JSON.stringify({ choices: [{ message: { content: "opus output" } }] }),
+          {
+            status: 200,
+            headers: {
+              "content-type": "application/json",
+              "x-omniroute-selected-connection-id": conn1Id,
+            },
+          }
+        );
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(phase1Result.ok, true);
+    assert.deepEqual(attemptedModels, [opusModel]);
+
+    const pinGen0 = getNativeCodexTurnPin(phase1Body, comboName, 0);
+    assert.ok(pinGen0, "Generation 0 pin created after Phase 1");
+    assert.equal(pinGen0.modelStr, opusModel);
+    assert.equal(pinGen0.provider, "antigravity");
+    assert.equal(pinGen0.connectionId, conn1Id);
+    assert.equal(getNativeCodexTurnActiveGeneration(phase1Body, comboName), 0);
+
+    // PHASE 2 & 3: Tool output sent for SAME turn. Opus receives 429 lockout across all connections.
+    // Safe automatic resume triggers to Gemini.
+    lockExactModel("antigravity", conn1Id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+    lockExactModel("antigravity", conn2Id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+    lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+    attemptedModels.length = 0;
+    const phase2LogEntries: Array<{ level: string; tag: string; msg: string }> = [];
+    const phase2Body = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+      },
+      input: [
+        { type: "message", role: "user", content: "list files then edit" },
+        { type: "function_call", call_id: "call-1", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "call-1", output: "main.ts\npackage.json" },
+      ],
+    };
+
+    const phase2Result = await handleComboChat({
+      body: phase2Body,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_body, modelStr) => {
+        attemptedModels.push(modelStr);
+        if (modelStr === geminiModel) {
+          return new Response(
+            JSON.stringify({ choices: [{ message: { content: "gemini resumed output" } }] }),
+            {
+              status: 200,
+              headers: {
+                "content-type": "application/json",
+                "x-omniroute-selected-connection-id": conn1Id,
+              },
+            }
+          );
+        }
+        return new Response(JSON.stringify({ error: "unexpected model" }), { status: 500 });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(phase2LogEntries),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(phase2Result.ok, true, "Phase 2 must succeed automatically on Gemini");
+    assert.deepEqual(
+      attemptedModels,
+      [geminiModel],
+      "Only Gemini dispatched (Opus skipped due to lockout)"
+    );
+
+    // Verify telemetry logs for auto-resume
+    const eligibleLog = phase2LogEntries.find((e) =>
+      e.msg.includes("Native Codex auto-resume eligible")
+    );
+    const startedLog = phase2LogEntries.find((e) =>
+      e.msg.includes("Native Codex auto-resume started")
+    );
+    const routedLog = phase2LogEntries.find((e) =>
+      e.msg.includes("Native Codex auto-resume routed")
+    );
+    assert.ok(eligibleLog, "Should log auto-resume eligible");
+    assert.ok(startedLog, "Should log auto-resume started");
+    assert.ok(routedLog, "Should log auto-resume routed to Gemini");
+
+    // PHASE 4: Verify generation isolation: Opus gen 0 pin NOT mutated, Gemini is gen 1 pin
+    const activeGen = getNativeCodexTurnActiveGeneration(phase2Body, comboName);
+    assert.equal(activeGen, 1, "Active generation is now 1");
+
+    const gen0PinCheck = getNativeCodexTurnPin(phase2Body, comboName, 0);
+    assert.ok(gen0PinCheck);
+    assert.equal(gen0PinCheck.modelStr, opusModel, "Generation 0 pin remains Opus (not mutated)");
+
+    const gen1PinCheck = getNativeCodexTurnPin(phase2Body, comboName, 1);
+    assert.ok(gen1PinCheck);
+    assert.equal(gen1PinCheck.modelStr, geminiModel, "Generation 1 pin is Gemini");
+
+    // Active pin query without generation returns current active (Gemini)
+    const currentActivePin = getNativeCodexTurnPin(phase2Body, comboName);
+    assert.equal(currentActivePin?.modelStr, geminiModel);
+
+    // PHASE 5: Subsequent tool output for SAME turn stays pinned to Gemini
+    attemptedModels.length = 0;
+    const phase3Body = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+      },
+      input: [
+        { type: "message", role: "user", content: "list files then edit" },
+        { type: "function_call", call_id: "call-1", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "call-1", output: "main.ts\npackage.json" },
+        { type: "function_call", call_id: "call-2", name: "cat", arguments: '{"file":"main.ts"}' },
+        { type: "function_call_output", call_id: "call-2", output: "console.log('hi')" },
+      ],
+    };
+
+    const phase3Result = await handleComboChat({
+      body: phase3Body,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_body, modelStr) => {
+        attemptedModels.push(modelStr);
+        return new Response(
+          JSON.stringify({ choices: [{ message: { content: "gemini step 2 output" } }] }),
+          {
+            status: 200,
+            headers: {
+              "content-type": "application/json",
+              "x-omniroute-selected-connection-id": conn1Id,
+            },
+          }
+        );
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(phase3Result.ok, true);
+    assert.deepEqual(attemptedModels, [geminiModel], "Subsequent request stayed pinned to Gemini");
+  });
+
+  test("Opaque continuation state (previous_response_id) rejects auto-resume and returns HTTP 400", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+
+    const baseTurnMetadata = {
+      thread_id: "thread-unsafe-state",
+      turn_id: "turn-unsafe-state",
+    };
+
+    // Phase 1: Opus succeeds
+    const phase1Body = {
+      stream: false,
+      client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+      input: [{ type: "message", role: "user", content: "hello" }],
+    };
+
+    await handleComboChat({
+      body: phase1Body,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1.id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    // Lock Opus
+    lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+    lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+    // Request with previous_response_id
+    const unsafeBody = {
+      stream: false,
+      previous_response_id: "resp_opus_pinned_upstream",
+      client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+      input: [
+        { type: "message", role: "user", content: "hello" },
+        { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "c1", output: "ok" },
+      ],
+    };
+
+    const attempted: string[] = [];
+    const logs: Array<{ level: string; tag: string; msg: string }> = [];
+    const result = await handleComboChat({
+      body: unsafeBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        attempted.push(m);
+        return new Response(JSON.stringify({ ok: true }), { status: 200 });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(logs),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(result.status, 400);
+    const data = await result.json();
+    assert.equal(data.error.code, NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE);
+    assert.equal(attempted.length, 0, "No model dispatched");
+
+    const rejectLog = logs.find(
+      (e) => e.msg.includes("auto-resume rejected") && e.msg.includes("unsafe_provider_state")
+    );
+    assert.ok(rejectLog, "Should log rejection reason unsafe_provider_state");
+  });
+
+  test("Pending tool call prevents auto-resume and returns HTTP 400", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+
+    const baseTurnMetadata = {
+      thread_id: "thread-pending-123",
+      turn_id: "turn-pending-456",
+    };
+
+    // Phase 1: Opus succeeds
+    const phase1Body = {
+      stream: false,
+      client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+      input: [{ type: "message", role: "user", content: "hello" }],
+    };
+
+    await handleComboChat({
+      body: phase1Body,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1.id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    // Lock Opus
+    lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+    lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+    // Request with UNRESOLVED tool call (missing tool output)
+    const pendingToolBody = {
+      stream: false,
+      client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+      input: [
+        { type: "message", role: "user", content: "hello" },
+        { type: "function_call", call_id: "call-unresolved", name: "shell", arguments: "{}" },
+      ],
+    };
+
+    const attempted: string[] = [];
+    const logs: Array<{ level: string; tag: string; msg: string }> = [];
+    const result = await handleComboChat({
+      body: pendingToolBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        attempted.push(m);
+        return new Response(JSON.stringify({ ok: true }), { status: 200 });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(logs),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(result.status, 400, "Must return HTTP 400 when tool call unresolved");
+    const data = await result.json();
+    assert.equal(data.error.code, NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE);
+    assert.equal(attempted.length, 0, "No model dispatched");
+
+    const rejectLog = logs.find(
+      (e) => e.msg.includes("auto-resume rejected") && e.msg.includes("pending_tool_call")
+    );
+    assert.ok(rejectLog, "Should log rejection reason pending_tool_call");
+  });
+
+  test("Partial stream safety: Opus emits partial SSE stream chunks then fails -> Gemini is NOT dispatched mid-stream", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+
+    const baseTurnMetadata = {
+      thread_id: "thread-partial-stream-safety",
+      turn_id: "turn-partial-stream-safety",
+    };
+
+    // Phase 1: Opus succeeds
+    const phase1Body = {
+      stream: true,
+      client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+      input: [{ type: "message", role: "user", content: "hello" }],
+    };
+
+    await handleComboChat({
+      body: phase1Body,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1.id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    // Phase 2: Request is sent. Opus is NOT locked before dispatch.
+    // Opus returns a stream that emits partial bytes and then aborts/fails.
+    // Invariant: Gemini MUST NOT be dispatched during this request.
+    const phase2Body = {
+      stream: true,
+      client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+      input: [
+        { type: "message", role: "user", content: "hello" },
+        { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "c1", output: "ok" },
+      ],
+    };
+
+    const attempted: string[] = [];
+    const stream = new ReadableStream({
+      start(controller) {
+        controller.enqueue(
+          new TextEncoder().encode('data: {"choices":[{"delta":{"content":"partial output"}}]}\n\n')
+        );
+        controller.error(new Error("Mid-stream connection reset"));
+      },
+    });
+
+    const _result = await handleComboChat({
+      body: phase2Body,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        attempted.push(m);
+        return new Response(stream, {
+          status: 200,
+          headers: { "content-type": "text/event-stream" },
+        });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    // Pinned turn with maxRetries=0 dispatches strictly Opus; Gemini must NOT be called
+    assert.deepEqual(attempted, [opusModel], "Only pinned Opus dispatched; Gemini never called");
+    assert.equal(
+      getNativeCodexTurnActiveGeneration(phase2Body, comboName),
+      0,
+      "Generation remains 0 on runtime failure"
+    );
+  });
+
+  test("Sibling connection is preferred over auto-resume", async () => {
+    const conn1Id = "conn-sib-1";
+    const conn2Id = "conn-sib-2";
+
+    const explicitComboConfig = {
+      name: comboName,
+      strategy: "fill-first" as const,
+      models: [
+        { id: "s1", kind: "model" as const, model: opusModel, connectionId: conn1Id, weight: 1 },
+        { id: "s2", kind: "model" as const, model: opusModel, connectionId: conn2Id, weight: 1 },
+        { id: "s3", kind: "model" as const, model: geminiModel, connectionId: conn1Id, weight: 1 },
+      ],
+      config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 },
+    };
+
+    const turnBody = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify({
+          thread_id: "thread-sib",
+          turn_id: "turn-sib",
+        }),
+      },
+      input: [{ type: "message", role: "user", content: "test" }],
+    };
+
+    // Phase 1: Opus succeeds on conn1
+    await handleComboChat({
+      body: turnBody,
+      combo: explicitComboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus conn1" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1Id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    // Lock ONLY conn1 Opus; conn2 remains healthy
+    lockExactModel("antigravity", conn1Id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+    const attempted: Array<{ modelStr: string; connectionId?: string }> = [];
+    const result = await handleComboChat({
+      body: turnBody,
+      combo: explicitComboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_body, modelStr, target) => {
+        attempted.push({ modelStr, connectionId: target?.connectionId || undefined });
+        return new Response(JSON.stringify({ choices: [{ message: { content: "opus conn2" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn2Id },
+        });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(result.ok, true);
+    assert.equal(attempted.length, 1);
+    assert.equal(attempted[0].modelStr, opusModel, "Opus remains pinned to sibling connection");
+    assert.equal(attempted[0].connectionId, conn2Id, "Connection failed over to conn2");
+    assert.equal(
+      getNativeCodexTurnActiveGeneration(turnBody, comboName),
+      0,
+      "No generation advance on sibling failover"
+    );
+  });
+
+  test("No healthy alternate model in combo returns HTTP 400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE and does NOT advance generation", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+
+    const singleModelComboConfig = {
+      name: "OpusOnly",
+      strategy: "fill-first" as const,
+      models: [opusModel],
+      config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 },
+    };
+
+    const turnBody = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify({
+          thread_id: "thread-single",
+          turn_id: "turn-single",
+        }),
+      },
+      input: [{ type: "message", role: "user", content: "test" }],
+    };
+
+    // Phase 1: Opus succeeds
+    await handleComboChat({
+      body: turnBody,
+      combo: singleModelComboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1.id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    // Lock Opus
+    lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+    lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+    const attempted: string[] = [];
+    const result = await handleComboChat({
+      body: turnBody,
+      combo: singleModelComboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        attempted.push(m);
+        return new Response(JSON.stringify({ ok: true }), { status: 200 });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(result.status, 400);
+    const data = await result.json();
+    assert.equal(data.error.code, NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE);
+    assert.equal(attempted.length, 0);
+    assert.equal(
+      getNativeCodexTurnActiveGeneration(turnBody, "OpusOnly"),
+      0,
+      "Generation must not advance when no alternate target exists"
+    );
+  });
+
+  test("Provider circuit breaker OPEN does NOT trigger auto-resume", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+
+    const turnBody = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify({
+          thread_id: "thread-cb",
+          turn_id: "turn-cb",
+        }),
+      },
+      input: [
+        { type: "message", role: "user", content: "cmd" },
+        { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "c1", output: "ok" },
+      ],
+    };
+
+    // Phase 1: Opus succeeds
+    await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1.id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    // Trip provider circuit breaker (provider-wide failure)
+    const cb = getCircuitBreaker("antigravity", { failureThreshold: 1, resetTimeout: 60000 });
+    try {
+      await cb.execute(async () => {
+        throw new Error("simulated 503");
+      });
+    } catch {
+      // expected
+    }
+    assert.equal(cb.getStatus().state, "OPEN");
+
+    const attempted: string[] = [];
+    const result = await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        attempted.push(m);
+        return new Response(JSON.stringify({ ok: true }), { status: 200 });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(result.ok, false, "Should fail due to provider circuit breaker OPEN");
+    assert.equal(attempted.length, 0, "No targets attempted");
+    assert.equal(
+      getNativeCodexTurnActiveGeneration(turnBody, comboName),
+      0,
+      "Circuit breaker does not advance generation"
+    );
+  });
+
+  test("Provider global cooldown does NOT trigger auto-resume", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+
+    const turnBody = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify({
+          thread_id: "thread-cd",
+          turn_id: "turn-cd",
+        }),
+      },
+      input: [
+        { type: "message", role: "user", content: "cmd" },
+        { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "c1", output: "ok" },
+      ],
+    };
+
+    // Phase 1: Opus succeeds
+    await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1.id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    // Trigger provider global cooldown.
+    // Current upstream requires providerFailureThreshold failures before
+    // the whole provider is considered cooling.
+    for (let i = 0; i < PROVIDER_PROFILES.oauth.providerFailureThreshold; i += 1) {
+      recordProviderCooldown("antigravity", undefined, settings);
+    }
+    assert.equal(isProviderInCooldown("antigravity", undefined, settings), true);
+
+    const attempted: string[] = [];
+    const result = await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        attempted.push(m);
+        return new Response(JSON.stringify({ ok: true }), { status: 200 });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(result.ok, false);
+    assert.equal(attempted.length, 0);
+    assert.equal(getNativeCodexTurnActiveGeneration(turnBody, comboName), 0);
+  });
+
+  test("MAX_AUTORESUMES_PER_TURN = 1 stops cascading: Opus -> Gemini succeeds, but second failure in same turn returns terminal 400", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+    await providersDb.createProviderConnection({
+      provider: "codex",
+      authType: "apikey",
+      name: "Codex Key",
+      apiKey: "sk-codex-test",
+    });
+
+    const baseTurnMetadata = {
+      thread_id: "thread-max-cascade-1",
+      turn_id: "turn-max-cascade-1",
+    };
+
+    const turnBody = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+      },
+      input: [
+        { type: "message", role: "user", content: "cascade test" },
+        { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "c1", output: "ok" },
+      ],
+    };
+
+    // Gen 0: Opus succeeds
+    await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1.id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+    assert.equal(getNativeCodexTurnActiveGeneration(turnBody, comboName), 0);
+
+    // Lock Opus -> 1st auto-resume to Gemini (Gen 1) SUCCEEDS
+    lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+    lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+    const resGen1 = await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        if (m === geminiModel) {
+          return new Response(JSON.stringify({ choices: [{ message: { content: "gemini" } }] }), {
+            status: 200,
+            headers: { "x-omniroute-selected-connection-id": conn1.id },
+          });
+        }
+        return new Response(JSON.stringify({ error: "fail" }), { status: 500 });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(resGen1.ok, true);
+    assert.equal(getNativeCodexTurnActiveGeneration(turnBody, comboName), 1);
+
+    // Now lock Gemini as well in the SAME turn: 2nd auto-resume MUST BE REJECTED (policy = 1)
+    lockExactModel("antigravity", conn1.id, "gemini-3.7-flash-high", "quota_exhausted", 60_000);
+    lockExactModel("antigravity", "", "gemini-3.7-flash-high", "quota_exhausted", 60_000);
+
+    const attemptedGen2: string[] = [];
+    const logsGen2: Array<{ level: string; tag: string; msg: string }> = [];
+    const resGen2 = await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        attemptedGen2.push(m);
+        return new Response(JSON.stringify({ choices: [{ message: { content: "codex" } }] }), {
+          status: 200,
+        });
+      },
+      isModelAvailable: async () => true,
+      log: createLog(logsGen2),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(resGen2.status, 400, "Must return HTTP 400: max resumes exceeded");
+    const dataGen2 = await resGen2.json();
+    assert.equal(dataGen2.error.code, NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE);
+    assert.equal(attemptedGen2.length, 0, "Codex must NOT be dispatched on 2nd cascade");
+    assert.equal(
+      getNativeCodexTurnActiveGeneration(turnBody, comboName),
+      1,
+      "Generation remains 1"
+    );
+
+    const maxLog = logsGen2.find(
+      (e) => e.msg.includes("auto-resume rejected") && e.msg.includes("max_resumes_exceeded")
+    );
+    assert.ok(maxLog, "Should log max_resumes_exceeded rejection");
+  });
+
+  test("Pin immutability, multi-generation revocation, and TTL expiry cleanup", () => {
+    const mockBody = {
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify({
+          thread_id: "thread-immutability",
+          turn_id: "turn-immutability",
+        }),
+      },
+    };
+
+    // Pin Generation 0 on conn-A
+    pinNativeCodexTurn({
+      body: mockBody,
+      comboName,
+      target: {
+        kind: "model",
+        stepId: "s1",
+        executionKey: "ek1",
+        modelStr: opusModel,
+        provider: "antigravity",
+        providerId: null,
+        connectionId: "conn-A",
+        weight: 1,
+        label: null,
+      },
+      connectionId: "conn-A",
+    });
+
+    const gen0Pin = getNativeCodexTurnPin(mockBody, comboName, 0);
+    assert.equal(gen0Pin?.modelStr, opusModel);
+    assert.equal(gen0Pin?.connectionId, "conn-A");
+
+    // Advance to Gen 1 and pin on conn-B
+    advanceNativeCodexTurnGeneration(mockBody, comboName);
+    pinNativeCodexTurn({
+      body: mockBody,
+      comboName,
+      target: {
+        kind: "model",
+        stepId: "s2",
+        executionKey: "ek2",
+        modelStr: geminiModel,
+        provider: "antigravity",
+        providerId: null,
+        connectionId: "conn-B",
+        weight: 1,
+        label: null,
+      },
+      connectionId: "conn-B",
+    });
+
+    // Verify Gen 0 is still Opus on conn-A (immutability check)
+    const gen0Check = getNativeCodexTurnPin(mockBody, comboName, 0);
+    assert.equal(gen0Check?.modelStr, opusModel);
+    assert.equal(gen0Check?.connectionId, "conn-A");
+
+    // Verify Gen 1 is Gemini on conn-B
+    const gen1Check = getNativeCodexTurnPin(mockBody, comboName, 1);
+    assert.equal(gen1Check?.modelStr, geminiModel);
+    assert.equal(gen1Check?.connectionId, "conn-B");
+
+    // Revoke pins for conn-A only: Gen 0 is deleted, Gen 1 is intact
+    const revokedConnA = revokeNativeCodexTurnPinsForConnection("conn-A");
+    assert.equal(revokedConnA, 1);
+    assert.equal(getNativeCodexTurnPin(mockBody, comboName, 0), null);
+    assert.equal(getNativeCodexTurnPin(mockBody, comboName, 1)?.modelStr, geminiModel);
+
+    // Revoke pins for conn-B: Gen 1 is deleted, turn record is fully removed
+    const revokedConnB = revokeNativeCodexTurnPinsForConnection("conn-B");
+    assert.equal(revokedConnB, 1);
+    assert.equal(getNativeCodexTurnPin(mockBody, comboName, 1), null);
+    assert.equal(getNativeCodexTurnActiveGeneration(mockBody, comboName), 0);
+  });
+
+  test("Auto-resume dispatch failure does not advance generation or cascade to 3rd model", async () => {
+    const conn1 = await providersDb.createProviderConnection({
+      provider: "antigravity",
+      authType: "oauth",
+      name: "Antigravity Account 1",
+    });
+    await providersDb.createProviderConnection({
+      provider: "codex",
+      authType: "apikey",
+      name: "Codex Key",
+      apiKey: "sk-codex-test",
+    });
+
+    const baseTurnMetadata = {
+      thread_id: "thread-fail-no-cascade",
+      turn_id: "turn-fail-no-cascade",
+    };
+
+    const turnBody = {
+      stream: false,
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+      },
+      input: [
+        { type: "message", role: "user", content: "cmd" },
+        { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+        { type: "function_call_output", call_id: "c1", output: "ok" },
+      ],
+    };
+
+    // Phase 1: Opus succeeds (Gen 0)
+    await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async () =>
+        new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+          status: 200,
+          headers: { "x-omniroute-selected-connection-id": conn1.id },
+        }),
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+    assert.equal(getNativeCodexTurnActiveGeneration(turnBody, comboName), 0);
+
+    // Lock Opus
+    lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+    lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+    // Phase 2: Auto-resume routes to Gemini, but Gemini upstream fails (500)
+    // Invariant: Codex (3rd model) MUST NOT be dispatched in this same request!
+    const attempted: string[] = [];
+    const res = await handleComboChat({
+      body: turnBody,
+      combo: comboConfig,
+      clientManagedResponsesContext: true,
+      handleSingleModel: async (_b, m) => {
+        attempted.push(m);
+        if (m === geminiModel) {
+          return new Response(JSON.stringify({ error: "gemini temporary 500" }), { status: 500 });
+        }
+        return new Response(
+          JSON.stringify({ choices: [{ message: { content: "codex leaked" } }] }),
+          {
+            status: 200,
+          }
+        );
+      },
+      isModelAvailable: async () => true,
+      log: createLog(),
+      settings: testSettings,
+      allCombos: null,
+    });
+
+    assert.equal(res.ok, false);
+    assert.deepEqual(attempted, [geminiModel], "Only Gemini attempted; no cascade to Codex");
+    // Because Gemini failed, active generation was NOT committed to 1
+    assert.equal(
+      getNativeCodexTurnActiveGeneration(turnBody, comboName),
+      0,
+      "Generation remains 0 on dispatch failure"
+    );
+    assert.equal(
+      getNativeCodexTurnPin(turnBody, comboName, 0)?.modelStr,
+      opusModel,
+      "Gen 0 pin remains Opus"
+    );
+  });
+
+  test("TTL expiry cleans up turn record and prevents memory leak", () => {
+    const mockBody = {
+      client_metadata: {
+        "x-codex-turn-metadata": JSON.stringify({
+          thread_id: "thread-ttl-test",
+          turn_id: "turn-ttl-test",
+        }),
+      },
+    };
+
+    pinNativeCodexTurn({
+      body: mockBody,
+      comboName,
+      target: {
+        kind: "model",
+        stepId: "s1",
+        executionKey: "ek1",
+        modelStr: opusModel,
+        provider: "antigravity",
+        providerId: null,
+        connectionId: "conn-ttl",
+        weight: 1,
+        label: null,
+      },
+      connectionId: "conn-ttl",
+    });
+
+    assert.ok(getNativeCodexTurnPin(mockBody, comboName));
+
+    // Advance Date.now past TTL_MS (45 minutes = 2_700_000 ms)
+    const origDateNow = Date.now;
+    try {
+      Date.now = () => origDateNow() + 46 * 60 * 1000;
+      // Prune is triggered on read
+      assert.equal(getNativeCodexTurnPin(mockBody, comboName), null, "Expired pin pruned");
+      assert.equal(
+        getNativeCodexTurnActiveGeneration(mockBody, comboName),
+        0,
+        "Expired turn record pruned"
+      );
+    } finally {
+      Date.now = origDateNow;
+    }
+  });
+});