diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 8c0a0b1994d..09262aa6769 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -860,4 +860,35 @@ describe("classifyFailoverReason", () => { ), ).toBe("timeout"); }); + it("does not shadow billing errors that carry api_error type", () => { + // A provider may wrap a billing error in a JSON payload with "type":"api_error". + // The billing classifier must win over the broad api_error transient match. + expect( + classifyFailoverReason( + '{"type":"error","error":{"type":"api_error","message":"insufficient credits"}}', + ), + ).toBe("billing"); + expect( + classifyFailoverReason( + '{"type":"error","error":{"type":"api_error","message":"Payment required"}}', + ), + ).toBe("billing"); + }); + it("does not shadow auth errors that carry api_error type", () => { + expect( + classifyFailoverReason( + '{"type":"error","error":{"type":"api_error","message":"invalid api key"}}', + ), + ).toBe("auth"); + expect( + classifyFailoverReason( + '{"type":"error","error":{"type":"api_error","message":"unauthorized"}}', + ), + ).toBe("auth"); + expect( + classifyFailoverReason( + '{"type":"error","error":{"type":"api_error","message":"permission_error"}}', + ), + ).toBe("auth_permanent"); + }); }); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index ce03a8482ae..10725427566 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -857,8 +857,15 @@ function isJsonApiInternalServerError(raw: string): boolean { // {"type":"error","error":{"type":"api_error","message":"Internal server error"}} // Non-standard providers (e.g. MiniMax) may use different message text: // {"type":"api_error","message":"unknown error, 520 (1000)"} - // Any api_error type indicates a provider-side failure regardless of message text. - return value.includes('"type":"api_error"'); + if (!value.includes('"type":"api_error"')) { + return false; + } + // Billing and auth errors can also carry "type":"api_error". Exclude them so + // the more specific classifiers further down the chain handle them correctly. + if (isBillingErrorMessage(raw) || isAuthErrorMessage(raw) || isAuthPermanentErrorMessage(raw)) { + return false; + } + return true; } export function parseImageDimensionError(raw: string): { @@ -1011,24 +1018,27 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { // Treat remaining transient 5xx provider failures as retryable transport issues. return "timeout"; } - if (isJsonApiInternalServerError(raw)) { - return "timeout"; - } - if (isCloudCodeAssistFormatError(raw)) { - return "format"; - } + // Billing and auth classifiers run before the broad isJsonApiInternalServerError + // check so that provider errors like {"type":"api_error","message":"insufficient + // balance"} are correctly classified as "billing"/"auth" rather than "timeout". if (isBillingErrorMessage(raw)) { return "billing"; } - if (isTimeoutErrorMessage(raw)) { - return "timeout"; - } if (isAuthPermanentErrorMessage(raw)) { return "auth_permanent"; } if (isAuthErrorMessage(raw)) { return "auth"; } + if (isJsonApiInternalServerError(raw)) { + return "timeout"; + } + if (isCloudCodeAssistFormatError(raw)) { + return "format"; + } + if (isTimeoutErrorMessage(raw)) { + return "timeout"; + } return null; }