Conversation
…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
… worktree db8ea67)
# 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
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Merging this PR will degrade performance by 18.52%
|
| 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)
Summary
Adds an opt-in
[server.weekly_fallback]policy that routes/v1/messagesrequests 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/mainwith 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_oauthprovider and one existing ChatGPT-backendchatgpt_oauthprovider, then maps explicit backend model pairs:rejectedstatus or>= 1.0utilization, 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.claude_fallbackonce on the same provider.429 rate_limit_error; partial output is never replayed.Request provenance
A new
AdapterFailure::NoUpstreamAttemptdistinguishes 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 asBeforeHeadersand remains eligible.Claude OAuth User-Agent
platform.claude.comsits behind Cloudflare and refuses a request with no browser-like signature (403error1010). The redirect-hardened token client sends no default User-Agent, so the refresh POST now presentsclaude-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 includestests/weekly_fallback.rs(28) plus theweekly,provenance,strict_reset, andusage_poll::tests::weeklysuites.Preservation
Every fork-only feature was ported onto upstream's implementation rather than dropped: the weekly-fallback config, strict evidence, and policy; the
NoUpstreamAttemptprovenance; 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)
src/proxy/weekly.rsthreadsstage_stampintofinish), but the combined[models.stage_router]+[server.weekly_fallback]case has no test.codex.rate_limitsevent 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.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/messagesrequests 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
claude_oauthprovider, onechatgpt_oauthprovider, and explicit bidirectional model pairs.rejectedstatus or>= 1.0utilization, fresh within 300 seconds.claude_fallbackonce on the same provider.429 rate_limit_error.Supporting changes
AdapterFailure::NoUpstreamAttemptso local request-construction failures are terminal and can never authorize a provider switch.claude-cli/<version> (external, cli)as User-Agent to satisfy Cloudflare.codex.rate_limitsevents still refresh quota but not strict weekly evidence; longusage_refresh_secondsintervals can expire observations early, which fails safe by not switching.x-gateway-routed-modelandx-gateway-route-sourceheaders 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.