fix(agents): classify insufficient_quota 400s as billing (#36783)

This commit is contained in:
Altay 2026-03-06 01:17:48 +03:00 committed by GitHub
parent 0c08e3f55f
commit 49acb07f9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 47 additions and 0 deletions

View File

@ -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({

View File

@ -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({

View File

@ -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");

View File

@ -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;

View File

@ -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",