From 50b8716ef4bd6305ffe15d9f05aed969486f40d8 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 12 Nov 2026 15:06:47 +0800 Subject: [PATCH] fix(combos): hop past Anthropic's extra-usage refusal Anthropic refuses an OAuth turn that bills against an empty extra-usage balance with HTTP 400 invalid_request_error ("You're out of extra usage" or "Third-party apps now draw from your extra usage, not your plan limits"). comboFailureDecision treated it as a terminal invalid request, so a combo ended the turn instead of reaching its next tier. Recognize the exact wording under the credit-balance evidence bar (invalid_request_error envelope, or exact prose with that structured code) and grant a target-scoped hop. Quoted, coded and other-typed variants stay terminal. Carried from the vadymhimself fork (e98765637b, 79faf0673b). Co-Authored-By: Claude Code --- src/combos/failover.ts | 3 +- src/providers/anthropic-billing-refusal.ts | 30 +++++++++++++++++-- structure/runtime.md | 2 +- .../router-anthropic-billing-failover.test.ts | 19 ++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 12c565a4524..54161fde750 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -2,7 +2,7 @@ import { parseResetCooldownMs } from "../codex/routing"; import { classifyError, isCyberPolicyCode, isTerminalRefusalCode } from "../lib/errors"; import { isHardPolicyOrOriginRefusal } from "../lib/hard-refusal"; import { isNonReplayableUpstreamCode } from "../lib/upstream-retry"; -import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal"; +import { isAnthropicBillingFailure, isAnthropicExtraUsageFailure } from "../providers/anthropic-billing-refusal"; import { hasConflictingReasoningEffortCode } from "../providers/reasoning-metadata"; import { isKiroAccountProfileRefusal } from "../adapters/kiro-refusal"; import type { OcxComboTarget } from "../types"; @@ -1273,6 +1273,7 @@ function decideComboFailure( // could have served the request (#4903). if (isResponseFormatCapabilityRefusal(status, message, rawCode)) return "hop"; if (isAnthropicBillingFailure(status, message, rawCode)) return "hop"; + if (isAnthropicExtraUsageFailure(status, message, rawCode)) return "hop"; // Kiro's body validator rejects the request for the identity it was routed under. The account // layer rotates logins first; this is the no-alternate case, so the next combo target is the // only remaining chance to serve the turn. Only a caller that knows the failed target is a Kiro diff --git a/src/providers/anthropic-billing-refusal.ts b/src/providers/anthropic-billing-refusal.ts index 6eb427b6ec8..f25e5d62b77 100644 --- a/src/providers/anthropic-billing-refusal.ts +++ b/src/providers/anthropic-billing-refusal.ts @@ -4,6 +4,9 @@ const MAX_BILLING_REFUSAL_BYTES = 8 * 1024; const BILLING_REFUSAL_DEADLINE_MS = 250; // Keep the whole-message credit-balance pattern used by anthropic-account-refusal. const CREDIT_BALANCE_REFUSAL = /^Your credit balance is too low to access the Anthropic API\.(?: Please go to Plans & Billing to upgrade or purchase credits\.)?$/i; +// Anthropic's OAuth refusal when a turn bills against an empty extra-usage balance. Exact wording +// only, as above: a validation error that quotes it back must not grant a combo hop. +const EXTRA_USAGE_REFUSAL = /^(?:You['\u2019]re out of extra usage|Third-party apps now draw from your extra usage, not your plan limits)\.(?: Add more at claude\.ai\/settings\/usage and keep going\.)?$/i; const BILLING_ERROR_TYPES = new Set(["invalid_request_error", "billing_error", "permission_error"]); const ACCOUNT_REFUSAL_CODES = new Set(["subscription_required", "insufficient_quota", "permission_denied"]); @@ -22,13 +25,17 @@ export function isAnthropicBillingRefusal(status: number, payload: unknown): boo return CREDIT_BALANCE_REFUSAL.test(error.message.trim()); } +function withoutProviderPrefix(status: number, message: string): string { + const text = message.trim(); + const prefix = `Provider error ${status}: `; + return text.startsWith(prefix) ? text.slice(prefix.length) : text; +} + /** Bounded combo evidence after existing HTTP error formatting, before any output. */ export function isAnthropicBillingFailure(status: number, message: string, code?: string | null): boolean { if (status !== 400 && status !== 402 || Buffer.byteLength(message, "utf8") > MAX_BILLING_REFUSAL_BYTES) return false; if (code != null && !BILLING_ERROR_TYPES.has(code) && !ACCOUNT_REFUSAL_CODES.has(code)) return false; - let text = message.trim(); - const prefix = `Provider error ${status}: `; - if (text.startsWith(prefix)) text = text.slice(prefix.length); + const text = withoutProviderPrefix(status, message); try { return isAnthropicBillingRefusal(status, JSON.parse(text)); } catch { /* Exact prose needs a structured type. */ } return typeof code === "string" && BILLING_ERROR_TYPES.has(code) && isAnthropicBillingRefusal(status, { type: "error", error: { type: code, message: text } }); @@ -53,3 +60,20 @@ export async function readAnthropicBillingRefusal(response: Response, signal?: A return false; } } + +/** + * A 400 extra-usage refusal says this OAuth target cannot serve the turn; a later combo target + * may. Same evidence bar as billing: an `invalid_request_error` envelope with the exact wording. + */ +export function isAnthropicExtraUsageFailure(status: number, message: string, code?: string | null): boolean { + if (status !== 400 || Buffer.byteLength(message, "utf8") > MAX_BILLING_REFUSAL_BYTES) return false; + if (code != null && code !== "invalid_request_error") return false; + const text = withoutProviderPrefix(status, message); + try { + const payload: unknown = JSON.parse(text); + return record(payload) && payload.type === "error" && record(payload.error) + && payload.error.type === "invalid_request_error" && payload.error.code == null + && typeof payload.error.message === "string" && EXTRA_USAGE_REFUSAL.test(payload.error.message.trim()); + } catch { /* Exact prose needs a structured type. */ } + return code === "invalid_request_error" && EXTRA_USAGE_REFUSAL.test(text); +} diff --git a/structure/runtime.md b/structure/runtime.md index e3b6775e8ed..dcefd85c1b3 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -488,7 +488,7 @@ Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#rena Translated audio/file admission follows the [final-adapter input contract](adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. ## Request-local target compatibility -`src/server/admission-model-scope.ts` grants metered combo eligibility to a matching configured key with literal `allowMeteredComboTargets: true`, or to local/missing admission when the selected combo has literal `allowMeteredTargets: true` and the full business config satisfies `!isApiAuthRequired(config)`. Configured and environment admissions never inherit combo consent; linked keys retain their key consent requirement even on a loopback Home. The full hostname also prevents secondary loopback listeners on public proxies from granting operator consent. `src/server/responses/core-combo.ts` filters marked targets on initial, failure and recovery selection for every strategy. A route the router collapsed to one combo pick outside that loop (a subagent fallback naming a combo alias) is re-picked among usable unmetered targets in `captureInboundRoutePolicy` of `src/server/responses/request-prepare.ts` when its pick is metered without consent, or refused if none remain; the replacement retains combo metadata and passes destination policy/scope checks. Request headers never grant consent; absent/false consent denies marked targets; provider/model scopes remain independent. `src/cli/combo-input.ts` accepts and validates the optional target boolean. Unmarked targets retain existing behavior; metering is operator metadata, not automatic billing detection. Coverage: `tests/server/combo-metered-consent.test.ts`, `tests/server/combo-loopback-metered-consent.test.ts` and `tests/adapters/anthropic/anthropic-subscription-chain.test.ts`. The exact Anthropic credit-balance refusal on HTTP 400/402 is a provider-state failure under the [Anthropic API-key billing contract](providers/anthropic-key-billing.md). `src/combos/failover.ts` recognizes complete bounded JSON or exact formatted prose with an accepted structured code before the generic invalid-request stop, granting a provider-scoped hop; malformed, truncated and unrelated 400s keep their existing decision. Google's final adapter compiler may emit an opt-in, content-free [tool-schema loss diagnostic](providers/google.md#google-tool-schema-loss-reporting). It observes adapter-local narrowing only and changes neither provider routing nor the serialized request body. +`src/server/admission-model-scope.ts` grants metered combo eligibility to a matching configured key with literal `allowMeteredComboTargets: true`, or to local/missing admission when the selected combo has literal `allowMeteredTargets: true` and the full business config satisfies `!isApiAuthRequired(config)`. Configured and environment admissions never inherit combo consent; linked keys retain their key consent requirement even on a loopback Home. The full hostname also prevents secondary loopback listeners on public proxies from granting operator consent. `src/server/responses/core-combo.ts` filters marked targets on initial, failure and recovery selection for every strategy. A route the router collapsed to one combo pick outside that loop (a subagent fallback naming a combo alias) is re-picked among usable unmetered targets in `captureInboundRoutePolicy` of `src/server/responses/request-prepare.ts` when its pick is metered without consent, or refused if none remain; the replacement retains combo metadata and passes destination policy/scope checks. Request headers never grant consent; absent/false consent denies marked targets; provider/model scopes remain independent. `src/cli/combo-input.ts` accepts and validates the optional target boolean. Unmarked targets retain existing behavior; metering is operator metadata, not automatic billing detection. Coverage: `tests/server/combo-metered-consent.test.ts`, `tests/server/combo-loopback-metered-consent.test.ts` and `tests/adapters/anthropic/anthropic-subscription-chain.test.ts`. The exact Anthropic credit-balance refusal on HTTP 400/402 is a provider-state failure under the [Anthropic API-key billing contract](providers/anthropic-key-billing.md). `src/combos/failover.ts` recognizes complete bounded JSON or exact formatted prose with an accepted structured code before the generic invalid-request stop, granting a provider-scoped hop; malformed, truncated and unrelated 400s keep their existing decision. Anthropic's exact OAuth extra-usage refusal on HTTP 400 ("You're out of extra usage" or "Third-party apps now draw from your extra usage, not your plan limits", optionally followed by the "Add more at claude.ai/settings/usage" sentence) is recognized by `isAnthropicExtraUsageFailure` in `src/providers/anthropic-billing-refusal.ts` under the same evidence bar (an `invalid_request_error` envelope, or the exact prose with that structured code). It grants a target-scoped hop before the invalid-request stop, so a combo reaches its next tier instead of ending the turn; quoted, coded and other-typed variants stay terminal (`tests/routing/router-anthropic-billing-failover.test.ts`). Google's final adapter compiler may emit an opt-in, content-free [tool-schema loss diagnostic](providers/google.md#google-tool-schema-loss-reporting). It observes adapter-local narrowing only and changes neither provider routing nor the serialized request body. `src/adapters/openai-responses.ts` omits top-level `user` and `safety_identifier` at the canonical ChatGPT Codex forward destination. The backend rejects both with its strict-allowlist `{"detail":"Unsupported parameter: …"}` envelope, and canonical forwarding removes `safety_identifier` regardless of which client supplied it — third-party clients (droid's registry-matched BYOK) attach it from the model name alone, but any Responses client may send the public OpenAI-platform parameter explicitly. Claude translation retains its original identity and prompt-cache key; public API and noncanonical gateways retain their `user` and `safety_identifier` fields. Input roles, tool-schema properties and original replay bodies are not changed. diff --git a/tests/routing/router-anthropic-billing-failover.test.ts b/tests/routing/router-anthropic-billing-failover.test.ts index a34be36cc1a..fbb86a50e38 100644 --- a/tests/routing/router-anthropic-billing-failover.test.ts +++ b/tests/routing/router-anthropic-billing-failover.test.ts @@ -28,3 +28,22 @@ test("billing prose without an accepted code, malformed JSON and unrelated refus expect(comboFailureDecision(499, envelope(), { code: "invalid_request_error" })).toBe("stop"); expect(comboFailureDecision(400, envelope(), { code: "origin_rejected" })).toBe("stop"); }); + +test("Anthropic's exact extra-usage refusal hops a combo; quoted or coded variants stay terminal", () => { + const refusals = [ + "You're out of extra usage. Add more at claude.ai/settings/usage and keep going.", + "You’re out of extra usage.", + "Third-party apps now draw from your extra usage, not your plan limits. Add more at claude.ai/settings/usage and keep going.", + ]; + for (const text of refusals) { + for (const carrier of [envelope("invalid_request_error", text), `Provider error 400: ${envelope("invalid_request_error", text)}`]) { + expect(comboFailureDecision(400, carrier)).toBe("hop"); + expect(comboFailureCooldownScope(400, carrier, { code: "invalid_request_error" })).toBe("target"); + } + expect(comboFailureDecision(400, text, { code: "invalid_request_error" })).toBe("hop"); + expect(comboFailureDecision(400, text)).toBe("stop"); + expect(comboFailureDecision(400, envelope("api_error", text))).toBe("stop"); + expect(comboFailureDecision(400, envelope("invalid_request_error", text, "context_length_exceeded"))).toBe("stop"); + } + expect(comboFailureDecision(400, envelope("invalid_request_error", `Diagnostic: ${refusals[0]}`))).toBe("stop"); +});