packages/llm:模型协议层

Schema 优先的 @opencode-ai/llm 如何用 Protocol、Endpoint、Auth、Framing 四轴组装路由,统一 LLMEvent 事件流与提示缓存,opencode 怎样按开关接入,以及下一代 @opencode-ai/ai 的设计草案。

作者 David更新于 第 22 篇(共 38 篇)

packages/llm(包名 @opencode-ai/llm)是 OpenCode 自己写的模型协议层,src/ 下约 9500 行,目标是在 AI SDK 之外提供一条自己可控的调用链路。README 第一句就是定位:

Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.

边界:只管一次 provider turn

它刻意做得很窄:工作区内只依赖 @opencode-ai/schema,外部运行时依赖是 effect,外加 Bedrock 要用的 @smithy/eventstream-codec、@smithy/util-utf8 与 aws4fetch;包标着 private,尚未单独发布。AGENTS.md 要求它与会话无关——鉴权、权限、插件、遥测头和运行时选择都留在 packages/opencode/src/session/llm.ts。LLMClient.stream() 与 generate() 都只发一次 provider 请求,不管会话历史,也不自动循环执行工具。

最小用法

const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")

const request = LLM.request({
  model,
  system: "You are concise.",
  prompt: "Say hello in one short sentence.",
  generation: { maxTokens: 40 },
})

const program = Effect.gen(function* () {
  const response = yield* LLMClient.generate(request)
  console.log(response.text)
})

(packages/llm/README.md:10)

先配置提供方门面(facade),再选模型;选出的 Model 携带一条可执行的路由(route),请求就按它执行。

Route:四根正交的轴

Route.make() 的注释(packages/llm/src/route/client.ts:306)把一次部署拆成四问:

轴回答例子
Protocol说的是哪种 API请求体构造与校验、流事件解码、事件到 LLMEvent 的状态机
Endpoint发到哪里baseURL 加路径;路径可以是函数,Gemini、Bedrock 把模型 ID 拼进 URL
Auth怎样鉴权Bearer、x-api-key 请求头、Bedrock 的 SigV4 签名
Framing字节怎样切帧SSE,或 Bedrock 的 AWS 二进制事件流

其下还有一层 Transport 作为 IO 模板,OpenAI Responses 因此能把 HTTP 换成 WebSocket 而不动协议。拆轴的收益在 OpenAI 兼容路由上最明显——整个协议原样复用:

export const route = Route.make({
  id: ADAPTER,
  protocol: OpenAIChat.protocol,
  endpoint: Endpoint.path("/chat/completions"),
  framing: Framing.sse,
})

(packages/llm/src/protocols/openai-compatible-chat.ts:17)

DeepSeek、Groq、Together、Fireworks、Cerebras、DeepInfra、Baseten 都只是在它上面配一个 baseURL。现有路由:

路由 id端点
openai-chat/chat/completions
openai-responses/responses,另有 WebSocket 版
openai-compatible-chat/chat/completions,baseURL 由门面指定
anthropic-messages/messages
gemini/models/<id>:streamGenerateContent?alt=sse
bedrock-converse/model/<id>/converse-stream,AWS 事件流

编译:从 LLMRequest 到一次 HTTP 请求

const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
  const resolved = applyCachePolicy(resolveRequestOptions(request))
  const route = resolved.model.route

  const body = yield* route.body
    .from(resolved)
    .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
  const prepared = yield* route.prepareTransport(body, resolved)
  // ...
})

(packages/llm/src/route/client.ts:344,删减后)

三组选项(generation、providerOptions、http)按“路由默认、模型默认、请求”由低到高合并,再套用缓存策略;协议生成原生请求体并用 Schema 校验,不支持的内容在本地就失败;Transport 生成真正的请求,Auth 在此写入请求头。RequestExecutor 对 429、503、504、529 与其他 5xx 最多重试 2 次,优先遵循 retry-after。响应帧逐个解码,经协议的 step 状态机变成 LLMEvent;LLMClient.prepare() 只编译不发送,便于测试。

LLMEvent 与提示缓存

所有协议流出同一个标签联合(packages/llm/src/schema/events.ts:209),共 16 种:step-start、step-finish,text-*、reasoning-* 的 start、delta、end,工具参数流式到达的 tool-input-*,解析完成的 tool-call,以及 tool-result、tool-error,终结于 finish 或 provider-error。Usage 同时给出含缓存的总量与互不重叠的分项,注释强调下游永远不必做减法;generate() 把事件流折叠成 LLMResponse,等不到终结事件就报错。

提示缓存默认 "auto":在最后一个工具定义、最后一段 system、最近一条用户消息上各放一个断点。最近一条用户消息是关键:一次用户输入会展开成多轮 assistant 与工具往返,它们共享这段前缀。注释算过账,Anthropic 5 分钟缓存写入按 1.25 倍计价、读取按 0.1 倍,复用一次就回本。只有 anthropic-messages 与 bedrock-converse 两个路由真正写出标记,OpenAI 与 Gemini 由服务端隐式缓存,直接跳过;cache: "none" 可以关闭,手工写的 CacheHint 总会保留。相比提供方适配层里 V1 写死的断点位置,这里的策略成了可配置的数据。

ToolRuntime:只执行一次调用

ToolRuntime.dispatch(tools, call)(packages/llm/src/tool-runtime.ts:23)按 parameters Schema 解码输入、执行、再按 success Schema 编码输出。未知工具、输入不合法或处理器返回 ToolFailure 都变成 tool-error 加一个错误 tool-result,让模型下一轮自行纠正,其他异常则让流失败。它不追加历史、不计步数、不发起下一轮。

opencode 如何接入

默认仍走 AI SDK;设了 OPENCODE_EXPERIMENTAL_NATIVE_LLM=true 后,session/llm.ts 每次请求先问 native-runtime.ts 能否走原生:

const providerID = input.model.providerID
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
  return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
const npm = input.model.api.npm
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
  return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }

(packages/opencode/src/session/llm/native-runtime.ts:54)

另外,OAuth 凭据只放行带自定义 fetch 的 OpenAI(即 ChatGPT 订阅),且必须拿得到 API key;不满足就回退 AI SDK,同一会话里的不同请求可以走不同路径。通过后,native-request.ts 按 npm 选门面,把 AI SDK 形状的消息降级成 LLMRequest;工具仍归 opencode 执行,每个本地 tool-call 交给 ToolRuntime.dispatch() 在 FiberSet 里并发执行。AI SDK 路径则由 session/llm/ai-sdk.ts 把 fullStream 转成同样的 LLMEvent,所以处理器不关心走的是哪条路,详见模型调用层。

session/llm/AGENTS.md 说 OPENCODE_EXPERIMENTAL=true 也能开启原生路径、OpenAI OAuth 一律回退;但 runtime-flags.ts 只读 OPENCODE_EXPERIMENTAL_NATIVE_LLM,native-runtime.ts 也已放行带 fetch 覆盖的 OpenAI OAuth。以代码为准。

下一代:@opencode-ai/ai(设计草案)

同目录的 DESIGN.md 开篇声明是讨论稿,签名只是示意,尚未实现。它打算改名 @opencode-ai/ai,与现有 API 彻底决裂。最关键的一刀是拆开两种语义:

This separation is load-bearing:

  • generate / stream: complete Model Run
  • generateTurn / streamTurn: one Provider Turn

将来的 LLM.generate 表示完整运行:自动执行本地工具并继续请求,默认最多 20 轮,触顶算成功结果 max-turns;generateTurn、streamTurn 严格只发一次请求、从不执行本地工具。草案明确要求 OpenCode 迁到后者,自己掌控持久化、权限、工具结算与续跑。此外,Route 将藏到可执行的 Model 之后,包内附带 models.dev 生成快照。眼下的 @opencode-ai/llm 仍是“每次一个 turn、调用方自己循环”的形态。

下一篇:配置系统——opencode.json 从哪些位置加载、如何合并与替换变量,本部分反复出现的 provider、model 等字段都在那里汇合。

本页目录