diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 3bf27c21cff..4e4379bf5da 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -18,6 +18,10 @@ const GEMINI_RESOURCE_EXHAUSTED_MESSAGE = "RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. check quota)."; // OpenRouter 402 billing example: https://openrouter.ai/docs/api-reference/errors const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits"; +// Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400: +// https://github.com/openclaw/openclaw/issues/23440 +const INSUFFICIENT_QUOTA_PAYLOAD = + '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // AWS Bedrock 429 ThrottlingException / 503 ServiceUnavailable: // https://docs.aws.amazon.com/bedrock/latest/userguide/troubleshooting-api-error-codes.html const BEDROCK_THROTTLING_EXCEPTION_MESSAGE = @@ -100,6 +104,15 @@ describe("failover-error", () => { ).toBe("timeout"); }); + it("treats 400 insufficient_quota payloads as billing instead of format", () => { + expect( + resolveFailoverReasonFromError({ + status: 400, + message: INSUFFICIENT_QUOTA_PAYLOAD, + }), + ).toBe("billing"); + }); + it("infers format errors from error messages", () => { expect( resolveFailoverReasonFromError({ diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 2c58a42c99a..93310d51f8e 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -179,6 +179,10 @@ const OPENAI_RATE_LIMIT_MESSAGE = // Anthropic overloaded_error example shape: https://docs.anthropic.com/en/api/errors const ANTHROPIC_OVERLOADED_PAYLOAD = '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_test"}'; +// Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400: +// https://github.com/openclaw/openclaw/issues/23440 +const INSUFFICIENT_QUOTA_PAYLOAD = + '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // Internal OpenClaw compatibility marker, not a provider API contract. const MODEL_COOLDOWN_MESSAGE = "model_cooldown: All credentials for model gpt-5 are cooling down"; // SDK/transport compatibility marker, not a provider API contract. @@ -399,6 +403,25 @@ describe("runWithModelFallback", () => { }); }); + it("records 400 insufficient_quota payloads as billing during fallback", async () => { + const cfg = makeCfg(); + const run = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error(INSUFFICIENT_QUOTA_PAYLOAD), { status: 400 })) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("ok"); + expect(result.attempts).toHaveLength(1); + expect(result.attempts[0]?.reason).toBe("billing"); + }); + it("falls back to configured primary for override credential validation errors", async () => { const cfg = makeCfg(); const run = createOverrideFailureRun({ diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 1ca99e8a993..dd8a38d2814 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -28,6 +28,10 @@ const ANTHROPIC_OVERLOADED_PAYLOAD = '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_test"}'; // OpenRouter 402 billing example: https://openrouter.ai/docs/api-reference/errors const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits"; +// Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400: +// https://github.com/openclaw/openclaw/issues/23440 +const INSUFFICIENT_QUOTA_PAYLOAD = + '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // Together AI error code examples: https://docs.together.ai/docs/error-codes const TOGETHER_PAYMENT_REQUIRED_MESSAGE = "402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit."; @@ -531,6 +535,7 @@ describe("classifyFailoverReason", () => { ).toBe("rate_limit"); expect(classifyFailoverReason("all credentials for model x are cooling down")).toBeNull(); expect(classifyFailoverReason("invalid request format")).toBe("format"); + expect(classifyFailoverReason(INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout"); expect(classifyFailoverReason("Connection error.")).toBe("timeout"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 58ad24f953a..e4944b0731c 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -283,6 +283,11 @@ export function classifyFailoverReasonFromHttpStatus( return "rate_limit"; } if (status === 400) { + // Some providers return quota/balance errors under HTTP 400, so do not + // let the generic format fallback mask an explicit billing signal. + if (message && isBillingErrorMessage(message)) { + return "billing"; + } return "format"; } return null; diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index d1e266ff53d..abbd6e769fa 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -44,6 +44,7 @@ const ERROR_PATTERNS = { /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i, "payment required", "insufficient credits", + /insufficient[_ ]quota/i, "credit balance", "plans & billing", "insufficient balance",