Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/combos/failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { parseResetCooldownMs } from "../codex/routing";
import { CYBER_POLICY_FALLBACK_MESSAGE, TERMINAL_REFUSAL_FALLBACK_MESSAGE, classifyError, isCyberPolicyCode, isCyberPolicyMessage, isRateLimitOrQuotaFailureMessage, isSubscriptionGateMessage, isTerminalRefusalCode, safetyRefusalCodeFromMessage } 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";
Expand Down Expand Up @@ -1601,6 +1601,7 @@ function decideComboFailure(
if (isResponseFormatCapabilityRefusal(status, message, rawCode)) return "hop";
if (providerModerationHop(status, message, rawCode, options?.providerModerationRefusal, options?.malformedResponseFormat)) 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
Expand Down
30 changes: 27 additions & 3 deletions src/providers/anthropic-billing-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand All @@ -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 } });
Expand All @@ -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);
}
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions tests/routing/router-anthropic-billing-failover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading