diff --git a/CHANGELOG.md b/CHANGELOG.md index ac347c335d9..558a40b6259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai - Browser plugin: trust managed Chrome CDP diagnostics when launch HTTP probes race cold-start readiness, avoiding false startup failures. Fixes #82904. (#82986) Thanks @kmanan and @hclsys. - Android: prompt before replacing a changed Gateway TLS thumbprint, showing the old and new SHA-256 fingerprints so users can accept expected certificate rotations instead of hard failing on pin mismatch. (#83077) Thanks @sliekens. - CLI/status: render extra gateway-like service diagnostics as warning/info output instead of error output. Fixes #46930. (#82922) thanks @giodl73-repo. +- Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23. ## 2026.5.17 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index cd8b536c854..e9a4ebce493 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -21,6 +21,9 @@ const GEMINI_RESOURCE_EXHAUSTED_MESSAGE = "RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. check quota)."; // OpenRouter 402 billing example: https://openrouter.ai/docs/api-reference/errors const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits"; +// Issue-backed Moonshot/Kimi exhausted-balance shape surfaced under HTTP 429 (#43447). +const MOONSHOT_INSUFFICIENT_BALANCE_429_PAYLOAD = + '{"error":{"type":"rate_limit_reached","message":"Insufficient account balance. Please recharge your Moonshot account."}}'; const OPENROUTER_MODEL_NOT_FOUND_PAYLOAD = '{"error":{"message":"Healer Alpha was a stealth model revealed on March 18th as an early testing version of MiMo-V2-Omni. Find it here: https://openrouter.ai/xiaomi/mimo-v2-omni","code":404},"user_id":"user_33GTyP8uDSYYbaeBO48AGHXyuMC"}'; const TOGETHER_MONTHLY_SPEND_CAP_MESSAGE = @@ -293,6 +296,46 @@ describe("failover-error", () => { ).toBe("overloaded"); }); + it("lets Moonshot/Kimi billing-shaped 429 payloads win over generic rate limit status", () => { + expect( + resolveFailoverReasonFromError({ + provider: "moonshot", + status: 429, + message: MOONSHOT_INSUFFICIENT_BALANCE_429_PAYLOAD, + }), + ).toBe("billing"); + expect( + resolveFailoverReasonFromError( + { + status: 429, + message: MOONSHOT_INSUFFICIENT_BALANCE_429_PAYLOAD, + }, + "kimi-claw", + ), + ).toBe("billing"); + expect( + resolveFailoverReasonFromError({ + provider: "moonshot", + status: 429, + message: OPENAI_RATE_LIMIT_MESSAGE, + }), + ).toBe("rate_limit"); + expect( + resolveFailoverReasonFromError({ + provider: "openai", + status: 429, + message: MOONSHOT_INSUFFICIENT_BALANCE_429_PAYLOAD, + }), + ).toBe("rate_limit"); + expect( + classifyFailoverSignal({ + provider: "moonshot", + status: 429, + message: MOONSHOT_INSUFFICIENT_BALANCE_429_PAYLOAD, + }), + ).toEqual({ kind: "reason", reason: "billing" }); + }); + it("classifies OpenRouter no-endpoints 404s as model_not_found", () => { expect( resolveFailoverReasonFromError({ diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 9dca15a9b30..9a5b38d3076 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -612,7 +612,13 @@ export function classifyFailoverReasonFromHttpStatus( ? classifyFailoverClassificationFromMessage(message, opts?.provider) : null; return failoverReasonFromClassification( - classifyFailoverClassificationFromHttpStatus(status, message, messageClassification, status), + classifyFailoverClassificationFromHttpStatus( + status, + message, + messageClassification, + status, + opts?.provider, + ), ); } @@ -621,6 +627,7 @@ function classifyFailoverClassificationFromHttpStatus( message: string | undefined, messageClassification: FailoverClassification | null, explicitStatus: number | undefined, + provider?: string, ): FailoverClassification | null { const messageReason = failoverReasonFromClassification(messageClassification); if (typeof status !== "number" || !Number.isFinite(status)) { @@ -644,6 +651,13 @@ function classifyFailoverClassificationFromHttpStatus( return toReasonClassification(classify402Message(message)); } if (status === 429) { + if ( + message && + (isProvider(provider, "moonshot") || isProvider(provider, "kimi")) && + isBillingErrorMessage(message) + ) { + return toReasonClassification("billing"); + } return toReasonClassification("rate_limit"); } if (status === 401 || status === 403) { @@ -910,6 +924,7 @@ export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassifi signal.message, messageClassification, signal.status, + signal.provider, ); if (statusClassification) { return statusClassification;