diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 38e3530f011..7da28ac2fa0 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -467,4 +467,18 @@ describe("failover-error", () => { expect(described.message).toBe("123"); expect(described.reason).toBeUndefined(); }); + + it("coerces bare leading 402 assistant error string to FailoverError (#47576)", () => { + // ZenMux quota-refresh 402 arrives as a bare assistant error string — must coerce to FailoverError + // so the embedded runner can enter model fallback. + const zenmuxQuota402 = + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access."; + const err = coerceToFailoverError(new Error(zenmuxQuota402), { + provider: "zenmux", + model: "gpt-4o", + }); + expect(err).not.toBeNull(); + expect(err?.reason).toBe("rate_limit"); + expect(err?.provider).toBe("zenmux"); + }); }); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 8c0a0b1994d..78e73ac01fc 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -860,4 +860,19 @@ describe("classifyFailoverReason", () => { ), ).toBe("timeout"); }); + + it("classifies bare leading 402 assistant error strings as rate_limit (#47576)", () => { + // ZenMux quota-refresh 402 surfaces as a bare assistant error string — must enter model fallback. + expect( + classifyFailoverReason( + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access.", + ), + ).toBe("rate_limit"); + // Bare 402 with generic text should be treated as a 402 marker. + expect(classifyFailoverReason("402 Quota exceeded")).toBe("rate_limit"); + // Bare 402 with typical billing text should classify as billing. + expect( + classifyFailoverReason("402 Please add more credits to continue using this service."), + ).toBe("billing"); + }); }); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6e38d831ad9..e285e11df01 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -270,7 +270,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+\S/i; const LEADING_402_WRAPPER_RE = /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i;