mirror of https://github.com/openclaw/openclaw.git
fix(agents): classify insufficient_quota 400s as billing (#36783)
This commit is contained in:
parent
0c08e3f55f
commit
49acb07f9f
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue