diff --git a/CHANGELOG.md b/CHANGELOG.md index cab361b79e4..76838ea551e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. - Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) - Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) +- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke. ## 2026.4.15 diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 25e575984fd..39d766889e1 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -441,6 +441,28 @@ describe("runWithModelFallback + runEmbeddedPiAgent failover behavior", () => { }); }); + it("falls back across providers after a bare leading 402 quota-refresh assistant error", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPrimaryErrorThenFallbackSuccess( + "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 result = await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:bare-402-cross-provider", + runId: "run:bare-402-cross-provider", + }); + + expect(result.provider).toBe("groq"); + expect(result.model).toBe("mock-2"); + expect(result.attempts[0]?.reason).toBe("rate_limit"); + expect(result.result.payloads?.[0]?.text ?? "").toContain("fallback ok"); + expectOpenAiThenGroqAttemptOrder(); + }); + }); + it("surfaces a bounded overloaded summary when every fallback candidate is overloaded", async () => { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 7420423d489..e9e3b81c28c 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -485,6 +485,16 @@ describe("runWithModelFallback", () => { }); }); + it("falls back on bare leading 402 quota-refresh errors", async () => { + await expectFallsBackToHaiku({ + provider: "openai", + model: "gpt-4.1-mini", + firstError: new Error( + "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.", + ), + }); + }); + it("records 400 insufficient_quota payloads as billing during fallback", async () => { const cfg = makeCfg(); const run = vi diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index e0999a1d9bb..813cb8b7cb4 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -911,6 +911,17 @@ describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () => expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit"); }); + it("classifies bare leading 402 quota-refresh payloads as rate_limit", () => { + const zenMuxMessage = + "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."; + expect(classifyFailoverReason(zenMuxMessage)).toBe("rate_limit"); + }); + + it("does not classify numeric references that merely start with 402", () => { + expect(classifyFailoverReason("402 items found in the database")).toBeNull(); + expect(classifyFailoverReason("402 records processed")).toBeNull(); + }); + it("keeps plan-upgrade 402 limit messages in billing", () => { const billingMessage = "Your usage limit has been reached. Please upgrade your plan."; expect(classifyFailoverReason(`HTTP 402 Payment Required: ${billingMessage}`)).toBe("billing"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 1b065df245d..73f50535189 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -295,6 +295,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [ ] 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; +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; const TIMEOUT_ERROR_CODES = new Set([ @@ -476,6 +477,15 @@ function hasRetryable402TransientSignal(text: string): boolean { ); } +function hasKnownBareLeading402Signal(text: string): boolean { + return ( + hasQuotaRefreshWindowSignal(text) || + hasExplicit402BillingSignal(text) || + isRateLimitErrorMessage(text) || + hasRetryable402TransientSignal(text) + ); +} + function normalize402Message(raw: string): string { return normalizeOptionalLowercaseString(raw)?.replace(LEADING_402_WRAPPER_RE, "").trim() ?? ""; } @@ -506,7 +516,14 @@ function classify402Message(message: string): PaymentRequiredFailoverReason { } function classifyFailoverReasonFrom402Text(raw: string): PaymentRequiredFailoverReason | null { - if (!RAW_402_MARKER_RE.test(raw)) { + if (RAW_402_MARKER_RE.test(raw)) { + return classify402Message(raw); + } + if (!BARE_LEADING_402_RE.test(raw)) { + return null; + } + const normalized = normalize402Message(raw); + if (!normalized || !hasKnownBareLeading402Signal(normalized)) { return null; } return classify402Message(raw); @@ -1157,6 +1174,15 @@ export function classifyFailoverReason( ): 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,