Repository navigation
feat(auth): add quota-response-mode toggle for final usage-limit responses - #3
Merged
Merged
Conversation
…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.
Soul4723
force-pushed
the
feat/quota-response-mode
branch
from
November 2, 2026 05:52
e698714 to
56d421f
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 noterror.type: usage_limit_reachedas retryable: it sleeps onRetry-Afterand 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
routing.cooldown.quota-response-mode: retryable | usage-limit(legacy flat keyquota-cooldown-response-mode). Empty meansretryable, and unknown values fail config loading. It is applied at startup and on hot reload, appears in the config change summary, and is documented inconfig.example.yaml.usage_limit_reached(HTTP and WebSocket forms)insufficient_quotaRESOURCE_EXHAUSTEDwith ErrorInfo reasonQUOTA_EXHAUSTED, from Gemini, Vertex (including Vertex OpenAPI, which is served by the OpenAI-compatible executor) and Antigravity. A shared helper inhelps/google_quota.goreads the reset fromRetryInfo.retryDelay, or else from a positiveRetry-After..cds), and is cleared with that cooldown.usage-limitmode, the final error ofExecuteandExecuteStreamis checked against the whole eligible pool, ignoring which credentials this request already tried. The pool uses the same filters as selection: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:X-Should-Retry: false, and noRetry-After. An upstreamRetry-Afterfrom passthrough headers is removed.X-Codex-Primary-{Used-Percent,Window-Minutes,Reset-At}when the exhausted window is shorter than a day, andX-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.usage-limitmode,Access-Control-Expose-HeadersaddsX-Should-Retryand the sixX-Codex-*headers after the base list. Inretryablemode the list is byte for byte the one oncontrib./v1/responsesand/v1/chat/completionskeep 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"}}/v1/messageskeepsrate_limit_error. The message ends with the suffix that T3 Code parses (fix(server): keep a gateway's Claude reset time on a direct 429 pingdotgg/t3code#25438):{"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:
ExecuteCount, Home mode (Home owns cooldowns) and the/v1/liveand alpha-search routes are unchanged.Tradeoffs
Errorstruct can't gain fields becauseTestErrorLegacyUnkeyedLiteralCompatibilitypins its unkeyed literal. SettingError.Codewould changeError()text inretryablemode. So the evidence travels on newResultfields,QuotaExhausted,QuotaLimitWindowMinutesandQuotaResetAfter, set where each result is built.Retry-After); only the evidence andQuotaResetAfterare added.RetryAfter()nil and the same error text. Their quota reset travels asQuotaResetAfter(newQuotaResetErrorinterface), and onlyusage-limitmode uses it as the cooldown deadline when the result has no retry hint. Soretryablemode keeps the exact cooldown and response it had for these errors. One effect: a Google credential that cooled down inretryablemode has no recorded evidence, so switching tousage-limitmid-cooldown keeps it retryable until its next failure.Evidence
All commands below ran on this branch at
3f7a4f1eon Go 1.27.3. The Vertex OpenAPI fix (e6987148) was checked afterwards; see the end of this list.go build ./...: OK.go veton every changed package: clean.go test ./...: all 102 packages OK.go test -raceonsdk/cliproxy/auth/...,sdk/api/handlers/...,internal/config/...,internal/api/...andinternal/runtime/executor/...: all OK except two tests, and both fail the same way on untouchedcontrib(7cad1cb7):internal/api/middlewareTestRequestLoggingMiddleware_StreamingPreservesFullUUIDForLogger: a data race betweenprocessStreamingChunksandFinalizeinresponse_writer.go. It failed 40 of 40-raceruns on both this branch andcontrib. This branch does not touch that package.TestResponsesInterruptStopsHTTPUpstream: failed 11 of 40-raceruns here and 14 of 40 oncontrib.TestServiceHeartbeatFailoverDoesNotDrainBlockingScope(sdk/cliproxy) is flaky oncontribtoo. It failed withnew subscription did not start while the old scope remained active:-race-racecontribBoth checkouts ran at the same time on one machine.
Vertex OpenAPI fix, on
e6987148:go build ./...OK.go vet ./internal/runtime/executor/clean.go teston the changed package OK.go test ./...: 101 of 102 packages OK; the exception wasTestResponsesInterruptStopsHTTPUpstream(flaky oncontrib, 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:insufficient_quotaerror, thenauth_not_found.internal/runtime/executor/vertex_openapi_quota_evidence_test.go: through the manager, routesdeepseek-ai/deepseek-v4.1-flash-maasvia Vertex OpenAPI, and the upstream returns a 429 withQUOTA_EXHAUSTED,retryDelay7200s andRetry-After: 7200. For execute and stream:usage-limitmode then returns a usage limit about 2h out.usage-limitmode returned a plain retryablemodel_cooldown.internal/runtime/executor/google_quota_evidence_test.go: Gemini and Vertex, execute and stream, report evidence and the reset forQUOTA_EXHAUSTEDand keepRetryAfter()and the error text unchanged.RATE_LIMIT_EXCEEDEDdoes not count as evidence.sdk/cliproxy/auth/usage_limit_refusal_test.goandsdk/api/handlers/quota_usage_limit_response_test.go: a quota reset without a retry hint becomes a usage limit only inusage-limitmode.retryablemode keeps the old response.internal/runtime/executor/quota_exhausted_evidence_test.go:TestClaudeQuotaWindowMatchesRetainedResetpairs the window with the kept reset, and leaves it unknown when it does not match.internal/api/cors_usage_limit_test.go: inretryablemode (empty or explicit),Access-Control-Expose-Headersequals the base list joined with", ". Inusage-limitmode it adds the seven headers.Earlier tests:
sdk/api/handlers/quota_usage_limit_response_test.goruns 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.gocovers 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 throughFileCooldownStateStorefor credential-scoped and model-scoped cooldowns.internal/runtime/executor/quota_exhausted_evidence_test.gochecks which upstream 429s count as evidence and the window length taken from the Codex and Anthropic headers.internal/config/quota_cooldown_response_mode_test.goround-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.gochecks startup, then reloads a config file throughLoadConfigandUpdateClientsand sees the mode change.internal/watcher/diffchecks the change summary line.Revert check, run on a clean
contribcheckout against the first version of this PR (18eaa8ed):Credit
The evidence and deadline tracking is ported from router-for-me#6647. The Codex rate-limit headers, the
withUsageLimitcooldown 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
retryablekeeps today's responses.usage-limitis 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.