Skip to content

fix(responses): carry WS dispatch override, pooled key rotation, usage accounting and Kiro refusal hop (B3) - #7389

Merged
lidge-jun merged 13 commits into
devfrom
fix/dispatch-usage-failover-2.90
Oct 19, 2026
Merged

lidge-jun merged 13 commits into
devfrom
fix/dispatch-usage-failover-2.90

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

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:

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

PR Taken Adapted / dropped
#7324 (vadymhimself) Both commits as-is (WS override, HTTP for dispatch-time reselection) —
#7323 (vadymhimself) Rotation helper passthrough-key-rotation.ts, tests, structure doc Adapted: the budget check moved into the helper (options object) and both passthrough credential moves are imported through it, so passthrough-dispatch.ts grows 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.
#7321 (vadymhimself) As-is —
#7336 (vadymhimself) As-is —
#7337 (vadymhimself) As-is Test conflict with #7336 in request-log-attempt-identity.test.ts resolved by keeping both cases.
#7330 (vadymhimself) Both commits as-is —
#7373 (hedging8563) Both commits (classifier, combo hop, fence, e2e tests, structure docs) Adapted (ratchet): its combos.test.ts case (+16 → 2000 lines, NEW_OVERSIZED) moved byte for byte into the new tests/codex-integration/combos-kiro-profile-hop.test.ts, registered in layout.json and test-layout-expected.json. Tightened (maintainer CHANGES_REQUESTED + review): the fence used the configured provider adapter; a kiro provider whose model is routed through openai-chat by modelAdapters would 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

  • Import via the rotation module: passthrough-key-rotation.ts re-exports recoverAutoPausedAccount so 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.
  • Kiro e2e regression does not pin same-provider sends: in the modelAdapters fixture, 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:

  • Unbounded key rotation: none. Rotation uses the existing pool selector, cools each key it leaves, and every rebuild draws on the shared send budget; exhaustion is checked before cooldown, so the genuine terminal 429 is preserved.
  • Rotation/cooldown on local or synthetic refusal: none. Non-replayable (synthetic) responses and distributed selections are excluded; local admission exceptions exit through their error boundary.
  • Double-counted or dropped sent legs: none. Sent legs without usage stay 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.json testRunnerBun), which was installed into a scratch dir because the host had 1.4.2/1.3.14.

  • bun run typecheck — pass
  • bun test --isolate on 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 fail
  • New Kiro modelAdapters regression: 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.ts all pass (repository scan 3.6s).
  • bun run structure:check — pass
  • bun run privacy:scan — pass
  • bun run test:changed — running on the final head (queued on the shared host test lock behind other worktrees); the result will be added here
  • Full bun 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.
  • Rebased onto origin/dev c120eea (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.
  • Pushed with --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)

File dev this branch
src/server/responses/passthrough-dispatch.ts 1996 1997
tests/codex-integration/combos.test.ts 1984 1984 (unchanged)
src/server/request-log.ts 1911 1920
src/server/responses/core-combo.ts 1344 1347
src/server/responses/request-transport.ts 1318 1322
src/server/messages-native.ts 1122 1133
src/combos/failover.ts 880 887
src/server/responses/fetch-helpers.ts 580 602

Checklist

  • Targets dev
  • No gui/, package.json, bun.lock or .github changes; no new dependencies
  • New test file registered in layout.json and test-layout-expected.json
  • structure/ docs updated (bun run structure:check passes)
  • Co-authored-by trailers for every carried author

vadymhimself and others added 13 commits October 20, 2026 06:07
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>
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner October 19, 2026 21:08
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T21:12:17.656588Z 1e7d626 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Oct 19, 2026
@coderabbitai

coderabbitai Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: Repository: lidge-jun/opencodex/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: d4b06b03-8cd3-41ba-aba2-530bb4ec105f
📥 Commits

Reviewing files that changed from the base of the PR and between c120eea and 1e7d626.

📒 Files selected for processing: 26
  • scripts/test-layout/layout.json
  • src/adapters/kiro-refusal.ts
  • src/combos/failover.ts
  • src/server/messages-native.ts
  • src/server/request-log.ts
  • src/server/responses/core-combo.ts
  • src/server/responses/fetch-helpers.ts
  • src/server/responses/passthrough-dispatch.ts
  • src/server/responses/passthrough-key-rotation.ts
  • src/server/responses/request-transport.ts
  • structure/providers/kiro.md
  • structure/transports/responses-failover.md
  • structure/transports/responses.md
  • tests/claude-integration/messages-native-oauth.test.ts
  • tests/codex-integration/combos-kiro-profile-hop.test.ts
  • tests/fixtures/test-layout-expected.json
  • tests/helpers/responses-core-source.ts
  • tests/oauth/generic-oauth-failover.test.ts
  • tests/providers/kiro/kiro-refusal.test.ts
  • tests/responses/responses-dispatch-receipt.test.ts
  • tests/responses/responses-native-key-cooldown.test.ts
  • tests/responses/responses-spend-anchor.test.ts
  • tests/server/server-kiro-refusal-e2e.test.ts
  • tests/usage/key-attribution.test.ts
  • tests/usage/request-log-attempt-identity.test.ts
  • tests/usage/request-log.test.ts
+--------------------------------------------------------+
| Maybe I am just like my mother. She's never satisfied. |
+--------------------------------------------------------+
  \
   \     /)/)
        ( '')
        /  |
      o(___)
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +421 to +424
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/server/request-log.ts
Comment on lines +1723 to +1726
// 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants