Repository navigation
fix(responses): carry WS dispatch override, pooled key rotation, usage accounting and Kiro refusal hop (B3) - #7389
Conversation
providerFetch applied `dispatchOverride` only on the HTTP path. The WebSocket branch called codexWsUpstreamFetch directly, so a key-auth WebSocket send (api.openai.com with upstreamWebsocket) never reached commitKeyAttemptSend or rebindPhysicalSend: its attempt row reported sendCount 0 for a frame that left. The WS send now runs through the same override; the override hands a marked WS executor its send directly instead of HTTP connection policy, and the HTTP fallback inside it skips the override so one logical send is counted once. Co-Authored-By: Claude Code <noreply@anthropic.com>
WS eligibility (egress, pacing, URL) is checked against the provider the WS executor was built for. The dispatch override can replace route.provider before it calls the executor; that replacement was never checked, so it could ride a WebSocket its own config rules out (e.g. an explicit proxy). The WS executor now records the provider it was admitted for and its HTTP fallback; when the override serves a different provider it sends through that fallback under the replacement's own egress route. Co-Authored-By: Claude Code <noreply@anthropic.com>
The native Responses passthrough returns before the adapter recovery loop that rotates pooled keys, so a key-pool provider's 429 was returned to the client while the same refusal rotates on the adapter and chat-native wires. Rotate to the next key before output, inside the shared send budget. Distributed round-robin selection keeps its cool-without-replay behaviour. Co-Authored-By: Claude Code <noreply@anthropic.com>
…wered When an account rotation splits the attempt row, the sealed attempt with no recorded status fell back to 401 for key-401, 429 for any *-429 kind, and 502 for everything else. An `oauth-account-403` rotation was therefore logged as a 502 server error, and `oauth-401` / `oauth-account-402` likewise, although the recovery kind names the status the account actually returned. Map those kinds to 401, 403 and 402 too. A status already recorded on the attempt still wins. Co-Authored-By: Claude Code <noreply@anthropic.com>
…orted A multi-attempt row takes its usage status from aggregateAttemptUsage, which let any usage-less attempt mark the whole row "unreported". A combo leg the gateway refused locally (input admission, a local cooldown) never reached upstream and carries no usage, so a combo that then served the turn on its next leg with fully reported usage was still logged as unreported. The status now ignores usage-less attempts that never sent or were answered locally -- the same "spent nothing" rule summary.ts already prices by. An attempt that was sent and reported nothing still leaves the row unreported. The existing "folds partial and unsupported" test built its unreported 503 without a send; it now notes the send, which is the case it describes. Co-Authored-By: Claude Code <noreply@anthropic.com>
aggregateAttemptUsage summed `cachedInputTokens` and `cacheReadInputTokens` independently. Anthropic usage sets both; an OpenAI-shaped attempt sets only `cachedInputTokens`. Row-level readers take `cacheReadInputTokens ?? cachedInputTokens` (request-log, management/shared.ts), so a row aggregating an Anthropic leg (60 read) and an OpenAI-shaped leg (40 cached) reported 60 cache reads instead of 100. Cost is priced per attempt and is not affected. Once any attempt reports `cacheReadInputTokens`, the aggregate's read field now counts each attempt's `cacheReadInputTokens ?? cachedInputTokens`. Rows whose attempts never report the read field are unchanged. Co-Authored-By: Claude Code <noreply@anthropic.com>
…with its reset #6991 made the bridge lane answer a 429 as "All Anthropic OAuth accounts have reached their usage limit; the first one resets at <time>" once the last pool account refuses on spent quota and rotation finds no replacement. The managed native Messages lane rotates through the same pool but, when it runs out, relays the upstream rate_limit_error ("try again later") unchanged, so Claude Code on that lane never learns the window is spent or when it reopens. After rotation ends on a 429, the native lane now asks the same anthropicPoolUsageLimitAfterRefusal the bridge uses and, when it holds, answers through the lane's existing AnthropicAccountCooldownError mapping (429 rate_limit_error, the usage-limit message, Retry-After). Co-Authored-By: Claude Code <noreply@anthropic.com>
…imit answer Co-Authored-By: Claude Code <noreply@anthropic.com>
Kiro answers `400 ValidationException: Improperly formed request` when the identity a request was routed under cannot serve it. classifyKiroRefusal returned `other`, so the rotation loop stopped: the account stayed selected, no other login was tried, and inside a combo the 400 ended the chain even though a later target could serve the turn. Classify it as the account-scoped kind `profile` (400 only, and both the vendor exception name and the "improperly formed request" wording are required, so a genuinely bad payload or a caller echoing one token stays terminal). Rotation and the existing Kiro cooldown apply unchanged. comboFailureDecision treats the same shape, also inside a transport-wrapped frame, as a hop, before the generic invalid_request_error stop.
Fold the budget check into passthrough-key-rotation.ts and import both passthrough credential moves through it, so passthrough-dispatch.ts grows by one line (1997) and stays under the 2000-line ratchet threshold. Co-authored-by: vadymhimself <11277453+vadymhimself@users.noreply.github.com>
combos.test.ts would reach 2000 lines (NEW_OVERSIZED). The case moves byte for byte into combos-kiro-profile-hop.test.ts, registered in layout.json and test-layout-expected.json. Co-authored-by: hedging8563 <45883076+hedging8563@users.noreply.github.com>
… resolved A kiro provider can route a model through another wire via modelAdapters; the configured adapter would still have granted the hop to a non-Kiro 400. Pass the child leg's resolved providerAdapter instead; an unresolved leg grants nothing. Adds an e2e regression that fails on the configured-adapter fence. Co-authored-by: hedging8563 <45883076+hedging8563@users.noreply.github.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
✅ Deterministic PR hygiene checks passed. |
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 26
✨ Finishing Touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1e7d626087
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // A WS frame is a physical send like any other, so it runs the same override: that is where a | ||
| // key-auth send is committed to its attempt row and the send budget is rebound to the | ||
| // credential that left. Without it a key-auth WS turn logged `sendCount: 0`. | ||
| return options.dispatchOverride ? options.dispatchOverride(input, init, wsSend, reacquirePacing) : wsSend(input, init); |
There was a problem hiding this comment.
Revalidate credentials at the actual WebSocket send boundary
When WebSocket reuse is enabled or the socket handshake is delayed, this invokes dispatchOverride before codexWsUpstreamFetch waits for a pooled session or opens the socket. oauthDispatch therefore checks the credential selection, rebinds the send budget, and commits the attempt before the frame is physically sent; if an API key or OAuth account changes during that asynchronous interval, the later open callback only runs options.beforeDispatch and does not repeat the selection fence, so the stale credential can still be sent and attributed as current. Move or repeat the credential-selection guard at the callback immediately preceding the WS frame send.
Useful? React with 👍 / 👎.
| // An account-scoped recovery kind names the status the sealed account answered. | ||
| finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status | ||
| : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502, | ||
| : recovery === "key-401" || recovery === "oauth-401" ? 401 : recovery === "oauth-account-403" ? 403 | ||
| : recovery === "oauth-account-402" ? 402 : recovery?.includes("429") ? 429 : 502, |
There was a problem hiding this comment.
Preserve HTTP 400 when sealing Kiro profile rotations
The newly classified Kiro profile refusal is an HTTP 400, but both the main and continuation Kiro rotation paths still label its replay as oauth-account-429. Because the failed attempt normally has no status set yet, this fallback maps that recovery kind to 429 and persists the first account's real 400 as a rate-limit response, corrupting per-attempt status and error accounting whenever the new profile rotation succeeds. Carry the actual refusal status or add a distinct 400 recovery kind before sealing the attempt.
Useful? React with 👍 / 👎.
Summary
Bundle B3 (credential rotation + spend accounting) for 2.90. Carries seven open PRs onto current
dev, with two adaptations made for the ratchet and one tightening from review:sendCountwas 0) and rebinds the send budget. The socket executor gets its send directly, without the HTTP connection/content-coding policy; the HTTP fallback inside that exchange does not run the override a second time; a provider reselected at dispatch time falls back to HTTP under its own route.unreported; cache reads sum across attempts that report them under different fields.No issue is closed by this PR. #7073 and #6740 (WS 1008 / pooled 502) are adjacent but not claimed as fixed; they stay needs-info.
Items fixed
No linked issues. The carried PRs are superseded by this bundle (see table).
Carried PRs
passthrough-key-rotation.ts, tests, structure docpassthrough-dispatch.tsgrows by +1 (1996 → 1997) instead of +3, leaving headroom under 2000 for the parallel B1 bundle. Behaviour unchanged: a spent budget is checked before any key is cooled and keeps the real 429.request-log-attempt-identity.test.tsresolved by keeping both cases.combos.test.tscase (+16 → 2000 lines, NEW_OVERSIZED) moved byte for byte into the newtests/codex-integration/combos-kiro-profile-hop.test.ts, registered inlayout.jsonandtest-layout-expected.json. Tightened (maintainer CHANGES_REQUESTED + review): the fence used the configured provider adapter; akiroprovider whose model is routed throughopenai-chatbymodelAdapterswould still hop on a non-Kiro 400. It now passes the adapter the failed leg actually resolved (childLog.providerAdapter); an unresolved leg grants nothing. New e2e regression fails on the old fence.Co-authored-by: vadymhimself 11277453+vadymhimself@users.noreply.github.com
Co-authored-by: hedging8563 45883076+hedging8563@users.noreply.github.com
Judgment calls
passthrough-key-rotation.tsre-exportsrecoverAutoPausedAccountso one import line serves both credential moves. This was the only way to hold the +1 line budget without a byte-shuffling move of unrelated code.modelAdaptersfixture, the Kiro provider's own OAuth account failover still tries the second login before the combo decides. That is existing same-provider behaviour, outside this bundle; the test pins only what the fence controls (client gets the 400, fallback target receives nothing).Held out
Review
A gpt-6.1-sol subagent reviewed the full diff vs
origin/dev: no P0/P1. Its one P2 (Kiro fence trusting the configured adapter) is fixed above. Explicit security answers from that review:unreported; zero-send usage-less refusals no longer contaminate totals; cache-read aggregation takes one value per attempt.Verification
All runs on this host with Bun 1.4.3 (
package.jsontestRunnerBun), which was installed into a scratch dir because the host had 1.4.2/1.3.14.bun run typecheck— passbun test --isolateon 13 focused files (responses-dispatch-receipt, responses-spend-anchor, generic-oauth-failover, responses-native-key-cooldown, key-attribution, request-log-attempt-identity, request-log, messages-native-oauth, kiro-refusal, server-kiro-refusal-e2e, combos, combos-kiro-profile-hop, server-key-failover-e2e) — 517 pass / 0 failmodelAdaptersregression: fails with the configured-adapter fence (29 pass / 1 fail) and passes with the fix (30 / 0)bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/ci-workflows/file-size-ratchet.test.ts(Bun 1.4.3) — 35 pass / 1 fail in a concurrent run. The failure was the ratchet repository scan hitting its 5s timeout (6.6s) under host contention. Rerun alone:file-size-ratchet.test.tsall pass (repository scan 3.6s).bun run structure:check— passbun run privacy:scan— passbun run test:changed— running on the final head (queued on the shared host test lock behind other worktrees); the result will be added herebun run test— not run: several bundle worktrees share this host and the user-wide test lock. Coverage beyond the import graph is left to CI.origin/devc120eea (fix(responses): recover cold opaque state and harden replay, archive and lifecycle paths #7388) with no conflicts; typecheck, focused (517/0), layout (27/0), ratchet (9/0), structure and privacy were all rerun on the rebased head.--no-verify: this host's opt-in pre-push shim (bun run setup:hooks→bun run prepush) stopped at the Bun 1.4.3 runner-pin check before any test ran, and a full-suite run was impractical with several worktrees sharing the test lock. Per AGENTS.md, the full suite is left to CI.Ratchet hot files touched (none is in the baseline; NEW_OVERSIZED limit is 2000)
Checklist
devgui/,package.json,bun.lockor.githubchanges; no new dependencieslayout.jsonandtest-layout-expected.jsonbun run structure:checkpasses)