Repository navigation
feat(anthropic): fail over to spare Claude API keys #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| """Several keys for one provider: use the first that is not resting, and rest refused ones. | ||
|
|
||
| Codex Everywhere pools accept a base key and numbered spares (`<BASE>`, `<BASE>_1`, | ||
| `<BASE>_2`, ...). Each request uses the first key in that order that is not resting. A key | ||
| Codex Everywhere and Claude API pools accept a base key and numbered spares (`<BASE>`, | ||
| `<BASE>_1`, `<BASE>_2`, ...). Each request uses the first key in that order that is not resting. A key | ||
| the provider refuses (401, 402 or 403, which includes an empty balance) rests for five | ||
| minutes and a rate-limited key (429) for one minute, so traffic moves to the next key and | ||
| returns once the rest ends. State is process-local. | ||
|
|
@@ -30,6 +30,7 @@ | |
| POOLED_KEY_ENVS = { | ||
| **{pool.provider: pool.credential_env for pool in CODEX_EVERYWHERE_POOLS}, | ||
| "ce-image": "CODEX_EVERYWHERE_API_KEY_GPT_IMAGE", | ||
| "anthropic": "ANTHROPIC_API_KEY", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With production's Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
|
|
||
|
|
@@ -264,21 +265,20 @@ def record_headers( | |
| usage_windows: list[Mapping] | None = None, | ||
| now: float | None = None, | ||
| ) -> None: | ||
| """Record the result for the bearer key a request carried.""" | ||
| """Record the result for the bearer or X-Api-Key credential a request carried.""" | ||
| if provider not in POOLED_KEY_ENVS: | ||
| return | ||
| authorization = next( | ||
| ( | ||
| value | ||
| for name, value in headers.items() | ||
| if name.lower() == "authorization" | ||
| ), | ||
| "", | ||
| ) | ||
| sent = {name.lower(): value for name, value in headers.items()} | ||
| authorization = sent.get("authorization", "") | ||
| if isinstance(authorization, str) and authorization.startswith("Bearer "): | ||
| key = authorization[len("Bearer ") :].strip() | ||
| else: | ||
| api_key = sent.get("x-api-key") | ||
| key = api_key.strip() if isinstance(api_key, str) else "" | ||
| if key: | ||
| cls.record( | ||
| provider, | ||
| authorization[len("Bearer ") :].strip(), | ||
| key, | ||
| status, | ||
| model=model, | ||
| quota_bucket=quota_bucket, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,11 @@ | |
| from services.transport_policy import RAW_PASSTHROUGH_PROVIDERS | ||
| from services.secret_firewall import protect_body | ||
| from providers.cline_pass import cline_completion_payload | ||
| from providers.anthropic import with_anthropic_headers | ||
| from providers.anthropic import ( | ||
| ANTHROPIC_REFUSAL_REST_SECONDS, | ||
| anthropic_credential_refusal, | ||
| with_anthropic_headers, | ||
| ) | ||
| from services.provider_request_headers import filter_upstream_headers | ||
| from providers.codex_everywhere import ( | ||
| CODEX_EVERYWHERE_ANTHROPIC_PROVIDERS, | ||
|
|
@@ -3325,9 +3329,20 @@ def make_request( | |
| ) | ||
| from services.credential_context import dispatch_context, observation_context | ||
| from services.pool_reset_schedule import response_usage_observation | ||
| CredentialPool.record_headers(api_provider, headers, response.status_code, | ||
| **(cooldown_context or dispatch_context(api_provider, data, url)), | ||
| **observation_context(response), **response_usage_observation(response)) | ||
| observation = {**observation_context(response), **response_usage_observation(response)} | ||
| status = response.status_code | ||
| if api_provider == "anthropic" and (refusal := anthropic_credential_refusal(response)): | ||
| # The account, not this request, was refused: rest its key so the | ||
| # rotation loops and later requests move to the next configured key. | ||
| response.multillm_credential_refusal = refusal | ||
| status = 402 | ||
| observation.update( | ||
| outcome=UpstreamOutcome(False, "neutral", "rejected", f"anthropic_{refusal}"), | ||
| credential_wide_auth=True, | ||
| retry_after_seconds=ANTHROPIC_REFUSAL_REST_SECONDS, | ||
| ) | ||
| CredentialPool.record_headers(api_provider, headers, status, | ||
| **(cooldown_context or dispatch_context(api_provider, data, url)), **observation) | ||
|
Comment on lines
+3344
to
+3345
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For accounts with an administrator-owned data policy, such as Useful? React with 👍 / 👎. |
||
| return response | ||
|
|
||
| # Check if this is a streaming request | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When keys belong to different organizations, a conversation started on the spare while the primary is resting will silently switch back to the primary once its cooldown expires. The advertised affinity protection does not cover native
/anthropic/v1/messagesor explicit unified Anthropic requests:request_scope()inservices/prompt_cache_affinity.pyonly creates scopes for unifiedauto:selectors, andprefer_credential()returns nothing without an active scope and candidate. Consequently, even with production affinity enabled, replaying the spare organization's signed thinking blocks after the primary recovers produces an invalid-signature 400, which is not retried. Extend conversation-to-credential binding to these routes so recovery does not move an otherwise healthy conversation between organizations.Useful? React with 👍 / 👎.