Skip to content

feat(auth): add quota-response-mode toggle for final usage-limit responses - #3

Merged
Soul4723 merged 8 commits into
contribfrom
feat/quota-response-mode
Nov 2, 2026
Merged

Soul4723 merged 8 commits into
contribfrom
feat/quota-response-mode

Conversation

@Soul4723

@Soul4723 Soul4723 commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Why

When every credential for a model is out of quota, CLIProxyAPI answers with a retryable 429 and Retry-After. Codex CLI treats any 429 that is not error.type: usage_limit_reached as retryable: it sleeps on Retry-After and tries again, so a client like T3 Code shows the thread as "working" for hours. T3 Code pauses a thread and resumes it at the reset only when the run fails as a usage limit with a known reset time. Upstream PRs disagree on whether the proxy should send a final usage limit or a retryable 429, so this adds a config toggle and keeps today's behavior as the default.

What changed

  • New setting routing.cooldown.quota-response-mode: retryable | usage-limit (legacy flat key quota-cooldown-response-mode). Empty means retryable, and unknown values fail config loading. It is applied at startup and on hot reload, appears in the config change summary, and is documented in config.example.yaml.
  • Executors now mark a 429 as explicit quota exhaustion for:
    • Codex usage_limit_reached (HTTP and WebSocket forms)
    • OpenAI insufficient_quota
    • Google RESOURCE_EXHAUSTED with ErrorInfo reason QUOTA_EXHAUSTED, from Gemini, Vertex (including Vertex OpenAPI, which is served by the OpenAI-compatible executor) and Antigravity. A shared helper in helps/google_quota.go reads the reset from RetryInfo.retryDelay, or else from a positive Retry-After.
    • an Anthropic unified 5h/7d rejection. The window reported is the rejected window whose reset equals the deadline the cooldown keeps: 300 for 5h, 10080 for weekly. If the kept deadline matches neither, the window is left unknown.
  • The cooldown records this evidence only when the upstream also gave a reset time. The evidence stays attached to the deadline it set, is saved with cooldown state (.cds), and is cleared with that cooldown.
  • In usage-limit mode, the final error of Execute and ExecuteStream is checked against the whole eligible pool, ignoring which credentials this request already tried. The pool uses the same filters as selection:
    • canonical provider names
    • disabled credentials skipped, but status-disabled ones kept, as selection does
    • a registered executor for the credential's provider
    • support for the route model, plus pinning
  • For a pooled OpenAI-compatible alias, cooldowns are recorded under each backend model. Each credential is checked against every backend behind the alias, using the state model that blocks that backend; listing the backends does not advance the pool rotation. The credential counts as blocked only when selection blocks it with evidence, or when every backend is blocked by a cooldown with evidence.
  • The error becomes a final usage limit only when every candidate is blocked by evidence that covers its blocking deadline and the earliest reset is more than 5 minutes away. The check runs on a 429, and also on auth_not_found (no executable credential), which is how an all-cooling pooled alias ends. Mixed pools, unclassified 429s, exponential backoff and resets of 5 minutes or less keep the retryable answer.

Wire output

retryable (default): unchanged on every path. Existing tests pass unchanged.

usage-limit, both when every credential is already cooling at selection (path a) and when the last credential returns 429 during this request (path b), before any response bytes are written:

  • Status 429, X-Should-Retry: false, and no Retry-After. An upstream Retry-After from passthrough headers is removed.
  • X-Codex-Primary-{Used-Percent,Window-Minutes,Reset-At} when the exhausted window is shorter than a day, and X-Codex-Secondary-* for the weekly window. Used percent is 100, taken from the credential that reopens first. No Codex headers are sent when the window is unknown.
  • CORS: in usage-limit mode, Access-Control-Expose-Headers adds X-Should-Retry and the six X-Codex-* headers after the base list. In retryable mode the list is byte for byte the one on contrib.
  • /v1/responses and /v1/chat/completions keep the existing cooldown body and add four fields:
{"error":{"code":"model_cooldown","type":"usage_limit_reached","resets_at":1793593893,"retryable":false,"limit_window_minutes":300,
  "message":"All credentials for model M are cooling down via provider P (last error: usage_limit_reached: The usage limit has been reached)",
  "model":"M","provider":"P","reset_seconds":7200,"reset_time":"2h0m0s","last_upstream_error":"usage_limit_reached: The usage limit has been reached"}}
{"type":"error","error":{"type":"rate_limit_error","message":"All credentials for model M are cooling down via provider P (last error: ...) (reset after 2h0m0s)","resets_at":1793593893,"retryable":false}}

The response is always 429. The status-code setting from router-for-me#6245 is not on dev, so there is nothing to override.

Scope

Covers config, evidence, and paths (a) and (b) on Responses, Chat Completions and Anthropic Messages, streaming or not, as long as nothing has been written yet. Not covered, left for a follow-up PR:

  • Errors after the first byte of an SSE stream. HTTP stays 200 there, so the metadata has to go into the terminal error event.
  • The Responses WebSocket path, which still closes without an error event on quota failures.

ExecuteCount, Home mode (Home owns cooldowns) and the /v1/live and alpha-search routes are unchanged.

Tradeoffs

  • The Error struct can't gain fields because TestErrorLegacyUnkeyedLiteralCompatibility pins its unkeyed literal. Setting Error.Code would change Error() text in retryable mode. So the evidence travels on new Result fields, QuotaExhausted, QuotaLimitWindowMinutes and QuotaResetAfter, set where each result is built.
  • Vertex OpenAPI errors keep the OpenAI-compatible error text and retry hint (Retry-After); only the evidence and QuotaResetAfter are added.
  • Gemini and Vertex native errors keep RetryAfter() nil and the same error text. Their quota reset travels as QuotaResetAfter (new QuotaResetError interface), and only usage-limit mode uses it as the cooldown deadline when the result has no retry hint. So retryable mode keeps the exact cooldown and response it had for these errors. One effect: a Google credential that cooled down in retryable mode has no recorded evidence, so switching to usage-limit mid-cooldown keeps it retryable until its next failure.
  • The decision is made once, at the end of execution, instead of in each of the four places that build a cooldown error. This covers path (a) and path (b) with one pool check.
  • One global policy with a fixed 5-minute threshold, matching fix(auth): report explicit quota exhaustion as a final usage limit router-for-me/CLIProxyAPI#6647 and fix(auth): report exhausted Codex quota as a usage limit Codex clients stop on router-for-me/CLIProxyAPI#7184.

Evidence

All commands below ran on this branch at 3f7a4f1e on Go 1.27.3. The Vertex OpenAPI fix (e6987148) was checked afterwards; see the end of this list.

  • go build ./...: OK.

  • go vet on every changed package: clean.

  • go test ./...: all 102 packages OK.

  • go test -race on sdk/cliproxy/auth/..., sdk/api/handlers/..., internal/config/..., internal/api/... and internal/runtime/executor/...: all OK except two tests, and both fail the same way on untouched contrib (7cad1cb7):

    • internal/api/middleware TestRequestLoggingMiddleware_StreamingPreservesFullUUIDForLogger: a data race between processStreamingChunks and Finalize in response_writer.go. It failed 40 of 40 -race runs on both this branch and contrib. This branch does not touch that package.
    • TestResponsesInterruptStopsHTTPUpstream: failed 11 of 40 -race runs here and 14 of 40 on contrib.
  • TestServiceHeartbeatFailoverDoesNotDrainBlockingScope (sdk/cliproxy) is flaky on contrib too. It failed with new subscription did not start while the old scope remained active:

    Checkout Without -race With -race
    contrib 2 of 40 2 of 40
    this branch 2 of 40 3 of 40

    Both checkouts ran at the same time on one machine.

  • Vertex OpenAPI fix, on e6987148: go build ./... OK. go vet ./internal/runtime/executor/ clean. go test on the changed package OK. go test ./...: 101 of 102 packages OK; the exception was TestResponsesInterruptStopsHTTPUpstream (flaky on contrib, above), and that package then passed 3 of 3 reruns. go test -race ./internal/runtime/executor/... ./sdk/cliproxy/auth/...: OK.

Each review fix has a regression test that was run and seen failing before the fix:

  • sdk/cliproxy/auth/usage_limit_alias_pool_test.go:
    • A pooled alias whose backends are all quota-exhausted gets the usage limit, for both execute and stream and for paths (a) and (b). Before the fix it got the raw insufficient_quota error, then auth_not_found.
    • A pooled alias with one available backend stays retryable.
    • A credential whose provider has no registered executor does not block the usage limit.
  • internal/runtime/executor/vertex_openapi_quota_evidence_test.go: through the manager, routes deepseek-ai/deepseek-v4.1-flash-maas via Vertex OpenAPI, and the upstream returns a 429 with QUOTA_EXHAUSTED, retryDelay 7200s and Retry-After: 7200. For execute and stream:
    • Default mode returns the raw error with unchanged text and retry hint, plus the evidence and a 2h reset.
    • usage-limit mode then returns a usage limit about 2h out.
    • Before the fix the evidence was missing and usage-limit mode returned a plain retryable model_cooldown.
  • internal/runtime/executor/google_quota_evidence_test.go: Gemini and Vertex, execute and stream, report evidence and the reset for QUOTA_EXHAUSTED and keep RetryAfter() and the error text unchanged. RATE_LIMIT_EXCEEDED does not count as evidence.
  • sdk/cliproxy/auth/usage_limit_refusal_test.go and sdk/api/handlers/quota_usage_limit_response_test.go: a quota reset without a retry hint becomes a usage limit only in usage-limit mode. retryable mode keeps the old response.
  • internal/runtime/executor/quota_exhausted_evidence_test.go: TestClaudeQuotaWindowMatchesRetainedReset pairs the window with the kept reset, and leaves it unknown when it does not match.
  • internal/api/cors_usage_limit_test.go: in retryable mode (empty or explicit), Access-Control-Expose-Headers equals the base list joined with ", ". In usage-limit mode it adds the seven headers.

Earlier tests:

  • sdk/api/handlers/quota_usage_limit_response_test.go runs the real manager, a fake executor and the real handlers. It covers both modes × paths (a) and (b) × the three surfaces × stream on/off (24 cases), and asserts status, headers, resets_at, the window and the Claude suffix. It also covers the mixed pool, a generic long 429, a reset under 5 minutes and a missing reset across all surfaces, plus switching the mode while a cooldown is active.
  • sdk/cliproxy/auth/usage_limit_refusal_test.go covers the boundary at exactly 5 minutes (retryable) and at 5 minutes plus 1 second (usage limit). It also checks that evidence is recorded only with a provider reset, that it must cover the model's blocking deadline, that the weekly window goes to the secondary headers and an unknown window gets none, and that evidence survives a restart through FileCooldownStateStore for credential-scoped and model-scoped cooldowns.
  • internal/runtime/executor/quota_exhausted_evidence_test.go checks which upstream 429s count as evidence and the window length taken from the Codex and Anthropic headers.
  • internal/config/quota_cooldown_response_mode_test.go round-trips the v8 key and the legacy key through load, save and reload, and checks that unknown values are rejected. internal/api/quota_cooldown_response_mode_test.go checks startup, then reloads a config file through LoadConfig and UpdateClients and sees the mode change. internal/watcher/diff checks the change summary line.

Revert check, run on a clean contrib checkout against the first version of this PR (18eaa8ed):

  • With the new test files only, every new test file fails to compile.
  • With the change applied but its decision points stubbed out (the pool check, evidence recording, the mode setter, executor evidence, value parsing, the v8 key, the handler headers and the Claude suffix), 41 subtests fail. That is every usage-limit assertion. The 43 that still pass are the retryable-mode and "stays retryable" checks, which should pass either way, plus two helper tests whose functions were not stubbed.

Credit

The evidence and deadline tracking is ported from router-for-me#6647. The Codex rate-limit headers, the withUsageLimit cooldown shape and the wrapped-error handling are ported from router-for-me#7184. Both are adapted here behind the toggle.

Merge danger

Two-way door. The default retryable keeps today's responses. usage-limit is opt-in and can be switched off by hot reload. Cooldown state files gain two optional fields (exhausted, limit_window_minutes), which older builds ignore.

…onses

Add routing.cooldown.quota-response-mode (legacy key
quota-cooldown-response-mode): "retryable" (default, unchanged behavior)
or "usage-limit".

In usage-limit mode, a request whose every eligible credential is blocked
by explicit upstream quota exhaustion (Codex usage_limit_reached, OpenAI
insufficient_quota, Google QUOTA_EXHAUSTED, Anthropic unified 5h/7d
rejection) with a provider reset more than five minutes away is answered,
before any bytes are written, with a final usage limit: 429,
X-Should-Retry: false, no Retry-After, error.type usage_limit_reached with
resets_at, retryable false and limit_window_minutes, Codex
x-codex-primary/secondary-* headers for a known window, and on
/v1/messages a rate_limit_error whose message ends with
"(reset after <duration>)". Mixed, unclassified or short pools keep the
retryable answer.

Quota-exhaustion evidence is recorded with the cooldown deadline it set,
persisted with cooldown state and cleared with it. Streaming after the
first byte and the Responses WebSocket path are not covered yet.

Ports the evidence and deadline tracking from router-for-me#6647 and the Codex headers
and wrapped-error handling from router-for-me#7184.
A pooled alias records cooldowns under each backend model, so the pool
check now reads every execution model behind the alias (without advancing
the pool rotation) and treats a credential as blocked only when every
backend is blocked by quota-exhaustion evidence. An all-cooling pooled
alias ends with auth_not_found rather than a 429; the pool check now also
considers that error.
Gemini and Vertex built bare status errors, so a 429 with ErrorInfo reason
QUOTA_EXHAUSTED lost both the evidence and its reset. A shared helper now
reads the evidence and the reset (RetryInfo retryDelay, else Retry-After)
for Gemini, Vertex and Antigravity.

The reset travels as QuotaResetAfter rather than as a retry hint, and only
the usage-limit mode uses it as the cooldown deadline, so the default mode
keeps the exact cooldown and response it had for these errors.
When both unified windows were rejected the window was always the weekly
one, while the cooldown keeps the latest applicable deadline. The reset
parser now also reports the rejected window whose reset is that deadline
(5h = 300, weekly = 10080) and leaves the window unknown when the kept
deadline matches neither.
The pool check counted credentials selection never offers: providers were
not canonicalised and credentials whose provider has no registered executor
stayed in the pool, so an unusable credential could block the usage limit.
It now applies the same filters as mixed selection, including keeping
status-disabled credentials as candidates.
Retryable mode keeps the base Access-Control-Expose-Headers list byte for
byte; X-Should-Retry and the X-Codex primary/secondary headers are added
only when quota-response-mode is usage-limit.
Vertex OpenAPI requests are delegated to the OpenAI-compatible executor,
whose errors only recognised OpenAI insufficient_quota, so a Google 429
with ErrorInfo reason QUOTA_EXHAUSTED lost its evidence and reset on both
the execute and stream paths. The delegate now also reads Google quota
evidence; the error text and retry hint are unchanged.
…bSocket (#4)

* feat(auth): report usage limits in committed streams and Responses WebSocket

* fix(api): send the WebSocket usage limit before the upstream disconnect closes the socket

With steering off, the Codex executor also reports a quota failure on its
upstream disconnect channel. Its subscriber closed the downstream socket
before the turn's stream delivered the normalised usage-limit error, so the
client saw a silent close. In usage-limit mode a quota-exhaustion disconnect
now waits for the running turn's terminal handling, which closes the socket
itself after the event.

* fix(auth): intercept Codex quota frames whose error is nested under body

The Codex WebSocket executor reads a failure's error from body.error before
error, but the stream classifier only looked at error and response.error. A
wrapped quota frame was forwarded and the client then got the usage-limit
event as a second error.

* fix(auth): keep output events coalesced with a dropped quota event

The wrapper held and then dropped a whole stream chunk when it contained a
quota failure event, so an output delta in the same SSE chunk was lost. Only
the quota event is now removed; the chunk's other events are still sent.

* fix(auth): let an explicit non-429 status decide the stream hold

A failure frame was held whenever its error type named a rate limit, even
with an explicit status such as 400. The Codex duplex executor treats that
frame as recoverable and forwards it without an error chunk, so in
usage-limit mode it waited for a chunk that never came. The classifier now
reads the status the way the executors do (the event's status, then the
error's status_code or status) and holds only a 429; error types decide only
when no status is given.

* fix(api): bound the WebSocket quota disconnect wait so a blocked client cannot hang the handler

The disconnect subscriber waited for the running turn to end before closing
the socket. When a client stopped reading during a large frame, the turn was
stuck in its write and nothing closed the socket, so the handler stayed
blocked until the client went away. The wait now also ends when the socket
context is canceled, and after a 5 second grace period; the subscriber then
closes the socket as it does without the wait, which releases the writer. A
client that keeps reading still gets the usage-limit event from the turn.

* fix(auth): read numeric-string statuses the way the executors do

The Codex executors read a failure's status with gjson Int(), so "400" is
status 400 and the frame is recoverable. The stream classifier accepted only
JSON numbers and fell back to the error type, so it held such a frame and,
in usage-limit mode, delayed it while the duplex upstream waited for the
client. Statuses at the event and error level now use Int() too.

* revert(api): close the WebSocket at once on a mid-stream quota disconnect

Remove the wait that made a quota disconnect defer closing the socket to the
running turn, and its grace period. Without steering, a mid-stream quota
failure closes the socket at once and silently (1006) in both modes, as on
base. Codex reconnects, and its next request finds every credential cooling
with quota evidence, so the pre-stream WebSocket path sends the usage-limit
event with the reset. This needs no timer and cannot hang the handler.

The tests now check the silent close in both modes, the prompt handler exit
with a client that stopped reading, and the usage limit on reconnect.

* fix(api): always close the WebSocket transport on a silent, disconnect or cancel close

Claiming terminal closure and closing the transport were one step. When a
terminal event write blocked, a later disconnect close found closure already
claimed and skipped conn.Close(), and a canceled request closed nothing, so
the handler stayed blocked until the client left. The writer now keeps the
claim only to suppress a second terminal event; silent and disconnect closes
always close the transport, and a canceled socket context closes it too.

* fix(auth): read error.code as a status only when it is a JSON number

Applying Int() to a string error.code made "429" a status, so a frame the
executor treats as a recoverable 400 was held, and a quota response.failed
with code "400" was forwarded and then sent again as the usage limit.
Numeric strings now count only in real status fields (the event's status or
status_code, the error's status or status_code); error.code counts only as a
JSON number, Google's error shape.

* Revert "fix(api): always close the WebSocket transport on a silent, disconnect or cancel close"

This reverts commit f51abfd.
@Soul4723
Soul4723 merged commit 65e968d into contrib Nov 2, 2026
3 checks passed
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