fix(llm): classify pi-ai failures by the leading HTTP status

pi-ai's SDK-backed providers render an HTTP failure as `<status> <body>`.
A gateway 500 that embeds its upstream's 401 in the body matched the AUTH
rule before the 5xx rule, so Chat reported an invalid API key and the
retry policy gave up. When a message leads with a 4xx/5xx status, the
status rules now read only that status; other wordings still scan the
whole message.

Refs #8182
This commit is contained in:
Yichen Jiang committed 2026-11-09 16:54:57 +08:00
1 parent 5a18be1c9a
commit d1711894c8
2 files changed
+26 -5

No files matched your search

+14 -5
View File
@@ -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 `<status> <body>`
// (Anthropic and OpenAI SDK `APIError`, pi-messages `<status> <statusText>: …`).
// 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
@@ -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":"<nil>","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',