Featured image of post OpenCode LLM Provider 层的三连修:从图片序列化到错误信息丢失

OpenCode LLM Provider 层的三连修:从图片序列化到错误信息丢失

分析 opencode 项目 LLM 协议层的三个连续 bug 修复:tool result 图片被字符串化、stream error 信息被吞掉、类型检查不稳定。讨论根因和对 AI 工具开发的启示。

语速

16 万 star 的 opencode 项目最近连续合并了三个 LLM provider 层的 bug 修复 PR,都是核心维护者 kitlangton 提交的。这三个 bug 不是某次重构引入的回归问题,而是从协议层初始设计就存在的缺陷——只是在最近使用量增长后才暴露出来。

这篇文章分析这三个 bug 的根因、修复方式,以及对开发 AI 工具的启示。

背景:opencode 的 LLM 协议层

opencode 支持多个 LLM provider(OpenAI、Anthropic 等),每个 provider 有自己的协议实现文件,负责把统一的内部格式转换成各 provider 的 API 格式。比如 openai-responses.ts 处理 OpenAI Responses API,anthropic-messages.ts 处理 Anthropic Messages API。

这些协议层有两个核心工作:

  1. 下行(lowering):把 opencode 内部的消息格式转成 provider 的请求格式
  2. 上行(parsing):把 provider 返回的流式响应解析成 opencode 内部的消息格式

三个 bug 分别出在这两个方向上。

Bug 1:Tool Result 中的图片被字符串化

相关 PR#28754(OpenAI,关闭 #28859)、#28755(Anthropic,关闭 #28861

问题

当 LLM 调用一个工具(比如截图工具),工具返回的结果可能包含图片(base64 编码)。opencode 的协议层在处理这种 tool result 时,统一调用了 ProviderShared.toolResultText(part),这个函数把整个 tool result——包括图片——JSON.stringify 成一个字符串。

对于 OpenAI Responses API,这意味着:

1
2
// 修复前:所有 tool result 都变成字符串
{ type: "function_call_output", call_id: "...", output: '{"type":"image","data":"base64..."}' }

一个包含 base64 图片的 tool result 被序列化成字符串塞进了 function_call_output.output。对于 Anthropic Messages API 同样:图片被 JSON.stringify 后塞进了 tool_result.content

修复

OpenAI 端:新增 lowerToolResultOutput 函数,判断 tool result 的类型:

  • 文本/json/error → 保持原来的字符串行为(向后兼容)
  • 图片 → 以 input_image 结构化块发送
1
2
3
4
// 修复后:图片以结构化格式发送
{ type: "function_call_output", call_id: "...", output: [
  { type: "input_image", image_url: "data:image/png;base64,..." }
]}

Anthropic 端做了类似的处理,图片以 Anthropic 原生的 image 块发送。同时 function_call_output.output 的 schema 从 Schema.String 改成了 Schema.Union([Schema.String, Schema.Array(...)]),既支持旧的字符串格式,也支持新的结构化数组。

根因

这不是某次重构搞坏的,而是初始设计就没考虑到 tool result 会返回非文本内容。toolResultText() 作为一个通用函数,把所有内容都当文本处理——在只有文本 tool result 的世界里这是对的,但世界变了。

Bug 2:Stream Error 信息被吞掉

相关 PR#28757(关闭 #28860

问题

LLM provider 的流式响应可能在中途出错(rate limit、context overflow、model overload 等)。opencode 的错误处理代码把这些错误全部压成了通用字符串:

1
2
// 修复前
event.message ?? event.code ?? "OpenAI Responses stream error"

所有错误都变成了 "OpenAI Responses stream error" 这一句话。维护者在 PR 描述里提到,这使得某个 session 的诊断变得极其痛苦——底层原因(base64 图片过大)完全不可见。

OpenAI 的 response.failed 事件更惨:错误信息在 response.error 下面,但代码读的是 event.messageevent.code(顶层字段),永远是 undefined。

修复

OpenAI 端:先读顶层 event.{code, message, param},再回退到嵌套的 event.response.error.{code, message, param}。当 code 和 message 同时存在时,用 code 做前缀:

1
2
rate_limit_exceeded: Slow down
server_error: Upstream model unavailable

Anthropic 端:用 error.type 做前缀:

1
overloaded_error: Overloaded

根因

错误处理的代码写得太"乐观"了——假设错误信息总在预期的位置。但 OpenAI 的 API 在不同错误场景下把信息放在不同的嵌套层级,代码没有覆盖所有情况。

Bug 3:Anthropic Tool Result 类型检查不稳定

相关 PR#28909

问题

这个 PR 在前两个修复合并后才出现。修复了图片 tool result 的结构化发送后,Anthropic 端的类型检查变得不稳定——某些边缘情况下 tool result 的类型推断会失败。

具体来说,tool_result.content 的类型从 string 扩展成了 string | ContentItem[],但下游代码没有完全适配这个联合类型。

修复

稳定了 Anthropic tool result 的类型检查逻辑,确保联合类型的所有分支都被正确处理。

根因

这是 Bug 1 修复的连锁反应。把 schema 从 Schema.String 改成 Schema.Union 后,类型系统变复杂了,之前不需要处理的分支现在必须处理。

三个 Bug 的关系

1
2
3
4
5
6
7
8
初始设计缺陷
├── tool result 只考虑文本场景
   ├── Bug 1a: OpenAI 图片被字符串化 (#28754)
   └── Bug 1b: Anthropic 图片被字符串化 (#28755)
├── error handler 只覆盖理想情况
   └── Bug 2: stream error 信息丢失 (#28757)
└── Bug 1 修复后类型变复杂
    └── Bug 3: 类型检查不稳定 (#28909)

Bug 1 和 Bug 2 是独立的初始设计缺陷。Bug 3 是 Bug 1 修复的副作用。

对 AI 工具开发的启示

1. 协议层的"够用就行"是最危险的

toolResultText() 在只有文本 tool result 的时候完全够用。但协议层的抽象一旦固定下来,后续扩展就很难——因为所有调用方都依赖当前行为。opencode 的修复保持了向后兼容(文本场景仍然用字符串),但代价是类型变复杂了(Bug 3)。

如果初始设计就把 tool result 分成文本/结构化两条路径,就不会有后面的问题。当然,这是事后诸葛亮——没有人能在第一天就预见到 tool result 会包含图片。

2. 错误处理要假设最坏情况

“错误信息总在预期位置"这个假设在 LLM API 上尤其不成立。各家 provider 的错误格式不一致,同一家 provider 的不同错误类型格式也不一致。写错误处理代码时,应该假设错误信息可能在任何嵌套层级,甚至完全缺失。

3. 修一个 bug 可能暴露下一个

opencode 的三个 PR 形成了一条修复链。修了图片序列化后,类型变复杂了,暴露了类型检查的漏洞。在提交修复时应该考虑到类型变更的下游影响。

4. 观察 bug 的方式决定了修复速度

kitlangton 在 #28757 的 PR 描述里提到,stream error 信息丢失使得某个 session 极其难诊断。如果错误信息是可见的,可能早就发现了。让错误可见是基础设施类代码的重要原则——宁可多输出一点,也不要吞掉信息。

总结

opencode 的这三个 LLM provider bug 都源自初始设计对复杂场景的简化处理。随着 AI 工具能力边界的扩展(从纯文本到多模态),早期"够用就行"的抽象开始出现裂缝。修复的方式是务实的:保持向后兼容的同时扩展新能力。对 AI 工具开发者来说,这是一个提醒——协议层的设计要为未知的变化留余地,错误处理要假设最坏的情况。