diff --git a/CHANGELOG.md b/CHANGELOG.md index dcce9520c34..7e15a973c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -196,6 +196,7 @@ Docs: https://docs.openclaw.ai - ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692) - Agents/embedded runner: recover canonical allowlisted tool names from malformed `toolCallId` and malformed non-blank tool-name variants before dispatch, while failing closed on ambiguous matches. (#34485) thanks @yuweuii. - Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke. +- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode. ## 2026.3.8 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 16f5b1d5fc8..1ddd1d9ceef 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -69,6 +69,7 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format"); + expect(resolveFailoverReasonFromError({ status: 422 })).toBe("format"); // Keep the status-only path behavior-preserving and conservative. expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull(); expect(resolveFailoverReasonFromError({ status: 502 })).toBe("timeout"); @@ -162,6 +163,44 @@ describe("failover-error", () => { ).toBe("billing"); }); + it("treats HTTP 422 as format error", () => { + expect( + resolveFailoverReasonFromError({ + status: 422, + message: "check open ai req parameter error", + }), + ).toBe("format"); + expect( + resolveFailoverReasonFromError({ + status: 422, + message: "Unprocessable Entity", + }), + ).toBe("format"); + }); + + it("treats 422 with billing message as billing instead of format", () => { + expect( + resolveFailoverReasonFromError({ + status: 422, + message: "insufficient credits", + }), + ).toBe("billing"); + }); + + it("classifies OpenRouter 'requires more credits' text as billing", () => { + expect( + resolveFailoverReasonFromError({ + message: "This model requires more credits to use", + }), + ).toBe("billing"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message: "This model require more credits", + }), + ).toBe("billing"); + }); + it("treats zhipuai weekly/monthly limit exhausted as rate_limit", () => { expect( resolveFailoverReasonFromError({ diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 205a90d8dda..3cbefadbce8 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -110,6 +110,9 @@ describe("isBillingErrorMessage", () => { // Venice returns "Insufficient USD or Diem balance" which has extra words // between "insufficient" and "balance" "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", + // OpenRouter returns "requires more credits" for underfunded accounts + "This model requires more credits to use", + "This endpoint require more credits", ]; for (const sample of samples) { expect(isBillingErrorMessage(sample)).toBe(true); @@ -503,6 +506,18 @@ describe("isTransientHttpError", () => { }); describe("classifyFailoverReasonFromHttpStatus", () => { + it("treats HTTP 422 as format error", () => { + expect(classifyFailoverReasonFromHttpStatus(422)).toBe("format"); + expect(classifyFailoverReasonFromHttpStatus(422, "check open ai req parameter error")).toBe( + "format", + ); + expect(classifyFailoverReasonFromHttpStatus(422, "Unprocessable Entity")).toBe("format"); + }); + + it("treats 422 with billing message as billing instead of format", () => { + expect(classifyFailoverReasonFromHttpStatus(422, "insufficient credits")).toBe("billing"); + }); + it("treats HTTP 499 as transient for structured errors", () => { expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout"); expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout"); @@ -718,6 +733,8 @@ describe("classifyFailoverReason", () => { "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", ), ).toBe("billing"); + // OpenRouter "requires more credits" billing text + expect(classifyFailoverReason("This model requires more credits to use")).toBe("billing"); }); it("classifies internal and compatibility error messages", () => { diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index ba42c77da13..6e38d831ad9 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -431,7 +431,7 @@ export function classifyFailoverReasonFromHttpStatus( if (status === 529) { return "overloaded"; } - if (status === 400) { + if (status === 400 || status === 422) { // 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)) { diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index 98c579708ee..9f6e83e9461 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -60,6 +60,7 @@ const ERROR_PATTERNS = { "plans & billing", "insufficient balance", "insufficient usd or diem balance", + /requires?\s+more\s+credits/i, ], authPermanent: [ /api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i,