Skip to content

ProxyOpenAIChatCompletion never suppresses an exhausted Claude subscription — spent OAuth token dispatched, no failover to deployment key #761

Description

@rohith500

TL;DR

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:

Surface Credential at dispatch[0]
/v1/messages nil=true oauth=false — deploy-key fallback
/v1/chat/completions nil=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:

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:

Not the bug (ruled out during verification)

Fix

internal/proxy/service.go, ProxyOpenAIChatCompletion, immediately before line 4241 (resolveAndInjectCredentials):

if s.claudeSubscriptionExhausted(ctx, r.Header) {
    ctx = withSuppressedClaudeSubscription(ctx)
}
ctx = resolveAndInjectCredentials(ctx, decision.Provider, r.Header)

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.
  • Adding suppression here changes OpenAI→Anthropic exhausted behavior from "inject spent token" to "deploy key" — this is the intended fix(proxy): fail over to the Weave key when a Claude subscription is exhausted #519 parity, not a conflicting intentional design choice.

Acceptance criteria

  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions