fix(llm-pi-ai): classify transport truncations

Personal customization replayed onto upstream master source.
This commit is contained in:
Turtle
2026-07-27 17:53:18 +08:00
parent d23621f922
commit 6a7307de37
5 changed files with 105 additions and 1 deletions

View File

@@ -28,6 +28,14 @@ export function mapUsage(usage: PiUsage): TokenUsage {
}
}
// XXX(pi-ai upstream): pi-ai flattens the caught error to `error.message`
// (api/anthropic-messages.js: `errorMessage = error instanceof Error ?
// error.message : JSON.stringify(error)`), discarding the original Error and its
// `cause` chain before it reaches us. undici carries the actionable transport
// detail on `cause` (e.g. `SocketError: other side closed`) but hands the fetch
// 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.
function classifyPiAiError(message: string): string {
if (/\b(?:401|403)\b/.test(message)) return 'AUTH'
if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE
@@ -35,8 +43,19 @@ function classifyPiAiError(message: string): string {
if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST'
if (/\b5\d\d\b/.test(message)) 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
// event (`… stream ended before message_stop`, `… before a terminal response
// event`, `… ended without a terminal event`, `Stream ended without
// finish_reason`). The connection dropped mid-response, so this is a transport
// truncation, not a model-level error.
if (/stream ended (?:before|without)\b/i.test(message)) return 'TRANSPORT'
if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)) {
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)
// undici renders a mid-stream socket drop as a bare `terminated` (its
// `cause` — the real SocketError — was flattened away upstream); Node's
// stream layer says `Premature close`.
|| /\bterminated\b|premature close/i.test(message)) {
return 'TRANSPORT'
}
return 'PI_AI_ERROR'

View File

@@ -578,6 +578,15 @@ describe('mapStopReason / mapUsage', () => {
'other side closed',
'HTTP2 request did not get a response',
'WebSocket closed unexpectedly',
// undici flattens a mid-stream socket drop to this bare word (its SocketError
// cause is discarded upstream before it reaches us).
'terminated',
'Premature close',
// pi-ai's per-provider throws when the wire closes before the terminal event.
'Anthropic stream ended before message_stop',
'OpenAI Responses stream ended before a terminal response event',
'openrouter stream ended without a terminal event',
'Stream ended without finish_reason',
])('maps pi-ai transport wording %j', (errorMessage) => {
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage })))
.toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } })