diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 58bd2e8e3e..59bfdce550 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -39,15 +39,24 @@ export function mapUsage(usage: PiUsage): TokenUsage { // wrapper a bare `terminated`, so we are left pattern-matching terse words here. // If pi-ai ever forwards the original Error (or a fetch/dispatcher hook that lets // us capture the cause ourselves), classify on `code`/`cause` instead of text. +// +// pi-ai's SDK-backed providers render an HTTP failure as ` ` +// (Anthropic and OpenAI SDK `APIError`, pi-messages ` : …`). +// The body can quote another hop's status, such as a gateway 500 that embeds its +// upstream's 401, so when a message leads with a status the status rules read +// only that status; other wordings scan the whole message. +const LEADING_HTTP_STATUS = /^[45]\d\d\b/ + function classifyPiAiError(message: string): BuiltinLlmFailureCode { - if (/\b(?:401|403)\b/.test(message)) return 'AUTH' + const statusSource = LEADING_HTTP_STATUS.exec(message)?.[0] ?? message + if (/\b(?:401|403)\b/.test(statusSource)) return 'AUTH' if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE - if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT' + if (/\b429\b/.test(statusSource) || /rate.?limit/i.test(message)) return 'RATE_LIMIT' // A rejected request body (gateway or provider size cap): resending the // same request cannot succeed, so it is invalid, not transient. - if (/\b413\b|failed to buffer the request body:\s*length limit exceeded|payload too large|request body too large/i.test(message)) return 'INVALID_REQUEST' - if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' - if (/\b5\d\d\b/.test(message)) return 'SERVER' + if (/\b413\b/.test(statusSource) || /failed to buffer the request body:\s*length limit exceeded|payload too large|request body too large/i.test(message)) return 'INVALID_REQUEST' + if (/\b400\b/.test(statusSource) || /invalid.?request/i.test(message)) return 'INVALID_REQUEST' + if (/\b5\d\d\b/.test(statusSource)) return 'SERVER' if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT' // A stream truncated before the provider's terminal event: each pi-ai provider // throws its own wording when the wire closes mid-response without a terminal diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index fe6d72a2b4..ebdf64fdb0 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -939,6 +939,18 @@ describe('mapStopReason / mapUsage', () => { }))).toMatchObject({ kind: 'error', failure: { code: 'PI_AI_ERROR' } }) }) + it.each([ + // A gateway 500 that relays its upstream's 401 in the body, as the Anthropic SDK renders it. + ['500 {"error":{"origin":"upstream","message":"upstream returned an error response: User not found.","type":"upstream_error","code":"upstream_response_error","upstream":{"provider":"custom","status":401,"body":{"error":{"type":"","message":"User not found."},"type":"error"}}}}', 'SERVER'], + ['502 {"error":{"message":"upstream status 400"}}', 'SERVER'], + ['404 {"error":{"message":"upstream status 403"}}', 'PI_AI_ERROR'], + ['401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}', 'AUTH'], + ['429 Too Many Requests: slow down', 'RATE_LIMIT'], + ] as const)('classifies %s by its leading response status', (errorMessage, code) => { + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage }))) + .toMatchObject({ kind: 'error', failure: { code } }) + }) + it.each([ 'other side closed', 'HTTP2 request did not get a response',