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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 5c5452f096..2a160c97c2 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/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index d2f3b3e55b..ea136a9e16 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. 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).", @@ -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": 2517, "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": 1273, + "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.", @@ -713,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/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/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/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/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/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 9582af9fe6..406cbbd05b 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; @@ -505,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 = { @@ -524,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. */ @@ -1166,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). @@ -1175,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; @@ -1183,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, @@ -1225,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); } /** @@ -1236,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(); @@ -1304,6 +1321,8 @@ export class AntigravityExecutor extends BaseExecutor { urlIndex, retryAttemptsByUrl, fallbackCount, + physicalSendCounter, + correlationId, }); if (outcome.action === "return") return outcome.result; @@ -1353,6 +1372,8 @@ export class AntigravityExecutor extends BaseExecutor { urlIndex, retryAttemptsByUrl, fallbackCount, + physicalSendCounter, + correlationId, } = ctx; const { response, finalHeaders } = await sendAntigravityRequest( @@ -1365,7 +1386,9 @@ export class AntigravityExecutor extends BaseExecutor { stream, signal, log, - retryAttemptsByUrl[urlIndex] + retryAttemptsByUrl[urlIndex], + physicalSendCounter, + correlationId ); let retryMs: number | null = null; @@ -1616,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/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/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/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/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/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/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.ts b/open-sse/handlers/chatCore.ts index 098eb3a26d..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})`); @@ -2494,8 +2508,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/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/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index bd6f1b68aa..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"), @@ -2912,11 +2908,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) { @@ -3212,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 = [ @@ -3250,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 = {}; + 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/services/accountFallback.ts b/open-sse/services/accountFallback.ts index e511a4429c..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"; @@ -260,6 +261,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) @@ -342,6 +345,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 @@ -1009,15 +1014,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) ); } @@ -1733,6 +1744,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, @@ -2047,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(); @@ -2200,6 +2213,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/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/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 184e357c99..d43ffd51c6 100644 --- a/open-sse/services/combo/attemptLoopTypes.ts +++ b/open-sse/services/combo/attemptLoopTypes.ts @@ -92,13 +92,17 @@ 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 * 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 debe92d2d7..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 { @@ -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; @@ -461,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 @@ -906,7 +929,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 +979,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 +1176,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/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/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/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/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/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/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/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/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index a70100964b..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" @@ -829,7 +855,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/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index cd1310bffd..a653028583 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 @@ -128,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) { @@ -225,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", { @@ -352,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", { @@ -402,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", { @@ -422,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" }, }); } @@ -480,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", { @@ -589,7 +641,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/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/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/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..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; @@ -1906,6 +1982,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 +2046,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 +2333,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/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/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/(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/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/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/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/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/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/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/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts
index 0764e10335..f3d2a814e2 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";
@@ -374,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> {
@@ -450,7 +460,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 +720,7 @@ export async function createApiKey(
     noLog: false,
     allowUsageCommand: false,
     createdAt: now,
+    expiresAt: options.expiresAt ?? null,
     scopes,
   };
 
@@ -727,7 +738,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/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts
index 774ab27e8d..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 {
@@ -65,29 +87,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
@@ -871,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()
   );
@@ -1071,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 ||
@@ -1131,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/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/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/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/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/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/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/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/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/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts index 5e169ab9a0..3655ed885c 100644 --- a/src/shared/network/remoteImageFetch.ts +++ b/src/shared/network/remoteImageFetch.ts @@ -146,6 +146,16 @@ async function readResponseBuffer(response: Response, maxBytes: number) { return Buffer.concat(chunks, totalBytes); } +// #13883: test-only escape hatch for `pinDns: true` callers that have no `fetchImpl` seam +// of their own (imageGeneration.ts / imageUpscale/shared.ts). `createPinnedFetch` opens a +// real undici connection, bypassing a test's monkeypatched `globalThis.fetch`; setting this +// override lets such a test keep exercising its mock instead of a real network attempt. +// Production callers never call the setter, so `pinDns` still pins for real in production. +let pinnedFetchTestOverride: typeof fetch | undefined; +export function setPinnedFetchTestOverride(fetchImpl: typeof fetch | undefined): void { + pinnedFetchTestOverride = fetchImpl; +} + export async function fetchRemoteMedia( input: string | URL, options: RemoteMediaFetchOptions = {} @@ -171,6 +181,7 @@ export async function fetchRemoteMedia( const addresses = await assertHostnameResolvesPublic(currentUrl, guard, lookup); const fetchImpl = injectedFetch ?? + pinnedFetchTestOverride ?? (pinDns && addresses.length ? createPinnedFetch(addresses[0].address, addresses[0].family) : fetch); 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 = [ /\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/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/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/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/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/src/sse/handlers/chat/comboTargetKeyPolicy.ts b/src/sse/handlers/chat/comboTargetKeyPolicy.ts index 7bc441056c..81bc7f07cc 100644 --- a/src/sse/handlers/chat/comboTargetKeyPolicy.ts +++ b/src/sse/handlers/chat/comboTargetKeyPolicy.ts @@ -7,8 +7,11 @@ * inner target so #9057 holds. */ +import { isModelBlockedByPatterns } from "@/lib/db/apiKeys"; + export type ComboTargetKeyPolicyInfo = { allowedModels?: string[] | null; + blockedModels?: string[] | null; disableNonPublicModels?: boolean | null; modelAccessMode?: string | null; }; @@ -37,9 +40,13 @@ export async function comboTargetPassesKeyModelPolicy(opts: { if (!apiKey || !apiKeyInfo) return true; const hasModelRestrictions = - Boolean(apiKeyInfo.allowedModels?.length) || apiKeyInfo.disableNonPublicModels === true; + Boolean(apiKeyInfo.allowedModels?.length) || + Boolean(apiKeyInfo.blockedModels?.length) || + apiKeyInfo.disableNonPublicModels === true; if (!hasModelRestrictions) return true; + if (await isModelBlockedByPatterns(apiKeyInfo.blockedModels, targetModelStr)) return false; + if (allowListCoversRequestedCombo(apiKeyInfo.allowedModels, requestedModelStr)) { return true; } 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/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/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/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); 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/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/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/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); +}); diff --git a/tests/unit/api-key-policy.test.ts b/tests/unit/api-key-policy.test.ts index eff769271f..e968d8bffa 100644 --- a/tests/unit/api-key-policy.test.ts +++ b/tests/unit/api-key-policy.test.ts @@ -517,6 +517,37 @@ test("enforceApiKeyPolicy rejects disallowed models and exhausted budgets", asyn assert.match(await readErrorMessage(overBudget.rejection), /Daily budget exceeded/); }); +test("enforceApiKeyPolicy applies blockedModels in all-access mode", async () => { + 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/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/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" + ); +}); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 1d5a4eea21..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(); @@ -1832,6 +1895,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", 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/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)}` + ); + } +}); 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)"); +}); 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/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-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); +}); 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"); +}); 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); +}); 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);
+    });
+  });
+});
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");
+  });
+});
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/
+    );
+  });
+});
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));
diff --git a/tests/unit/fal-image-generation-default.test.ts b/tests/unit/fal-image-generation-default.test.ts
index 91b9203150..20fc059836 100644
--- a/tests/unit/fal-image-generation-default.test.ts
+++ b/tests/unit/fal-image-generation-default.test.ts
@@ -20,10 +20,11 @@ process.on("exit", () => {
 });
 
 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/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/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"
+  );
+});
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/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/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/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/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"
+  );
+});
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/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;
+    }
+  });
+});
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")
+  );
+});
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/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/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
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 });
+});
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);
+});
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/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();
+  }
+});
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: [
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/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"
   );
 });
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.
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/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/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);
+  });
+});
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);
+});
diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts
index cf44d1b798..7073e3b969 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".
@@ -1622,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"
+  );
+});
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/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",
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",