Skip to content

feat: opt-in weekly quota fallback between Claude and Codex - #602

Open
epruseal wants to merge 25 commits into
pleaseai:mainfrom
epruseal:integrate/upstream-main
Open

epruseal wants to merge 25 commits into
pleaseai:mainfrom
epruseal:integrate/upstream-main

Conversation

@epruseal

@epruseal epruseal commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in [server.weekly_fallback] policy that routes /v1/messages requests between a Claude OAuth provider and a ChatGPT/Codex provider when the shared weekly window is provably exhausted, in both directions, plus the request-provenance and Claude User-Agent fixes it depends on.

This branch is upstream/main with the four commits below applied on top; the diff is additive and does not rewrite any existing upstream behavior.

[server.weekly_fallback]

Off by default. When enabled, it names one existing claude_oauth provider and one existing ChatGPT-backend chatgpt_oauth provider, then maps explicit backend model pairs:

[server.weekly_fallback]
enabled = true
claude_provider = "anthropic"
codex_provider = "codex"

[[server.weekly_fallback.models]]
claude = "claude-fable-5"
codex = "gpt-6-astra"
claude_fallback = "claude-opus-5"

[[server.weekly_fallback.models]]
claude = "claude-opus-5"
codex = "gpt-5.6-sol"
  • Strict weekly evidence. One observation must supply a future reset and rejected status or >= 1.0 utilization, from the same response, within a 300-second age bound. Header observations and usage-API poll observations both qualify; incomplete or contradictory evidence never proves exhaustion, and a restart starts with none.
  • Fable same-provider fallback. An eligible upstream failure (a relayed 4xx/5xx response, or a failure before headers) retries the configured claude_fallback once on the same provider.
  • At most one provider switch per request. Dual exhaustion returns Anthropic 429 rate_limit_error; partial output is never replayed.

Request provenance

A new AdapterFailure::NoUpstreamAttempt distinguishes a local request-construction failure from an attempted-but-failed turn on the Claude and Codex pool paths and on the single-credential HTTP and WebSocket paths. The weekly policy treats it as terminal, so a malformed header or a request-builder failure can never authorize a provider switch. An earlier real transport attempt still classifies as BeforeHeaders and remains eligible.

Claude OAuth User-Agent

platform.claude.com sits behind Cloudflare and refuses a request with no browser-like signature (403 error 1010). The redirect-hardened token client sends no default User-Agent, so the refresh POST now presents claude-cli/<version> (external, cli), and the authorization-code exchange presents it too.

Verification

  • cargo fmt --all --check — pass.
  • cargo clippy --all-targets --jobs 1 -- -D warnings — pass.
  • cargo test --workspace --jobs 1 -- --test-threads=1 — all suites pass, 0 failed. This includes tests/weekly_fallback.rs (28) plus the weekly, provenance, strict_reset, and usage_poll::tests::weekly suites.
  • Two independent adversarial reviews (a fresh in-process reviewer and a separate CLI process on a different model) both returned PASS after re-running the gates. Their findings are listed under "Known follow-ups".

Preservation

Every fork-only feature was ported onto upstream's implementation rather than dropped: the weekly-fallback config, strict evidence, and policy; the NoUpstreamAttempt provenance; and the Claude User-Agent fix. Upstream's newer implementations of shared subsystems (wham poller, pool re-probe, admin SPA, stage router, inbound Codex routing) were kept as canonical, and the corresponding fork tests were carried onto them.

Known follow-ups (non-blocking)

  • No regression test yet pins the stage-router headers on the weekly-policy path. The wiring is fixed (src/proxy/weekly.rs threads stage_stamp into finish), but the combined [models.stage_router] + [server.weekly_fallback] case has no test.
  • The Codex WebSocket in-stream codex.rate_limits event updates quota but not strict weekly evidence; only response headers and the usage poll do. An account served solely by reused WebSocket turns would rely on the poller for evidence.
  • The strict-evidence age bound (300s) is shorter than a large usage_refresh_seconds; if an operator sets the poll interval above it, poll observations expire before they can authorize a switch. This fails safe (no switch).

Summary by cubic

Adds an opt-in [server.weekly_fallback] policy that routes /v1/messages requests between a Claude OAuth provider and a ChatGPT-backend Codex provider when shared weekly quota is provably exhausted, in both directions. Also adds the request-provenance and Claude OAuth User-Agent fixes the policy depends on.

Weekly fallback policy

  • Off by default; when enabled, binds one claude_oauth provider, one chatgpt_oauth provider, and explicit bidirectional model pairs.
  • Switches providers only when a single response supplies a future reset with rejected status or >= 1.0 utilization, fresh within 300 seconds.
  • Header observations and usage-API poll observations both qualify; incomplete, expired, or 5h/cooldown-only evidence never proves exhaustion.
  • An eligible upstream 4xx/5xx or pre-header failure retries the configured claude_fallback once on the same provider.
  • Allows at most one provider switch per request; simultaneous exhaustion returns Anthropic 429 rate_limit_error.
  • Rejects invalid provider kinds, auth modes, and model chains; unknown config keys fail validation.

Supporting changes

  • Adds AdapterFailure::NoUpstreamAttempt so local request-construction failures are terminal and can never authorize a provider switch.
  • Claude OAuth refresh and authorization-code exchange now send claude-cli/<version> (external, cli) as User-Agent to satisfy Cloudflare.
  • Codex WebSocket in-stream codex.rate_limits events still refresh quota but not strict weekly evidence; long usage_refresh_seconds intervals can expire observations early, which fails safe by not switching.
  • Pins the stage-router x-gateway-routed-model and x-gateway-route-source headers on the weekly path so combined [models.stage_router] + [server.weekly_fallback] configs report the same route metadata as the ordinary path.

Written for commit e01e655. Summary will update on new commits.

epruseal and others added 23 commits August 26, 2026 10:04
…ow observation times

A deployed multi-account codex pool recorded a valid window-minutes
group with utilization above threshold but a blank reset-at header, so
no reset instant was ever captured and the near-quota mark never
expired on its own — the account stayed stuck out of rotation
indefinitely even after the real upstream window had long since reset.

Stamp an observed_at timestamp alongside every window's utilization or
status whenever it is recorded (header parsing, usage snapshots,
legacy import), and use it as a lifetime bound: a reset-less window now
expires one window length after it was last observed instead of never.
The legacy aggregate status gets the same treatment plus an
unconditional cap independent of window signal. A restored window with
no observation timestamp at all (state written before this field
existed) is backdated to boot time rather than treated as expired, so
persistence still warm-starts.

observed_at is structurally kept out of window_headroom's burn-rate
math — it only bounds staleness, never synthesizes a reset or leaks
into headroom — per the design's rejection of a reset-synthesis
alternative.

note_usage's reset handling is also tightened to preserve-future-only:
an incoming resets_at always wins, and when absent the stored reset is
kept only if it is still in the future.
…bsocket turns

The websocket transport captured quota headers at handshake time and
replayed them on every subsequent turn over a pooled, reused
connection — even though a reused turn performs no new handshake and
carries no new quota signal from upstream. That let a stale
handshake-time reading keep reporting the same quota indefinitely for
every turn that shared the connection, working against the same
staleness problem the per-window observation times fix on the header
path.

Drop the stored handshake headers from the pooled Connection entirely
and make Turn::handshake_headers() fresh-only: a turn that reused a
pooled connection now reports no quota headers rather than stale ones,
so it contributes nothing to quota tracking instead of misleading it.
Positive reprobe_seconds values below 60 previously scheduled
opportunistic re-probes faster than usage_refresh_seconds itself
typically refreshes, defeating the point of throttling. Clamp at the
single read site (reprobe_interval, called once per select_order
request) rather than duplicating the clamp elsewhere, and warn once at
config load (Config::validate) instead of once per request.
…ection

Races 8 threads x 200 rounds against select_order to catch a regression
that splits the select-then-stamp critical section in probe_selection
into two lock acquisitions -- a single-shot two-thread race can't hit
the nanosecond-wide window such a split opens.
Today, a 200 response with an unrecognizable body marks the account observed and deduplicates it against other aliases. Skip note_usage unless at least one usage window parsed.
Live wham/usage responses report a window's length as
`limit_window_seconds`, which the parser never read: it looked for
`window_minutes`, missed, and fell back to the key's position, filing
`primary_window` into the five-hour slot. Both observed accounts report a
single weekly window there, so a 604800-second window was recorded as
five-hour usage and the pool saw a 5h utilization it had never been told.

Classify each window from its reported duration instead -- accepting
`limit_window_seconds`, `window_minutes`, and `limit_window_minutes` --
and skip a window whose duration cannot be determined rather than
inferring one from where it appeared. A window absent upstream stays
absent here; nothing is derived from a sibling window.

The current weekly-only shape is an upstream policy state, not the API's
contract. Bucketing by duration means a returned five-hour window is
picked up again with no code change, from either key position.
A wham/usage response enumerates the windows an account actually has, so
a bucket with no matching window in a successfully parsed response is not
missing information: upstream is saying the window does not exist. The
poller treated it as missing anyway, because note_usage guards each slot
with `if let Some(window)` and leaves an unreported slot untouched. A
stale five-hour utilization therefore outlived the window it described,
and only disappeared once the observation-age bound expired it.

Report authoritative absence per bucket alongside the parsed windows and
clear that bucket's utilization, reset, status and observation stamp when
upstream reports it gone. A window skipped for an unrecognizable duration
stays untouched: that is unknown, not absent, and the two must not be
confused. The Claude path keeps its existing behavior, where an omitted
window preserves the prior header value.
chore: preserve the operating shunt baseline
# Conflicts:
#	README.ja.md
#	README.ko.md
#	README.md
#	README.zh-CN.md
#	docs/m10-codex-multi-account.md
#	docs/m8-anthropic-multi-account.md
#	site/src/content/docs/guides/codex-multi-account.mdx
#	site/src/content/docs/ja/guides/codex-multi-account.mdx
#	site/src/content/docs/ja/reference/configuration.md
#	site/src/content/docs/ko/guides/codex-multi-account.mdx
#	site/src/content/docs/ko/reference/configuration.md
#	site/src/content/docs/reference/configuration.md
#	site/src/content/docs/reference/endpoints.md
#	site/src/content/docs/zh-cn/guides/codex-multi-account.mdx
#	site/src/content/docs/zh-cn/reference/configuration.md
#	src/accounts.rs
#	src/adapters/responses/http.rs
#	src/adapters/responses/pool.rs
#	src/adapters/responses/request.rs
#	src/adapters/responses/websocket.rs
#	src/auth/claude/auth.rs
#	src/auth/claude/usage.rs
#	src/auth/codex/usage.rs
#	src/config.rs
#	src/metrics.rs
#	src/proxy/failover.rs
#	src/state_persist.rs
#	src/usage_poll.rs
#	tests/codex_multi_account.rs
#	tests/inbound_codex_endpoint.rs
#	tests/multi_account.rs

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements an optional weekly quota fallback policy ([server.weekly_fallback]) for Claude Code Messages requests, allowing seamless switching between Claude OAuth and ChatGPT OAuth providers when all enabled accounts exhaust their weekly quota. It also ensures that Claude OAuth token refresh and exchange requests include the proper Claude CLI User-Agent to prevent Cloudflare blocks. A compilation issue was identified in strict_reset.rs where is_multiple_of is called on a primitive integer without the required trait import; using the standard modulo operator % is recommended to resolve this.

let hour = input[11..13].parse::<u8>().ok()?;
let minute = input[14..16].parse::<u8>().ok()?;
let second = input[17..19].parse::<u8>().ok()?;
let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

[MEDIUM] Use standard modulo operator for leap year calculation

Problem: The code uses year.is_multiple_of(4) which is not a standard method on primitive integer types in Rust's standard library. This will cause a compilation error unless an external trait like num::Integer is imported and in scope.
Rationale: Standard modulo operator % is universally available, highly readable, and avoids external trait dependencies.
Suggestion: Replace is_multiple_of calls with standard modulo operations.

Suggested change
let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
let leap = (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not valid here. is_multiple_of on unsigned integers is stable since Rust 1.87, and this PR's fmt · clippy · test check passes on the repo's stable toolchain, so the call compiles. Keeping the standard method.

@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no actionable new issue remains, and the previously reported translation duplication has been resolved.

Summary

The PR adds an opt-in weekly-quota fallback policy for Anthropic Messages requests, allowing controlled switching between configured Claude OAuth and ChatGPT/Codex OAuth providers only when fresh, strict shared-weekly exhaustion evidence exists.

  • Adds strict, process-local weekly evidence parsing from response headers and provider usage APIs.
  • Adds bidirectional provider switching, optional same-provider Claude model fallback, and a one-switch-per-request limit.
  • Distinguishes local failures with no upstream attempt from retry-eligible pre-header failures.
  • Adds Claude OAuth User-Agent handling, configuration validation, documentation, and broad regression coverage.
  • Since the previous review, removes duplicated Japanese and Korean reprobe guidance and adds coverage ensuring weekly-policy responses preserve stage-router metadata.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Messages request] --> B[Resolve single route and model pair]
  B --> C[Authenticate all possible routes]
  C --> D{Original pool has fresh evidence that every account is exhausted?}
  D -- No --> E[Dispatch original provider]
  D -- Yes --> F{Destination pool also exhausted?}
  F -- Yes --> G[Return Anthropic 429 rate_limit_error]
  F -- No --> H[Switch provider and dispatch destination]
  E --> I{Eligible failure before partial output?}
  I -- No --> J[Return response]
  I -- Yes --> K{New strict exhaustion evidence?}
  K -- Yes --> F
  K -- No --> L{Configured same-provider Claude fallback available?}
  L -- Yes --> M[Retry once with Claude fallback model]
  L -- No --> J
  M --> J
  H --> J
Loading

Reviews (2) · Last reviewed commit: "test(proxy): pin the stage-router header..."

Comment thread site/src/content/docs/ja/reference/configuration.md Outdated
@codspeed

codspeed Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will degrade performance by 18.52%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 5 regressed benchmarks
✅ 96 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
account_pool_quota_updates[32] 104.7 ms 131.4 ms -20.29%
account_pool_quota_updates[8] 104.8 ms 131.3 ms -20.22%
account_pool_quota_updates[128] 105.5 ms 131.9 ms -20.04%
account_pool_quota_updates[1] 106.1 ms 132 ms -19.61%
parse_body_to_value[50] 746.5 µs 850 µs -12.18%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing epruseal:integrate/upstream-main (e01e655) with main (daa5ed6)

Open in CodSpeed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant