You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ProxyOpenAIChatCompletion never checks whether the caller's Claude subscription is exhausted before injecting credentials — unlike ProxyMessages, which suppresses a spent OAuth token and falls through to the deployment/BYOK key (added in #519). On an OpenAI-wire turn that routes to Anthropic (subsidy bias, scorer pick, or dual-sub headers), the spent token gets dispatched anyway. Proven live: the client gets a bare 429 with no failover, where the identical scenario on /v1/messages succeeds on the deployment key. Same asymmetry also breaks the subscription-only 402 guard on this path. Both share one root cause and one fix location.
Summary
Two writeback-equivalent checks exist for Claude-subscription exhaustion:
internal/proxy/service.go:2340-2350 (ProxyMessages) — checks s.claudeSubscriptionExhausted(ctx, r.Header), and if true, calls withSuppressedClaudeSubscription(ctx) before resolveAndInjectCredentials.
internal/proxy/service.go:2681 (ProxyMessages, baseline-failover retry path) — same check, same suppression, on the retry branch.
internal/proxy/service.go:4235-4250 (ProxyOpenAIChatCompletion) — goes straight to resolveAndInjectCredentials(ctx, decision.Provider, r.Header). No exhaustion check anywhere in this function. claudeSubscriptionExhausted / withSuppressedClaudeSubscription are defined once (usage_bypass.go:116, service.go:818) and used twice, both in ProxyMessages. ProxyOpenAIChatCompletion is the only credential-resolution path that never calls either.
This is not the same bug as #533/#728 ("suppress Claude subscription on ctx in baseline failover exhaustion check") — that's a ctx-propagation race entirely inside ProxyMessages's baseline-failover retry (billing misattribution). This issue is ProxyOpenAIChatCompletion never calling the suppression path at all (credential-injection bug, separate function, no overlap — checked against #533's actual diff before filing).
Why it matters
Any OpenAI-wire client (Claude Code running in OpenAI-compat mode, dual-sub setups with both Claude and Codex headers, or subsidy/scorer routing that happens to pick an Anthropic model) that hits this path while the caller's Claude subscription is exhausted gets the spent token dispatched instead of failing over to the deployment key. The identical scenario on /v1/messages already works correctly (#519). This reopens that exact failure mode on the other wire format.
Live verification
Credential actually on the wire (not inferred from servedOnSubscription()):
Same exhausted-subscription fixture used by TestSubscriptionExhausted_ServesOnDeploymentKey, deployment Anthropic key present, decision resolves to Anthropic. Captured via CredentialsFromContext at dispatch:
OpenAI puts the spent Claude OAuth token on the wire; Messages does not.
Consequence (real round-trip against a mock Anthropic upstream returning 429 for any request carrying creds.OAuth):
OpenAI + exhausted observer:
dispatch[0..2]: key=sk-ant-oat01-...-spent-subscription-token oauth=true source=subscription
proxy_err=upstream returned status 429 (buffered)
client_status=429
client_body={"type":"error","error":{"type":"rate_limit_error","message":"weekly limit exceeded"}}
dispatch_calls=3 // same-binding retries only — deployment key never attempted
failover_used=false
Messages + exhausted (preemptive suppress): one dispatch, no credential, 200 — never reaches a 429.
Messages + live 429 (subscriptionRetryEligible path): three attempts on the spent token, then a fourth dispatch with no credential — deployment key — succeeds, subscription_failover=true.
OpenAI + live 429: same three attempts on the spent token, then the client gets the 429 directly. No fourth dispatch. No failover attempted at any point on this surface, preemptive or reactive.
Second guard also missing: subscription-only mode
Beyond credential injection, the two surfaces diverge on the subscription-only exhaustion guard too:
ProxyMessages checks anthropicSubscriptionObservedExhausted (service.go:2373) and returns 402 with zero dispatches when the caller is subscription-only and exhausted.
ProxyOpenAIChatCompletion only checks !servedOnSubscription (service.go:4255), which doesn't account for observed exhaustion — confirmed live: proxy_err=<nil>, one dispatch, spent OAuth sent, neither a 402 nor a suppression fires.
This is a distinct missing guard from the credential-injection gap above, but shares the same underlying cause: ProxyOpenAIChatCompletion never consults the exhaustion observer at all. Fixing the observer check in one place should let both symptoms be addressed together.
Also confirmed, filed separately (not in this issue's scope)
Verification also confirmed two related but independent gaps on the same surface — recommending these be filed as their own issues rather than bundled here, to keep this one single-cause:
routeRes.UsageBypass / bypassToAnthropic / errBypassRetryable are also never called on this surface. turnloop can still set UsageBypass for an OpenAI-wire turn, but nothing consumes it downstream.
Not the bug (ruled out during verification)
Hard-pin beating usage-bypass is documented, intentional precedence — unrelated.
Usage-bypass decision logic itself mostly works on OpenAI (shared turnloop); only the downstream bypassToAnthropic dispatch is missing, which is the separate finding noted above.
Mirrors ProxyMessages exactly. The subscription-only 402 parity (service.go:4255's guard) should switch from !servedOnSubscription to also checking anthropicSubscriptionObservedExhausted, matching service.go:2373.
Regression check
go test ./internal/proxy/... -race — clean except the pre-existing TestFireTelemetryRecoversFromPanic flake (fails in isolation too, unrelated to this change).
No existing OpenAI-path test asserts Claude OAuth must stay injected when exhausted — the only OAuth-related OpenAI tests cover the Codex subscription-only path (subscription_only_openai_test.go), which this change doesn't touch.
New test mirroring TestSubscriptionExhausted_ServesOnDeploymentKey, but for ProxyOpenAIChatCompletion: exhausted snapshot + deployment key present + Anthropic-resolving decision → no OAuth credential injected, deployment key used.
New test for subscription-only + exhausted on the OpenAI surface: expect a 402-equivalent rejection with zero dispatches, not a dispatch on the spent token.
No behavior change for OpenAI turns that don't resolve to Anthropic, or where the subscription isn't exhausted.
TL;DR
ProxyOpenAIChatCompletionnever checks whether the caller's Claude subscription is exhausted before injecting credentials — unlikeProxyMessages, which suppresses a spent OAuth token and falls through to the deployment/BYOK key (added in #519). On an OpenAI-wire turn that routes to Anthropic (subsidy bias, scorer pick, or dual-sub headers), the spent token gets dispatched anyway. Proven live: the client gets a bare 429 with no failover, where the identical scenario on/v1/messagessucceeds on the deployment key. Same asymmetry also breaks the subscription-only 402 guard on this path. Both share one root cause and one fix location.Summary
Two writeback-equivalent checks exist for Claude-subscription exhaustion:
internal/proxy/service.go:2340-2350(ProxyMessages) — checkss.claudeSubscriptionExhausted(ctx, r.Header), and if true, callswithSuppressedClaudeSubscription(ctx)beforeresolveAndInjectCredentials.internal/proxy/service.go:2681(ProxyMessages, baseline-failover retry path) — same check, same suppression, on the retry branch.internal/proxy/service.go:4235-4250(ProxyOpenAIChatCompletion) — goes straight toresolveAndInjectCredentials(ctx, decision.Provider, r.Header). No exhaustion check anywhere in this function.claudeSubscriptionExhausted/withSuppressedClaudeSubscriptionare defined once (usage_bypass.go:116,service.go:818) and used twice, both inProxyMessages.ProxyOpenAIChatCompletionis the only credential-resolution path that never calls either.This is not the same bug as #533/#728 ("suppress Claude subscription on ctx in baseline failover exhaustion check") — that's a
ctx-propagation race entirely insideProxyMessages's baseline-failover retry (billing misattribution). This issue isProxyOpenAIChatCompletionnever calling the suppression path at all (credential-injection bug, separate function, no overlap — checked against #533's actual diff before filing).Why it matters
Any OpenAI-wire client (Claude Code running in OpenAI-compat mode, dual-sub setups with both Claude and Codex headers, or subsidy/scorer routing that happens to pick an Anthropic model) that hits this path while the caller's Claude subscription is exhausted gets the spent token dispatched instead of failing over to the deployment key. The identical scenario on
/v1/messagesalready works correctly (#519). This reopens that exact failure mode on the other wire format.Live verification
Credential actually on the wire (not inferred from
servedOnSubscription()):Same exhausted-subscription fixture used by
TestSubscriptionExhausted_ServesOnDeploymentKey, deployment Anthropic key present, decision resolves to Anthropic. Captured viaCredentialsFromContextat dispatch:/v1/messagesnil=true oauth=false— deploy-key fallback/v1/chat/completionsnil=false oauth=true source="subscription" key="sk-ant-oat01-...spent-token"OpenAI puts the spent Claude OAuth token on the wire; Messages does not.
Consequence (real round-trip against a mock Anthropic upstream returning 429 for any request carrying
creds.OAuth):OpenAI + exhausted observer:
Messages + exhausted (preemptive suppress): one dispatch, no credential, 200 — never reaches a 429.
Messages + live 429 (
subscriptionRetryEligiblepath): three attempts on the spent token, then a fourth dispatch with no credential — deployment key — succeeds,subscription_failover=true.OpenAI + live 429: same three attempts on the spent token, then the client gets the 429 directly. No fourth dispatch. No failover attempted at any point on this surface, preemptive or reactive.
Second guard also missing: subscription-only mode
Beyond credential injection, the two surfaces diverge on the subscription-only exhaustion guard too:
ProxyMessageschecksanthropicSubscriptionObservedExhausted(service.go:2373) and returns402with zero dispatches when the caller is subscription-only and exhausted.ProxyOpenAIChatCompletiononly checks!servedOnSubscription(service.go:4255), which doesn't account for observed exhaustion — confirmed live:proxy_err=<nil>, one dispatch, spent OAuth sent, neither a 402 nor a suppression fires.This is a distinct missing guard from the credential-injection gap above, but shares the same underlying cause:
ProxyOpenAIChatCompletionnever consults the exhaustion observer at all. Fixing the observer check in one place should let both symptoms be addressed together.Also confirmed, filed separately (not in this issue's scope)
Verification also confirmed two related but independent gaps on the same surface — recommending these be filed as their own issues rather than bundled here, to keep this one single-cause:
subscriptionRetryEligible(the live-429/401/403-triggered mid-request failover to the Weave key) has zero call sites inProxyOpenAIChatCompletion. Same fix(router): fail over on 429 / header-timeout on the subscription path #555/fix(proxy): fail over subscription OAuth 401/403 to the Weave key #588 under-wiring shape, but a genuinely separate code path from the preemptive check above — needed even in cases where the exhaustion observer hasn't caught up yet.routeRes.UsageBypass/bypassToAnthropic/errBypassRetryableare also never called on this surface.turnloopcan still setUsageBypassfor an OpenAI-wire turn, but nothing consumes it downstream.Not the bug (ruled out during verification)
turnloop); only the downstreambypassToAnthropicdispatch is missing, which is the separate finding noted above.Fix
internal/proxy/service.go,ProxyOpenAIChatCompletion, immediately before line 4241 (resolveAndInjectCredentials):Mirrors
ProxyMessagesexactly. The subscription-only402parity (service.go:4255's guard) should switch from!servedOnSubscriptionto also checkinganthropicSubscriptionObservedExhausted, matchingservice.go:2373.Regression check
go test ./internal/proxy/... -race— clean except the pre-existingTestFireTelemetryRecoversFromPanicflake (fails in isolation too, unrelated to this change).subscription_only_openai_test.go), which this change doesn't touch.Acceptance criteria
TestSubscriptionExhausted_ServesOnDeploymentKey, but forProxyOpenAIChatCompletion: exhausted snapshot + deployment key present + Anthropic-resolving decision → no OAuth credential injected, deployment key used.402-equivalent rejection with zero dispatches, not a dispatch on the spent token.