Classify pi-ai transport truncations from flattened message text
从扁平化的消息文本中分类 pi-ai 传输层截断
A TUI run whose model connection dropped mid-stream surfaced the single notice `terminated`, and a truncated Anthropic response surfaced `Anthropic stream ended before message_stop`. Both are transport truncations — the connection died before the provider's terminal SSE event — yet `classifyPiAiError` in `dsh-llm-pi-ai` mapped neither, falling through to the catch-all `PI_AI_ERROR`. Because `PI_AI_ERROR` is not in `l
English
Problem
A TUI run whose model connection dropped mid-stream surfaced the single notice terminated, and a truncated Anthropic response surfaced Anthropic stream ended before message_stop. Both are transport truncations — the connection died before the provider's terminal SSE event — yet classifyPiAiError in dsh-llm-pi-ai mapped neither, falling through to the catch-all PI_AI_ERROR. Because PI_AI_ERROR is not in llm-retry's DEFAULT_RETRYABLE_CODES (RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT), a recoverable drop was treated as a permanent failure and never retried.
The detail loss is upstream and unrecoverable in the adapter: pi-ai reduces a caught error to error.message (api/anthropic-messages.js: errorMessage = error instanceof Error ? error.message : JSON.stringify(error)) before pushing the terminal error event, discarding the original Error and its cause chain. undici carries the actionable SocketError on cause but hands the fetch wrapper a bare terminated; pi-ai keeps only that word. pi-ai SimpleStreamOptions exposes no fetch/dispatcher/client hook we could use to capture the cause ourselves before it is flattened.
Decision
classifyPiAiErrorrecognizes two more transport wordings and maps both toTRANSPORT:- a mid-stream socket drop rendered as a bare
terminated(undici) orPremature close(Node stream layer); - a stream truncated before its terminal event, which each pi-ai provider throws with its own wording (
Anthropic stream ended before message_stop,… before a terminal response event,… ended without a terminal event,Stream ended without finish_reason), matched onstream ended before/without.
- a mid-stream socket drop rendered as a bare
- The classifier carries an
XXX(pi-ai upstream)note naming the flattening site and stating the intended fix: classify oncode/causeif pi-ai ever forwards the originalErroror a hook that lets us capture thecause. Classification stays best-effort text matching until then. llm-pi-ai/README.mdgains a Known-Limitations bullet recording that pi-ai flattens the cause chain and that harness codes are therefore classified from message text.
Classification stays on message text because that is the only signal pi-ai delivers; the XXX marks it as a workaround, not the desired end state.
Alternatives considered
Capture the cause via a pi-ai fetch/dispatcher/client hook. Rejected: pi-ai 0.81.1 exposes none. StreamOptions offers only onPayload/onResponse; onResponse fires before the body stream is consumed, so it cannot observe a mid-stream drop. The Anthropic path accepts a client object, but constructing and injecting a provider SDK client per request to intercept transport errors reaches around the adapter boundary for one diagnostic string.
Leave both as PI_AI_ERROR and widen llm-retry's retryable set. Rejected: PI_AI_ERROR is the catch-all for genuinely unclassified failures, including non-retryable ones (a malformed provider response, an unexpected SDK bug). Making the catch-all retryable would retry failures that will never succeed; the fix is to classify the recoverable case, not to blur the bucket.
Wrap the flattened error in an LlmError('TRANSPORT', { cause }) in the adapter, mirroring the DeepSeek adapter. Rejected here: the DeepSeek adapter wraps a pre-response fetch rejection whose cause is still intact, so chaining preserves real detail. In the pi-ai path the terminal event's errorMessage is already a flattened string with no cause to chain, so wrapping would add a layer without recovering anything; classifying the code is the only value left to add.
Consequences
- A mid-stream transport drop and a pre-terminal stream truncation now carry
TRANSPORT, so a composedllm-retrypolicy retries them by default instead of failing the turn. - The notice text is unchanged (
terminated/Anthropic stream ended before message_stop): the cause detail is gone before the adapter sees it, soerrorChainhas nothing more to render. Only the routedcodeimproved. - Classification remains string-matching and provider-wording-dependent: a future pi-ai release that rewords these errors would silently fall back to
PI_AI_ERRORuntil the patterns are updated. TheXXXnote points at the durable fix (route on a forwardedcode/cause).
中文
问题
一次 TUI 运行的模型连接在流式输出中途断开,只浮现出一条 terminated 通知,而一个被截断的 Anthropic 响应则浮现出 Anthropic stream ended before message_stop。两者都是传输层截断——连接在提供方的终止 SSE(Server-Sent Events)事件之前就已断开——然而 dsh-llm-pi-ai 中的 classifyPiAiError 对两者都不匹配,最终落入兜底的 PI_AI_ERROR。由于 PI_AI_ERROR 不在 llm-retry 的 DEFAULT_RETRYABLE_CODES(RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT)中,一次可恢复的断开被当作永久性失败处理,从未被重试。
细节丢失发生在上游,且在适配器内无法恢复:pi-ai 在推送终止 error 事件之前,把捕获到的错误缩减为 error.message(api/anthropic-messages.js:errorMessage = error instanceof Error ? error.message : JSON.stringify(error)),丢弃了原始的 Error 及其 cause 链。undici 将可据以采取行动的 SocketError 放在 cause 上,却只交给 fetch 包装层一个裸的 terminated;pi-ai 只保留了这个词。pi-ai 的 SimpleStreamOptions 没有暴露任何 fetch/dispatcher/client 钩子,让我们能在细节被扁平化之前自行捕获 cause。
决策
classifyPiAiError识别另外两种传输层措辞,并将两者都映射为TRANSPORT:- 流式输出中途的套接字断开,呈现为裸的
terminated(undici)或Premature close(Node 流层); - 在终止事件之前被截断的流,每个 pi-ai 提供方各自抛出不同措辞(
Anthropic stream ended before message_stop、… before a terminal response event、… ended without a terminal event、Stream ended without finish_reason),统一按stream ended before/without匹配。
- 流式输出中途的套接字断开,呈现为裸的
- 该分类器带有一条
XXX(pi-ai upstream)注记,点名扁平化发生的位置并说明期望的修复方式:如果 pi-ai 有朝一日转发原始的Error或提供一个让我们捕获cause的钩子,就改为基于code/cause分类。在此之前分类仍是尽力而为的文本匹配。 llm-pi-ai/README.md新增一条 Known-Limitations 条目,记录 pi-ai 会扁平化 cause 链,因此 harness code 是从消息文本中分类出来的。
分类仍然基于消息文本,因为那是 pi-ai 唯一交付的信号;XXX 标明它是一个权宜之计,而非期望的最终状态。
考虑过的替代方案
通过 pi-ai 的 fetch/dispatcher/client 钩子捕获 cause。 否决:pi-ai 0.81.1 一个都没暴露。StreamOptions 只提供 onPayload/onResponse;onResponse 在响应体流被消费之前触发,因此无法观察到流式输出中途的断开。Anthropic 路径接受一个 client 对象,但为拦截传输错误而为每个请求构造并注入一个提供方 SDK client,只为一个诊断字符串就越过了适配器的服务边界。
把两者都保留为 PI_AI_ERROR,并放宽 llm-retry 的可重试集合。 否决:PI_AI_ERROR 是真正未分类失败的兜底,其中包括不可重试的失败(畸形的提供方响应、意料之外的 SDK bug)。让兜底可重试会重试那些永远不会成功的失败;修复之道是分类出可恢复的那种情况,而不是模糊这个类别。
在适配器里把扁平化后的错误包装成 LlmError('TRANSPORT', { cause }),仿照 DeepSeek 适配器。 在此否决:DeepSeek 适配器包装的是拿到响应之前的 fetch 拒绝,其 cause 仍然完好,因此链式包装保留了真实细节。而在 pi-ai 路径中,终止事件的 errorMessage 已经是一个没有 cause 可链的扁平化字符串,因此包装只会加一层却恢复不了任何东西;分类出 code 是唯一还能增加的价值。
后果
- 流式输出中途的传输层断开和终止前的流截断现在都携带
TRANSPORT,因此组合出的llm-retry策略会默认重试它们,而不是让该轮次失败。 - 通知文本不变(
terminated/Anthropic stream ended before message_stop):cause 细节在适配器看到之前就已丢失,因此errorChain没有更多内容可渲染。只有被路由的code得到了改善。 - 分类仍然依赖字符串匹配且依赖提供方的措辞:未来某个 pi-ai 版本若改写这些错误的措辞,就会静默回退到
PI_AI_ERROR,直到模式被更新。XXX注记指向那个持久的修复方式(基于转发的code/cause路由)。