From ef3f9796c8bf6fc7fba5b56032686ad4ebca93ab Mon Sep 17 00:00:00 2001 From: junyuc25 <10862251+junyuc25@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:38:04 -0700 Subject: [PATCH] fix(failover): widen raw 402 detection for third-party proxy messages (#45827) Merged via squash. Prepared head SHA: 5f4b5d728375f173b9edcbafe727acbb1d947fc6 Co-authored-by: junyuc25 <10862251+junyuc25@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + ...dded-helpers.isbillingerrormessage.test.ts | 14 ++++++++ src/agents/pi-embedded-helpers/errors.ts | 34 ++++++++++--------- .../pi-embedded-helpers/failover-matches.ts | 2 ++ 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78597287113..405e04f2745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent. - Matrix: honor `channels.matrix.network.dangerouslyAllowPrivateNetwork` when creating clients for private-network homeservers. (#68332) Thanks @kagura-agent. - Cron/message tool: keep cron-owned runs with `delivery.mode: "none"` on the normal message-tool path so they can still send explicit messages, create threads, and route conditionally when no runner-owned delivery target is active. (#68482) Thanks @obviyus. +- Agents/failover: avoid treating bare leading `402 ...` prose as billing errors while still recognizing proxy subscription failures. (#45827) Thanks @junyuc25. ## 2026.4.15 diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index fc1ec760752..94e5d67adab 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -923,6 +923,9 @@ describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () => expect(classifyFailoverReasonFromHttpStatus(402, undefined)).toBe("billing"); expect(classifyFailoverReasonFromHttpStatus(402, "")).toBe("billing"); expect(classifyFailoverReasonFromHttpStatus(402, "Payment required")).toBe("billing"); + expect(classifyFailoverReasonFromHttpStatus(402, "402 custom proxy billing failure")).toBe( + "billing", + ); }); it("matches raw 402 wrappers and status-split payloads for the same message", () => { @@ -938,6 +941,9 @@ describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () => it("keeps explicit 402 rate-limit messages in the rate_limit lane", () => { const transientMessage = "rate limit exceeded"; + expect(classifyFailoverReasonFromHttpStatus(402, `402: ${transientMessage}`)).toBe( + "rate_limit", + ); expect(classifyFailoverReason(`HTTP 402 Payment Required: ${transientMessage}`)).toBe( "rate_limit", ); @@ -1014,6 +1020,14 @@ describe("classifyFailoverReason", () => { "402 You've used up your points! Visit https://poe.com/api/keys to get more.", ), ).toBe("billing"); + // Third-party proxy 402 with non-standard wording (#45774) + expect( + classifyFailoverReason( + "402 No available asset for API access, please purchase a subscription", + ), + ).toBe("billing"); + expect(classifyFailoverReason("402 items found in the database")).toBeNull(); + expect(classifyFailoverReason("402 room is available")).toBeNull(); expect(classifyFailoverReason(INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index ab78cc8cc0c..2e8ae9f2536 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -298,7 +298,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [ "exhausted", ] as const; const RAW_402_MARKER_RE = - /["']?(?: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 required\b|^\s*402\s+.*used up your points\b/i; + /["']?(?: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 required\b|.*used up your points\b|no available asset for api access\b)/i; const BARE_LEADING_402_RE = /^\s*402\b/i; const LEADING_402_WRAPPER_RE = /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i; @@ -464,7 +464,6 @@ function isOAuthCallbackTimeoutMessage(raw: string): boolean { function isOAuthCallbackValidationMessage(raw: string): boolean { return /\bcallback_validation_failed\b/i.test(raw); } - function includesAnyHint(text: string, hints: readonly string[]): boolean { return hints.some((hint) => text.includes(hint)); } @@ -584,7 +583,7 @@ export function classifyFailoverReasonFromHttpStatus( ? classifyFailoverClassificationFromMessage(message, opts?.provider) : null; return failoverReasonFromClassification( - classifyFailoverClassificationFromHttpStatus(status, message, messageClassification), + classifyFailoverClassificationFromHttpStatus(status, message, messageClassification, status), ); } @@ -592,6 +591,7 @@ function classifyFailoverClassificationFromHttpStatus( status: number | undefined, message: string | undefined, messageClassification: FailoverClassification | null, + explicitStatus: number | undefined, ): FailoverClassification | null { const messageReason = failoverReasonFromClassification(messageClassification); if (typeof status !== "number" || !Number.isFinite(status)) { @@ -599,7 +599,20 @@ function classifyFailoverClassificationFromHttpStatus( } if (status === 402) { - return toReasonClassification(message ? classify402Message(message) : "billing"); + if (!message) { + return toReasonClassification("billing"); + } + const leadingStatus = extractLeadingHttpStatus(message.trim()); + if (leadingStatus?.code === 402) { + const reasonFrom402Text = classifyFailoverReasonFrom402Text(message); + if (reasonFrom402Text) { + return toReasonClassification(reasonFrom402Text); + } + return typeof explicitStatus === "number" + ? toReasonClassification(classify402Message(message)) + : messageClassification; + } + return toReasonClassification(classify402Message(message)); } if (status === 429) { return toReasonClassification("rate_limit"); @@ -828,6 +841,7 @@ export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassifi inferredStatus, signal.message, messageClassification, + signal.status, ); if (statusClassification) { return statusClassification; @@ -1239,20 +1253,8 @@ export function classifyFailoverReason( raw: string, opts?: { provider?: string }, ): FailoverReason | null { - const trimmed = raw.trim(); - const leadingStatus = extractLeadingHttpStatus(trimmed); - const reasonFrom402Text = - leadingStatus?.code === 402 ? classifyFailoverReasonFrom402Text(trimmed) : null; - if ( - leadingStatus?.code === 402 && - !reasonFrom402Text && - !isHtmlErrorResponse(trimmed, leadingStatus.code) - ) { - return null; - } return failoverReasonFromClassification( classifyFailoverSignal({ - status: leadingStatus?.code, message: raw, provider: opts?.provider, }), diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index 14d7ede0b1c..efa5ff62166 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -217,6 +217,8 @@ export function isBillingErrorMessage(raw: string): boolean { value.includes("upgrade") || value.includes("credits") || value.includes("payment") || + value.includes("purchase") || + value.includes("subscription") || value.includes("plan") ); }