fix(failover): classify HTTP 422 as format and OpenRouter credits as billing (#43823)

Merged via squash.

Prepared head SHA: 4f48e977fe
Co-authored-by: jnMetaCode <12096460+jnMetaCode@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
jnMetaCode 2026-03-13 05:50:28 +08:00 committed by GitHub
parent 268e036172
commit 7332e6d609
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 59 additions and 1 deletions

View File

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

View File

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

View File

@ -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", () => {

View File

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

View File

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