Skip to content

fix(sidecars): rotate generic OAuth accounts on 429 (#2568) - #2607

Merged
lidge-jun merged 3 commits into
devfrom
codex/2568-sidecar-oauth-429
Aug 25, 2026
Merged

lidge-jun merged 3 commits into
devfrom
codex/2568-sidecar-oauth-429

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

Completes the sidecar half of #2568. Generic OAuth account rotation on 429 already worked on the main response path, but the image bridge and the web-search loop each received an on429 hook that only called rotateProviderTransportOn429 — key-pool-only, since hasKeyPoolFailover returns false for authMode: "oauth". So a provider with three logged-in accounts recovered from a 429 on a normal turn and failed on an image or web-search turn.

The blocking constraint was that on429 is synchronous (ProviderAdapter | null) while OAuth rotation needs failoverAccountSnapshot, which may refresh a token. The contract is widened to ProviderAdapter | null | Promise<ProviderAdapter | null> and both loops await it; every existing synchronous key-pool caller keeps working unchanged.

Both injection sites now share one hook. It tries the key pool first and unconditionally, so an API-key provider behaves exactly as before, and only falls through to the OAuth roster when the key pool declines. The rotation swaps in the FULL credential snapshot rather than a bare bearer — Kiro carries routing metadata and Antigravity pairs an account-matched projectId with its token, so a token-only swap would mix one account's credential with another's routing data.

Codex and Anthropic stay excluded through isGenericFailoverProvider: their pools own quota scopes, probe leases and affinity that this must not reimplement. A single-account provider is a strict no-op, and the whole path stays behind oauthAccountFailover.enabled.

Verification

Merged onto dev at f392e02eb and verified there, not on the branch's own base:

bun x tsc --noEmit                                          exit 0
bun test tests/generic-oauth-failover.test.ts \
         tests/images/loop.test.ts tests/web-search.test.ts  95 pass / 0 fail
bun test tests/generic-oauth-failover.test.ts               10 pass / 0 fail

Falsification, all four directions:

  • image loop, await removed → fails, expected 2 fetches received 1
  • web-search loop, await removed → fails, expected 200 received 502
  • web-search site given a divergent key-pool-only hook → fails the hook-identity assertion (expected 1 distinct hook, received 2)
  • knob gate removed from the shared hook → fails the gate assertion

The last two come from a test added on review. The two loop tests stub on429 and therefore never reach the OAuth branch; the hook is a closure over request-local state (route, genericFailoverAccountId, genericFailovers) and is not importable. The added assertions are structural, in the same spirit as the route-inventory contract — they cannot prove rotation works, but they do catch the regression that actually threatens this feature: one sidecar silently keeping the key-pool-only hook while the other gets the OAuth-aware one. That is exactly how the gap arose in the first place.

Checklist

  • Targets dev
  • Focused regression tests near the existing tests for the subsystem
  • Opt-in default unchanged; single-account and key-auth paths are strict no-ops
  • Credential handling reviewed: full snapshot per account, no token logging, no cross-account mixing

Closes part of #2568.

Summary by CodeRabbit

  • New Features

    • Added asynchronous provider failover during rate-limit recovery, supporting credential refresh and adapter rebuilding before retrying.
    • Centralized failover handling across image and web-search requests.
    • Added bounded OAuth account failover when provider rotation is unavailable.
  • Bug Fixes

    • Improved rate-limit retry behavior by ensuring refreshed providers are ready before requests continue.
  • Tests

    • Added coverage for provider rotation order, OAuth eligibility, and asynchronous failover completion.

Widen the image and web-search on429 hooks to accept an awaitable adapter and await them at both consumption sites. Existing synchronous key-pool callbacks remain source-compatible and are still exercised unchanged.

Option (a) is required because failoverAccountSnapshot may refresh a token asynchronously. Awaiting the hook preserves full snapshot atomicity (including Antigravity projectId and Kiro routing metadata) without eager refresh work, blocking tricks, or a fire-and-forget race. The shared sidecar callback tries key-pool rotation first, then bounded generic OAuth rotation, retaining the existing OpenAI/Anthropic exclusions and single-account no-op.
…ey-pool-only

The two loop tests only prove the loops AWAIT the hook; they stub it and never
reach the OAuth branch core.ts injects. The hook is a closure over request-local
state, so it is not importable, and driving it end to end means a full sidecar
request against a stubbed provider.

These are structural assertions on the source, like the route-inventory contract:
they cannot prove rotation works, but they catch the regression that actually
threatens this feature - one sidecar keeping a key-pool-only hook while the other
gets the OAuth-aware one. That divergence is how the gap arose in the first place.

Falsified both ways: inlining a key-pool-only hook at the web-search site fails
the identity assertion (expected 1 distinct hook, received 2); removing the knob
gate fails the gate assertion.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 25, 2026 19:41
@lidge-jun
lidge-jun merged commit 8725087 into dev Aug 25, 2026
6 checks passed
@lidge-jun
lidge-jun deleted the codex/2568-sidecar-oauth-429 branch August 25, 2026 19:41
@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 Aug 25, 2026
@coderabbitai

coderabbitai Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a52d815c-eb63-405c-8312-216b98d775c7

📥 Commits

Reviewing files that changed from the base of the PR and between f392e02 and a1640b6.

📒 Files selected for processing (6)
  • src/images/loop.ts
  • src/server/responses/core.ts
  • src/web-search/loop.ts
  • tests/generic-oauth-failover.test.ts
  • tests/images/loop.test.ts
  • tests/web-search.test.ts

📝 Walkthrough

Walkthrough

Image and web-search 429 handling now supports asynchronous adapter rotation. Both bridges use shared sidecar failover logic that can rotate transport keys, fail over generic OAuth accounts, refresh credentials, rebuild adapters, and restore reasoning replay scope.

Changes

Sidecar provider failover

Layer / File(s) Summary
Async 429 rotation contracts
src/images/loop.ts:261-265, src/images/loop.ts:576, src/web-search/loop.ts:307-311, src/web-search/loop.ts:513
Both loop contracts accept synchronous or asynchronous on429 callbacks. The 429 retry paths await the callback before continuing.
Shared sidecar failover handler
src/server/responses/core.ts:199, src/server/responses/core.ts:4198-4244, src/server/responses/core.ts:4335, src/server/responses/core.ts:4403
rotateSidecarProviderOn429 rotates transport keys first, then applies bounded OAuth account failover, refreshes account data, rebuilds the adapter, and rebinds reasoning replay scope. Image and web-search bridges use the shared handler.
Failover wiring and async tests
tests/generic-oauth-failover.test.ts:2, tests/generic-oauth-failover.test.ts:116-171, tests/images/loop.test.ts:655, tests/images/loop.test.ts:680-682, tests/web-search.test.ts:772, tests/web-search.test.ts:828-831
Tests verify shared wiring, rotation ordering, OAuth gating, complete account snapshots, and awaited adapter refresh in both loops.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ImageBridge
  participant WebSearchBridge
  participant rotateSidecarProviderOn429
  participant ProviderAdapter
  ImageBridge->>rotateSidecarProviderOn429: handle 429
  WebSearchBridge->>rotateSidecarProviderOn429: handle 429
  rotateSidecarProviderOn429->>ProviderAdapter: rotate transport or rebuild OAuth adapter
  ProviderAdapter-->>rotateSidecarProviderOn429: replacement adapter
  rotateSidecarProviderOn429-->>ImageBridge: awaited adapter result
  rotateSidecarProviderOn429-->>WebSearchBridge: awaited adapter result
Loading

Suggested reviewers: ingwannu, harryzhou2000

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/2568-sidecar-oauth-429

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: a1640b6b40

ℹ️ 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".

const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
genericFailoverAccountId = nextAccountId;
genericFailovers += 1;
route.provider = { ...route.provider, apiKey: snapshot.accessToken };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-resolve the rotated account's provider transport

When two github-copilot OAuth accounts have different stored endpoints.api origins, this swaps only the bearer while preserving the baseUrl resolved for the original account at src/server/responses/core.ts:2821-2825. Because failoverAccountSnapshot also omits the account-scoped apiBaseUrl (src/oauth/index.ts:53-66), the retry sends the replacement account's token to the previous account's Copilot endpoint and can fail with 401 instead of recovering. Include the validated Copilot origin in the failover snapshot and run the replacement through resolveProviderTransport before rebuilding the adapter.

AGENTS.md reference: src/AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

lidge-jun added a commit that referenced this pull request Aug 26, 2026
…(#2568d) (#2640)

* feat(oauth): activate multi-account 429 failover on account presence (#2568d)

The mechanism shipped in #2590/#2607/#2608 but stayed gated behind
oauthAccountFailover.enabled, which is false for every install that never
edited its config — so the workflow #2568 actually reported (three xAI
accounts logged in, a 429 on the active one) was still broken by default.
The owner has now settled the escalation recorded in the wp7 audit response:
always on.

Presence is the activation signal, not an unconditional true. The predicate
also decides whether a runTurn stream is wrapped in preflightRunTurnFailover,
and that wrapper can never rotate for a single-account install, so an
unconditional true would add a preflight for users who get nothing from it.
Two or more eligible accounts is the same consent rule hasKeyPoolFailover
already applies to a 2+ key pool. Explicit config still wins, now at two
levels: providers.<name>.oauthAccountFailover beats the global knob, and both
beat presence.

Default-off was hiding three defects in the rotation path that presence-driven
activation would have handed to every multi-account user:

- Copilot pins its bearer to an account-scoped regional origin. core.ts pairs
  them on the initial resolve, but OAuthAccessSnapshot did not carry
  apiBaseUrl, so a rotation sent account B's token to account A's host.
  resolveGithubCopilotTransport fails closed to the canonical host, which is
  why this was misrouting rather than a leak — it was still wrong.
- Antigravity rotation wrote project only when the new snapshot had one, so a
  project-less account inherited the FAILED account's project. That account is
  reachable: refresh tolerates project discovery failing.
- The presence check would have run loadAuthStore on paths that never see a
  429, and that function chmods, reads, parses and normalizes the whole store
  every call.

The first two shared a root cause: three rotation sites each inlined the same
credential swap, and the duplication is what let the paired metadata drift.
They now go through one applyFailoverSnapshot helper that assembles the whole
identity or refuses — a cloud-code-assist snapshot with no project aborts the
rotation rather than mixing two accounts. Presence is answered from a
TTL-bounded per-provider count, invalidated on rotation; it caches a number,
never a credential.

Separately, upsertOAuthProvider rebuilds the provider row from the registry
preset on every login. Without preservation the sequence was: operator sets
enabled false, operator adds a second account, and that one action both
deletes the opt-out and creates the quorum that turns rotation on.

Tests: activation matrix (no config, global both ways, per-provider both ways,
reauth-flagged second account, cache TTL), structural contracts pinning that
the bearer is written in exactly one place and that the helper fails closed,
an explicit-opt-out e2e case, and an incremental SSE read proving the first
delta reaches the client before completion. Both new contracts were driven
red: forcing preflight to buffer the whole turn fails the delivery test at its
5s bound, and disabling the preservation branch fails both upsert cases.

Closes #2568

* fix(gui): translate the alias catalog keys and unbreak the locale parity parser

The gates job was red on dev before this branch existed, on four locale tests
with two unrelated causes.

Three of them read a catalog by regex anchored at "^\s*", but these files pack
several entries onto one line. The parser therefore saw only the first entry
per line and reported "models.newBadge" missing from every non-English
locale — a key that is present in all of them. The catalogs were identical;
the check was not reading them. Dropping the line anchor fixes all three, and
the module-level check in i18n-locales.test.ts already agreed the key sets
match, which is what made the phantom visible.

The fourth was real: #2463 landed thirteen alias keys with the English string
copied into all eight locales. They are now translated. "models.aliasAuto" is
the exception and joins the intentional-English allowlist, because "auto" is
the same word in French and labels a machine-derived alias source rather than
prose.

gui: bun test 994 pass, 0 fail (was 990/4).
tarunravi pushed a commit to tarunravi/opencodex that referenced this pull request Sep 14, 2026
…idge-jun#2607)

* fix(sidecars): rotate generic OAuth accounts on 429

Widen the image and web-search on429 hooks to accept an awaitable adapter and await them at both consumption sites. Existing synchronous key-pool callbacks remain source-compatible and are still exercised unchanged.

Option (a) is required because failoverAccountSnapshot may refresh a token asynchronously. Awaiting the hook preserves full snapshot atomicity (including Antigravity projectId and Kiro routing metadata) without eager refresh work, blocking tricks, or a fire-and-forget race. The shared sidecar callback tries key-pool rotation first, then bounded generic OAuth rotation, retaining the existing OpenAI/Anthropic exclusions and single-account no-op.

* test(sidecars): pin the shared on429 hook so neither sidecar drifts key-pool-only

The two loop tests only prove the loops AWAIT the hook; they stub it and never
reach the OAuth branch core.ts injects. The hook is a closure over request-local
state, so it is not importable, and driving it end to end means a full sidecar
request against a stubbed provider.

These are structural assertions on the source, like the route-inventory contract:
they cannot prove rotation works, but they catch the regression that actually
threatens this feature - one sidecar keeping a key-pool-only hook while the other
gets the OAuth-aware one. That divergence is how the gap arose in the first place.

Falsified both ways: inlining a key-pool-only hook at the web-search site fails
the identity assertion (expected 1 distinct hook, received 2); removing the knob
gate fails the gate assertion.
tarunravi pushed a commit to tarunravi/opencodex that referenced this pull request Sep 14, 2026
…(#2568d) (lidge-jun#2640)

* feat(oauth): activate multi-account 429 failover on account presence (#2568d)

The mechanism shipped in lidge-jun#2590/lidge-jun#2607/lidge-jun#2608 but stayed gated behind
oauthAccountFailover.enabled, which is false for every install that never
edited its config — so the workflow lidge-jun#2568 actually reported (three xAI
accounts logged in, a 429 on the active one) was still broken by default.
The owner has now settled the escalation recorded in the wp7 audit response:
always on.

Presence is the activation signal, not an unconditional true. The predicate
also decides whether a runTurn stream is wrapped in preflightRunTurnFailover,
and that wrapper can never rotate for a single-account install, so an
unconditional true would add a preflight for users who get nothing from it.
Two or more eligible accounts is the same consent rule hasKeyPoolFailover
already applies to a 2+ key pool. Explicit config still wins, now at two
levels: providers.<name>.oauthAccountFailover beats the global knob, and both
beat presence.

Default-off was hiding three defects in the rotation path that presence-driven
activation would have handed to every multi-account user:

- Copilot pins its bearer to an account-scoped regional origin. core.ts pairs
  them on the initial resolve, but OAuthAccessSnapshot did not carry
  apiBaseUrl, so a rotation sent account B's token to account A's host.
  resolveGithubCopilotTransport fails closed to the canonical host, which is
  why this was misrouting rather than a leak — it was still wrong.
- Antigravity rotation wrote project only when the new snapshot had one, so a
  project-less account inherited the FAILED account's project. That account is
  reachable: refresh tolerates project discovery failing.
- The presence check would have run loadAuthStore on paths that never see a
  429, and that function chmods, reads, parses and normalizes the whole store
  every call.

The first two shared a root cause: three rotation sites each inlined the same
credential swap, and the duplication is what let the paired metadata drift.
They now go through one applyFailoverSnapshot helper that assembles the whole
identity or refuses — a cloud-code-assist snapshot with no project aborts the
rotation rather than mixing two accounts. Presence is answered from a
TTL-bounded per-provider count, invalidated on rotation; it caches a number,
never a credential.

Separately, upsertOAuthProvider rebuilds the provider row from the registry
preset on every login. Without preservation the sequence was: operator sets
enabled false, operator adds a second account, and that one action both
deletes the opt-out and creates the quorum that turns rotation on.

Tests: activation matrix (no config, global both ways, per-provider both ways,
reauth-flagged second account, cache TTL), structural contracts pinning that
the bearer is written in exactly one place and that the helper fails closed,
an explicit-opt-out e2e case, and an incremental SSE read proving the first
delta reaches the client before completion. Both new contracts were driven
red: forcing preflight to buffer the whole turn fails the delivery test at its
5s bound, and disabling the preservation branch fails both upsert cases.

Closes lidge-jun#2568

* fix(gui): translate the alias catalog keys and unbreak the locale parity parser

The gates job was red on dev before this branch existed, on four locale tests
with two unrelated causes.

Three of them read a catalog by regex anchored at "^\s*", but these files pack
several entries onto one line. The parser therefore saw only the first entry
per line and reported "models.newBadge" missing from every non-English
locale — a key that is present in all of them. The catalogs were identical;
the check was not reading them. Dropping the line anchor fixes all three, and
the module-level check in i18n-locales.test.ts already agreed the key sets
match, which is what made the phantom visible.

The fourth was real: lidge-jun#2463 landed thirteen alias keys with the English string
copied into all eight locales. They are now translated. "models.aliasAuto" is
the exception and joins the intentional-English allowlist, because "auto" is
the same word in French and labels a machine-derived alias source rather than
prose.

gui: bun test 994 pass, 0 fail (was 990/4).
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…idge-jun#2607)

* fix(sidecars): rotate generic OAuth accounts on 429

Widen the image and web-search on429 hooks to accept an awaitable adapter and await them at both consumption sites. Existing synchronous key-pool callbacks remain source-compatible and are still exercised unchanged.

Option (a) is required because failoverAccountSnapshot may refresh a token asynchronously. Awaiting the hook preserves full snapshot atomicity (including Antigravity projectId and Kiro routing metadata) without eager refresh work, blocking tricks, or a fire-and-forget race. The shared sidecar callback tries key-pool rotation first, then bounded generic OAuth rotation, retaining the existing OpenAI/Anthropic exclusions and single-account no-op.

* test(sidecars): pin the shared on429 hook so neither sidecar drifts key-pool-only

The two loop tests only prove the loops AWAIT the hook; they stub it and never
reach the OAuth branch core.ts injects. The hook is a closure over request-local
state, so it is not importable, and driving it end to end means a full sidecar
request against a stubbed provider.

These are structural assertions on the source, like the route-inventory contract:
they cannot prove rotation works, but they catch the regression that actually
threatens this feature - one sidecar keeping a key-pool-only hook while the other
gets the OAuth-aware one. That divergence is how the gap arose in the first place.

Falsified both ways: inlining a key-pool-only hook at the web-search site fails
the identity assertion (expected 1 distinct hook, received 2); removing the knob
gate fails the gate assertion.
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…(#2568d) (lidge-jun#2640)

* feat(oauth): activate multi-account 429 failover on account presence (#2568d)

The mechanism shipped in lidge-jun#2590/lidge-jun#2607/lidge-jun#2608 but stayed gated behind
oauthAccountFailover.enabled, which is false for every install that never
edited its config — so the workflow lidge-jun#2568 actually reported (three xAI
accounts logged in, a 429 on the active one) was still broken by default.
The owner has now settled the escalation recorded in the wp7 audit response:
always on.

Presence is the activation signal, not an unconditional true. The predicate
also decides whether a runTurn stream is wrapped in preflightRunTurnFailover,
and that wrapper can never rotate for a single-account install, so an
unconditional true would add a preflight for users who get nothing from it.
Two or more eligible accounts is the same consent rule hasKeyPoolFailover
already applies to a 2+ key pool. Explicit config still wins, now at two
levels: providers.<name>.oauthAccountFailover beats the global knob, and both
beat presence.

Default-off was hiding three defects in the rotation path that presence-driven
activation would have handed to every multi-account user:

- Copilot pins its bearer to an account-scoped regional origin. core.ts pairs
  them on the initial resolve, but OAuthAccessSnapshot did not carry
  apiBaseUrl, so a rotation sent account B's token to account A's host.
  resolveGithubCopilotTransport fails closed to the canonical host, which is
  why this was misrouting rather than a leak — it was still wrong.
- Antigravity rotation wrote project only when the new snapshot had one, so a
  project-less account inherited the FAILED account's project. That account is
  reachable: refresh tolerates project discovery failing.
- The presence check would have run loadAuthStore on paths that never see a
  429, and that function chmods, reads, parses and normalizes the whole store
  every call.

The first two shared a root cause: three rotation sites each inlined the same
credential swap, and the duplication is what let the paired metadata drift.
They now go through one applyFailoverSnapshot helper that assembles the whole
identity or refuses — a cloud-code-assist snapshot with no project aborts the
rotation rather than mixing two accounts. Presence is answered from a
TTL-bounded per-provider count, invalidated on rotation; it caches a number,
never a credential.

Separately, upsertOAuthProvider rebuilds the provider row from the registry
preset on every login. Without preservation the sequence was: operator sets
enabled false, operator adds a second account, and that one action both
deletes the opt-out and creates the quorum that turns rotation on.

Tests: activation matrix (no config, global both ways, per-provider both ways,
reauth-flagged second account, cache TTL), structural contracts pinning that
the bearer is written in exactly one place and that the helper fails closed,
an explicit-opt-out e2e case, and an incremental SSE read proving the first
delta reaches the client before completion. Both new contracts were driven
red: forcing preflight to buffer the whole turn fails the delivery test at its
5s bound, and disabling the preservation branch fails both upsert cases.

Closes lidge-jun#2568

* fix(gui): translate the alias catalog keys and unbreak the locale parity parser

The gates job was red on dev before this branch existed, on four locale tests
with two unrelated causes.

Three of them read a catalog by regex anchored at "^\s*", but these files pack
several entries onto one line. The parser therefore saw only the first entry
per line and reported "models.newBadge" missing from every non-English
locale — a key that is present in all of them. The catalogs were identical;
the check was not reading them. Dropping the line anchor fixes all three, and
the module-level check in i18n-locales.test.ts already agreed the key sets
match, which is what made the phantom visible.

The fourth was real: lidge-jun#2463 landed thirteen alias keys with the English string
copied into all eight locales. They are now translated. "models.aliasAuto" is
the exception and joins the intentional-English allowlist, because "auto" is
the same word in French and labels a machine-derived alias source rather than
prose.

gui: bun test 994 pass, 0 fail (was 990/4).
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.

1 participant