模型 Provider / LLM 调用
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
04-llm-provider - 章节摘要:从已准备好的模型上下文出发,理解 provider client、system/options/tools/headers、消息兼容转换、native 回退与统一 LLMEvent。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/session/llm.tspackages/opencode/src/session/llm/ai-sdk.tspackages/opencode/src/session/llm/native-runtime.tspackages/opencode/src/provider/provider.tspackages/opencode/src/provider/transform.tspackages/llm/src/protocols/index.ts
本章以 OpenCode 源码版本
eec0843ce422为证据基线。我们追踪一条典型 AI SDK 路径:session 已准备好消息、system 内容和工具;当 native runtime 未启用或不支持当前 provider 时,OpenCode 怎样构造请求、调用 provider,再把流还原为统一事件?这条路径由源码证明,但不是一次真实网络请求录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”学完这一章,你应该能够:
- 画出
SessionProcessor -> LLM.stream -> Provider -> native/AI SDK -> LLMEvent的适配地图。 - 区分 provider 元数据、language model client、请求参数与 provider-specific transform。
- 沿源码解释 system、messages、tools、options 与 headers 怎样合成一次模型请求。
- 说明为什么同一份内部消息在发给不同 provider 前仍需要归一化。
- 判断 native runtime 何时回退、工具何时被过滤、异常事件怎样进入统一错误通道。
- 把“稳定内核 + 边界适配器”的方法迁移到自己的多模型 Agent。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 的 LLM 层是一座双向翻译桥:向外把统一的 session 消息、system、工具和策略翻译成某个 provider 接受的请求;向内把不同 runtime 的流翻译成统一 LLMEvent。
本章只回答一个问题:
为什么切换 provider 不应该迫使 agent loop 重写一遍?
因为 loop 只依赖 LLM.stream(input): Stream<LLMEvent>;provider SDK 的创建、请求兼容和原始事件差异都被关在 LLM/Provider 边界内。核心合同见 packages/opencode/src/session/llm.ts:39-62。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:39-62
39export type StreamInput = {定义数据结构约束。40 user: MessageV2.User会话消息片段结构。41 sessionID: string42 parentSessionID?: string43 model: Provider.Model选择模型或 provider。44 agent: Agent.Info45 permission?: Permission.Ruleset46 system: string[]47 messages: ModelMessage[]48 small?: boolean49 tools: Record<string, Tool>50 retries?: number51 toolChoice?: "auto" | "required" | "none"52}5354export type StreamRequest = StreamInput & {定义数据结构约束。55 abort: AbortSignal用于中断运行任务。56}5758export interface Interface {定义数据结构约束。59 readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>60}6162export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}定义一个类。
2. 为什么“调用模型”远不止一个 HTTP 请求
Section titled “2. 为什么“调用模型”远不止一个 HTTP 请求”最简聊天请求看起来像:
messages -> provider -> textcoding agent 的真实请求还包含:
- agent prompt、环境 system 与用户自定义 system;
- 当前模型能力、variant、温度、topP、token 上限;
- 几十个带 JSON Schema 和 execute 回调的工具;
- OAuth、API key、base URL、provider options 与 headers;
- text、reasoning、tool call、tool result、usage、finish reason 等流事件。
更麻烦的是,各 provider 对空消息、tool-call id、附件模态、缓存标记和 options namespace 的要求并不一致。若 agent loop 直接理解这些差异,它会迅速变成 provider 条件分支的墙。
3. 先画地图:三种稳定合同夹住变化
Section titled “3. 先画地图:三种稳定合同夹住变化”SessionProcessor | | LLM.StreamInput vLLM.Service | +--> Provider.getLanguage(model) ---> provider SDK / model client | +--> ProviderTransform ------------> messages/options/schema 兼容 | +--> native runtime (支持时) | | | +-----------------------> LLMEvent | +--> AI SDK streamText (默认回退) | v LLMAISDK.toLLMEvents ----------> LLMEvent | v SessionProcessor| 层 | 稳定合同 | 变化被放在哪里 |
|---|---|---|
| session -> LLM | StreamInput | session 如何准备历史,不泄漏给 provider |
| Provider | Model、Info、getLanguage | SDK 包、认证、base URL、model client |
| LLM -> session | LLMEvent | AI SDK/native 原始流事件被适配掉 |
通用 Agent 内核只需要“输入一份模型上下文,返回一条事件流”;OpenCode 的 provider catalog、插件 hooks、native 实验路径、GitLab workflow 与大量兼容规则是产品层。
4. 最小机制:先看 15 行请求桥
Section titled “4. 最小机制:先看 15 行请求桥”1function stream(input: StableLLMInput): Stream<LLMEvent> {定义一段可复用逻辑。2 return scopedStream(async (abort) => {返回给上一层。3 const language = await provider.getLanguage(input.model)选择模型或 provider。4 const system = buildSystem(input)5 const params = mergeModelAgentVariantOptions(input)6 const tools = filterDisabledTools(input)7 const messages = adaptMessages(input.messages, input.model)89 const native = tryNative({ language, system, messages, tools, abort })10 if (native.supported) return native.stream按条件进入分支。1112 const result = streamText({向模型发起请求。13 model: language, system, messages, tools, ...params, abortSignal: abort,选择模型或 provider。14 })15 return mapEvents(result.fullStream, toLLMEvent)返回给上一层。16 })17}
这是从真实实现抽出的教学骨架。OpenCode 还要加入 auth、config、plugin hooks、telemetry、headers、tool-call repair 和 provider-specific options。
5. 读源码前,只分清四个角色
Section titled “5. 读源码前,只分清四个角色”5.1 Provider.Model 是能力与寻址信息,不是客户端
Section titled “5.1 Provider.Model 是能力与寻址信息,不是客户端”Provider.Model 保存 model id、provider id、API package/id/url、能力、上下文限制、价格、options、headers 和 variants。来源:packages/opencode/src/provider/provider.ts:850-925。
packages/opencode/src/provider/provider.ts
packages/opencode/src/provider/provider.ts:850-925
850const ProviderModalities = Schema.Struct({定义并校验数据形状。851 text: Schema.Boolean,定义并校验数据形状。852 audio: Schema.Boolean,定义并校验数据形状。853 image: Schema.Boolean,定义并校验数据形状。854 video: Schema.Boolean,定义并校验数据形状。855 pdf: Schema.Boolean,定义并校验数据形状。856})857858const ProviderInterleaved = Schema.Union([定义并校验数据形状。859 Schema.Boolean,定义并校验数据形状。860 Schema.Struct({定义并校验数据形状。861 field: Schema.Literals(["reasoning_content", "reasoning_details"]),定义并校验数据形状。862 }),863])864865const ProviderCapabilities = Schema.Struct({定义并校验数据形状。866 temperature: Schema.Boolean,定义并校验数据形状。867 reasoning: Schema.Boolean,定义并校验数据形状。868 attachment: Schema.Boolean,定义并校验数据形状。869 toolcall: Schema.Boolean,定义并校验数据形状。870 input: ProviderModalities,选择模型或 provider。871 output: ProviderModalities,选择模型或 provider。872 interleaved: ProviderInterleaved,选择模型或 provider。873})874875const ProviderCacheCost = Schema.Struct({定义并校验数据形状。876 read: Schema.Finite,定义并校验数据形状。877 write: Schema.Finite,定义并校验数据形状。878})879880const ProviderCostTier = Schema.Struct({定义并校验数据形状。881 input: Schema.Finite,定义并校验数据形状。882 output: Schema.Finite,定义并校验数据形状。883 cache: ProviderCacheCost,选择模型或 provider。884 tier: Schema.Struct({定义并校验数据形状。885 type: Schema.Literal("context"),定义并校验数据形状。886 size: Schema.Finite,定义并校验数据形状。887 }),888})889890const ProviderCost = Schema.Struct({定义并校验数据形状。891 input: Schema.Finite,定义并校验数据形状。892 output: Schema.Finite,定义并校验数据形状。893 cache: ProviderCacheCost,选择模型或 provider。894 tiers: optionalOmitUndefined(Schema.Array(ProviderCostTier)),定义并校验数据形状。895 experimentalOver200K: optionalOmitUndefined(896 Schema.Struct({定义并校验数据形状。897 input: Schema.Finite,定义并校验数据形状。898 output: Schema.Finite,定义并校验数据形状。899 cache: ProviderCacheCost,选择模型或 provider。900 }),901 ),902})903904const ProviderLimit = Schema.Struct({定义并校验数据形状。905 context: Schema.Finite,定义并校验数据形状。906 input: optionalOmitUndefined(Schema.Finite),定义并校验数据形状。907 output: Schema.Finite,定义并校验数据形状。908})909910export const Model = Schema.Struct({定义并校验数据形状。911 id: ModelID,912 providerID: ProviderID,选择模型或 provider。913 api: ProviderApiInfo,选择模型或 provider。914 name: Schema.String,定义并校验数据形状。915 family: optionalOmitUndefined(Schema.String),定义并校验数据形状。916 capabilities: ProviderCapabilities,选择模型或 provider。917 cost: ProviderCost,选择模型或 provider。918 limit: ProviderLimit,选择模型或 provider。919 status: ModelStatus,920 options: Schema.Record(Schema.String, Schema.Any),定义并校验数据形状。921 headers: Schema.Record(Schema.String, Schema.String),定义并校验数据形状。922 release_date: Schema.String,定义并校验数据形状。923 variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),定义并校验数据形状。924}).annotate({ identifier: "Model" })925export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>定义并校验数据形状。
它回答“这个模型是什么、能做什么、怎样找到 SDK”,但不能直接发请求。
5.2 language model 是可调用客户端
Section titled “5.2 language model 是可调用客户端”Provider.getLanguage(model) 返回 AI SDK 的 LanguageModelV3,并按 providerID/modelID 缓存。它通过 resolveSDK 得到 provider factory,再调用自定义 model loader 或 sdk.languageModel(model.api.id)。来源:packages/opencode/src/provider/provider.ts:1679-1703。
packages/opencode/src/provider/provider.ts
packages/opencode/src/provider/provider.ts:1679-1703
1679 const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {选择模型或 provider。1680 const s = yield* InstanceState.get(state)等待 Effect 结果。1681 const envs = yield* env.all()等待 Effect 结果。1682 const key = `${model.providerID}/${model.id}`选择模型或 provider。1683 if (s.models.has(key)) return s.models.get(key)!按条件进入分支。16841685 const provider = s.providers[model.providerID]选择模型或 provider。1686 return yield* EffectPromise.refineRejection(等待 Effect 结果。1687 async () => {1688 const sdk = await resolveSDK(model, s, envs)1689 const language = s.modelLoaders[model.providerID]选择模型或 provider。1690 ? await s.modelLoaders[model.providerID](sdk, model.api.id, {选择模型或 provider。1691 ...provider.options,选择模型或 provider。1692 ...model.options,1693 })1694 : sdk.languageModel(model.api.id)1695 s.models.set(key, language)1696 return language返回给上一层。1697 },1698 (cause) =>1699 cause instanceof NoSuchModelError1700 ? new ModelNotFoundError({ modelID: model.id, providerID: model.providerID, cause })选择模型或 provider。1701 : undefined,1702 )1703 })
可以把它类比为 Spring 中由配置创建并缓存的 client bean。类比边界是:不同 provider SDK 在运行时动态选择,并不都实现一个由 OpenCode 自己定义的 Java interface。
5.3 ProviderTransform 是兼容层,不是 provider registry
Section titled “5.3 ProviderTransform 是兼容层,不是 provider registry”它负责消息、options、temperature、token 上限和 tool schema 等转换。registry 决定“用谁”,transform 解决“对方接受什么形状”。
5.4 LLMEvent 是 session 层唯一需要懂的输出语言
Section titled “5.4 LLMEvent 是 session 层唯一需要懂的输出语言”AI SDK 的 text-delta、tool-call、finish-step 等事件经 LLMAISDK.toLLMEvents 转成统一事件;native runtime 原生返回同一事件类型。来源:packages/opencode/src/session/llm/ai-sdk.ts:61-252、packages/opencode/src/session/llm/native-runtime.ts:16-18。
packages/opencode/src/session/llm/ai-sdk.ts
packages/opencode/src/session/llm/ai-sdk.ts:61-252
61export function toLLMEvents(对外暴露模块成员。62 state: ReturnType<typeof adapterState>,63 event: AISDKEvent,64): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {Effect 异步工作流。65 switch (event.type) {66 case "start":67 return Effect.succeed([])Effect 异步工作流。6869 case "start-step":70 return Effect.succeed([LLMEvent.stepStart({ index: state.step })])Effect 异步工作流。7172 case "finish-step":73 return Effect.sync(() => [Effect 异步工作流。74 LLMEvent.stepFinish({75 index: state.step++,76 reason: finishReason(event.finishReason),77 usage: usage(event.usage),78 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。79 }),80 ])8182 case "finish":83 return Effect.sync(() => {Effect 异步工作流。84 const events = [85 LLMEvent.finish({86 reason: finishReason(event.finishReason),87 usage: usage(event.totalUsage),88 providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined,选择模型或 provider。89 }),90 ]91 // Reset so the adapter can be reused for a follow-up stream without leaking92 // counters or block IDs. adapterState() is the single source of truth for shape.93 Object.assign(state, adapterState())94 return events返回给上一层。95 })9697 case "text-start":98 return Effect.sync(() => {Effect 异步工作流。99 state.currentTextID = currentTextID(state, event.id)100 return [返回给上一层。101 LLMEvent.textStart({102 id: state.currentTextID,103 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。104 }),105 ]106 })107108 case "text-delta":109 return Effect.succeed([Effect 异步工作流。110 LLMEvent.textDelta({111 id: currentTextID(state, event.id),112 text: event.text,113 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。114 }),115 ])116117 case "text-end":118 return Effect.sync(() => {Effect 异步工作流。119 const id = currentTextID(state, event.id)120 state.currentTextID = undefined121 return [返回给上一层。122 LLMEvent.textEnd({123 id,124 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。125 }),126 ]127 })128129 case "reasoning-start":130 return Effect.sync(() => {Effect 异步工作流。131 state.currentReasoningID = currentReasoningID(state, event.id)132 return [返回给上一层。133 LLMEvent.reasoningStart({134 id: state.currentReasoningID,135 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。136 }),137 ]138 })139140 case "reasoning-delta":141 return Effect.succeed([Effect 异步工作流。142 LLMEvent.reasoningDelta({143 id: currentReasoningID(state, event.id),144 text: event.text,145 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。146 }),147 ])148149 case "reasoning-end":150 return Effect.sync(() => {Effect 异步工作流。151 const id = currentReasoningID(state, event.id)152 state.currentReasoningID = undefined153 return [返回给上一层。154 LLMEvent.reasoningEnd({155 id,156 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。157 }),158 ]159 })160161 case "tool-input-start":162 return Effect.sync(() => {Effect 异步工作流。163 state.toolNames[event.id] = event.toolName164 return [返回给上一层。165 LLMEvent.toolInputStart({166 id: event.id,167 name: event.toolName,168 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。169 }),170 ]171 })172173 case "tool-input-delta":174 return Effect.succeed([Effect 异步工作流。175 LLMEvent.toolInputDelta({176 id: event.id,177 name: state.toolNames[event.id] ?? "unknown",178 text: event.delta ?? "",179 }),180 ])181182 case "tool-input-end":183 return Effect.succeed([Effect 异步工作流。184 LLMEvent.toolInputEnd({185 id: event.id,186 name: state.toolNames[event.id] ?? "unknown",187 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。188 }),189 ])190191 case "tool-call":192 return Effect.sync(() => {Effect 异步工作流。193 state.toolNames[event.toolCallId] = event.toolName194 return [返回给上一层。195 LLMEvent.toolCall({196 id: event.toolCallId,197 name: event.toolName,198 input: event.input,199 providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,选择模型或 provider。200 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。201 }),202 ]203 })204205 case "tool-result":206 return Effect.sync(() => {Effect 异步工作流。207 const name = state.toolNames[event.toolCallId] ?? "unknown"208 delete state.toolNames[event.toolCallId]209 return [返回给上一层。210 LLMEvent.toolResult({211 id: event.toolCallId,212 name,213 result: ToolResultValue.make(event.output),214 providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,选择模型或 provider。215 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。216 }),217 ]218 })219220 case "tool-error":221 return Effect.sync(() => {Effect 异步工作流。222 const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")223 delete state.toolNames[event.toolCallId]224 return [返回给上一层。225 LLMEvent.toolError({226 id: event.toolCallId,227 name,228 message: errorMessage(event.error),229 error: event.error,230 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。231 }),232 ]233 })234235 case "error":236 return Effect.fail(event.error)Effect 异步工作流。237238 case "abort":239 case "source":240 case "file":241 case "raw":242 case "tool-output-denied":243 case "tool-approval-request":244 return Effect.succeed([])Effect 异步工作流。245246 default: {247 const _exhaustive: never = event248 void _exhaustive249 return Effect.succeed([])Effect 异步工作流。250 }251 }252}
packages/opencode/src/session/llm/native-runtime.ts
packages/opencode/src/session/llm/native-runtime.ts:16-18
16export type StreamResult =定义数据结构约束。17 | { readonly type: "supported"; readonly stream: Stream.Stream<LLMEvent, unknown> }18 | { readonly type: "unsupported"; readonly reason: string }
6. 追一条典型源码旅程:从稳定输入到 AI SDK 事件
Section titled “6. 追一条典型源码旅程:从稳定输入到 AI SDK 事件”假设 Agent 核心循环已经准备好:
- 当前 user message;
- 当前 agent 与 model;
- 转成
ModelMessage[]的会话历史; - system 内容数组;
- 当前轮可用 tools。
我们只追 native 未启用或返回 unsupported 后的 AI SDK 分支。
6.1 第一站:StreamInput 划定 session 与 provider 的边界
Section titled “6.1 第一站:StreamInput 划定 session 与 provider 的边界”1export type StreamInput = {定义数据结构约束。2 user: MessageV2.User会话消息片段结构。3 sessionID: string4 parentSessionID?: string5 model: Provider.Model选择模型或 provider。6 agent: Agent.Info7 permission?: Permission.Ruleset8 system: string[]9 messages: ModelMessage[]10 small?: boolean11 tools: Record<string, Tool>12 retries?: number13 toolChoice?: "auto" | "required" | "none"14}
来源:packages/opencode/src/session/llm.ts:39-52
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:39-52
39export type StreamInput = {定义数据结构约束。40 user: MessageV2.User会话消息片段结构。41 sessionID: string42 parentSessionID?: string43 model: Provider.Model选择模型或 provider。44 agent: Agent.Info45 permission?: Permission.Ruleset46 system: string[]47 messages: ModelMessage[]48 small?: boolean49 tools: Record<string, Tool>50 retries?: number51 toolChoice?: "auto" | "required" | "none"52}
LLM 层的稳定输入输出合同
packages/opencode/src/session/llm.ts:39-62
先看边界需要哪些数据,再看 provider 细节怎样被关在实现内部。
39export type StreamInput = {定义数据结构约束。40 user: MessageV2.User会话消息片段结构。41 sessionID: string42 parentSessionID?: string43 model: Provider.Model选择模型或 provider。44 agent: Agent.Info45 permission?: Permission.Ruleset46 system: string[]47 messages: ModelMessage[]48 small?: boolean49 tools: Record<string, Tool>50 retries?: number51 toolChoice?: "auto" | "required" | "none"52}5354export type StreamRequest = StreamInput & {定义数据结构约束。55 abort: AbortSignal用于中断运行任务。56}5758export interface Interface {定义数据结构约束。59 readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>60}6162export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}定义一个类。
注意这里已经是 ModelMessage[],不是数据库原始 parts。message-to-model 的领域转换属于 session/message 层;本章从这个稳定边界开始。
6.2 第二站:并发取得 client、配置、provider 与认证
Section titled “6.2 第二站:并发取得 client、配置、provider 与认证”LLM.run 使用 Effect.all(..., { concurrency: "unbounded" }) 同时获取:
provider.getLanguage(input.model);- 全局 config;
- provider info;
- 当前 provider auth。
来源:packages/opencode/src/session/llm.ts:85-107。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:85-107
85 const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {Effect 异步工作流。86 const l = log87 .clone()88 .tag("providerID", input.model.providerID)选择模型或 provider。89 .tag("modelID", input.model.id)选择模型或 provider。90 .tag("session.id", input.sessionID)91 .tag("small", (input.small ?? false).toString())92 .tag("agent", input.agent.name)93 .tag("mode", input.agent.mode)94 l.info("stream", {95 modelID: input.model.id,选择模型或 provider。96 providerID: input.model.providerID,选择模型或 provider。97 })9899 const [language, cfg, item, info] = yield* Effect.all(Effect 异步工作流。100 [101 provider.getLanguage(input.model),选择模型或 provider。102 config.get(),读取运行配置。103 provider.getProvider(input.model.providerID),选择模型或 provider。104 auth.get(input.model.providerID),选择模型或 provider。105 ],106 { concurrency: "unbounded" },107 )
这些读取互不依赖,因此并发可以缩短首个 token 前的准备时间。失败仍会进入同一个 Effect 错误通道。
6.3 第三站:Provider 把配置变成 language model
Section titled “6.3 第三站:Provider 把配置变成 language model”resolveSDK 从 provider options 开始,解析 base URL 中的变量,补 API key 与 model headers,再对 { providerID, npm, options } 求 hash 作为 SDK 缓存 key。来源:packages/opencode/src/provider/provider.ts:1508-1561。
packages/opencode/src/provider/provider.ts
packages/opencode/src/provider/provider.ts:1508-1561
1508 async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {选择模型或 provider。1509 try {开始保护性执行。1510 using _ = log.time("getSDK", {1511 providerID: model.providerID,选择模型或 provider。1512 })1513 const provider = s.providers[model.providerID]选择模型或 provider。1514 const options = { ...provider.options }选择模型或 provider。15151516 if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {选择模型或 provider。1517 delete options.fetch1518 }15191520 if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {按条件进入分支。1521 options["includeUsage"] = true1522 }15231524 const baseURL = iife(() => {1525 let url =1526 typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url1527 if (!url) return按条件进入分支。15281529 const loader = s.varsLoaders[model.providerID]选择模型或 provider。1530 if (loader) {按条件进入分支。1531 const vars = loader(options)1532 for (const [key, value] of Object.entries(vars)) {遍历集合。1533 const field = "${" + key + "}"1534 url = url.replaceAll(field, value)1535 }1536 }15371538 url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {1539 const val = envs[String(key)]1540 return val ?? item返回给上一层。1541 })1542 return url返回给上一层。1543 })15441545 if (baseURL !== undefined) options["baseURL"] = baseURL按条件进入分支。1546 if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key选择模型或 provider。1547 if (model.headers)按条件进入分支。1548 options["headers"] = {1549 ...options["headers"],1550 ...model.headers,1551 }15521553 const key = Hash.fast(1554 JSON.stringify({1555 providerID: model.providerID,选择模型或 provider。1556 npm: model.api.npm,1557 options,1558 }),1559 )1560 const existing = s.sdk.get(key)1561 if (existing) return existing按条件进入分支。
若 package 在 bundled provider 表中,直接加载内置 factory;否则通过 Npm.add 获取入口或加载 file:// provider,再查找 create* factory。来源:packages/opencode/src/provider/provider.ts:1609-1648。
packages/opencode/src/provider/provider.ts
packages/opencode/src/provider/provider.ts:1609-1648
1609 const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]1610 if (bundledLoader) {按条件进入分支。1611 log.info("using bundled provider", {选择模型或 provider。1612 providerID: model.providerID,选择模型或 provider。1613 pkg: model.api.npm,1614 })1615 const factory = await bundledLoader()1616 const loaded = factory({1617 name: model.providerID,选择模型或 provider。1618 ...options,1619 })1620 s.sdk.set(key, loaded)1621 return loaded as SDK返回给上一层。1622 }16231624 let installedPath: string1625 if (!model.api.npm.startsWith("file://")) {按条件进入分支。1626 const item = await Npm.add(model.api.npm)1627 if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)按条件进入分支。1628 installedPath = item.entrypoint1629 } else {1630 log.info("loading local provider", { pkg: model.api.npm })选择模型或 provider。1631 installedPath = model.api.npm1632 }16331634 // `installedPath` is a local entry path or an existing `file://` URL. Normalize1635 // only path inputs so Node on Windows accepts the dynamic import.1636 const importSpec = installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href1637 const mod = await import(importSpec)按需加载模块。16381639 const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]1640 const loaded = fn({1641 name: model.providerID,选择模型或 provider。1642 ...options,1643 })1644 s.sdk.set(key, loaded)1645 return loaded as SDK返回给上一层。1646 } catch (e) {1647 throw new InitError({ providerID: model.providerID, cause: e })选择模型或 provider。1648 }
provider options 与 SDK 缓存键
packages/opencode/src/provider/provider.ts:1508-1561
区分 provider SDK 实例缓存和下一段的 language model 缓存。
1508 async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {选择模型或 provider。1509 try {开始保护性执行。1510 using _ = log.time("getSDK", {1511 providerID: model.providerID,选择模型或 provider。1512 })1513 const provider = s.providers[model.providerID]选择模型或 provider。1514 const options = { ...provider.options }选择模型或 provider。15151516 if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {选择模型或 provider。1517 delete options.fetch1518 }15191520 if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {按条件进入分支。1521 options["includeUsage"] = true1522 }15231524 const baseURL = iife(() => {1525 let url =1526 typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url1527 if (!url) return按条件进入分支。15281529 const loader = s.varsLoaders[model.providerID]选择模型或 provider。1530 if (loader) {按条件进入分支。1531 const vars = loader(options)1532 for (const [key, value] of Object.entries(vars)) {遍历集合。1533 const field = "${" + key + "}"1534 url = url.replaceAll(field, value)1535 }1536 }15371538 url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {1539 const val = envs[String(key)]1540 return val ?? item返回给上一层。1541 })1542 return url返回给上一层。1543 })15441545 if (baseURL !== undefined) options["baseURL"] = baseURL按条件进入分支。1546 if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key选择模型或 provider。1547 if (model.headers)按条件进入分支。1548 options["headers"] = {1549 ...options["headers"],1550 ...model.headers,1551 }15521553 const key = Hash.fast(1554 JSON.stringify({1555 providerID: model.providerID,选择模型或 provider。1556 npm: model.api.npm,1557 options,1558 }),1559 )1560 const existing = s.sdk.get(key)1561 if (existing) return existing按条件进入分支。
随后 getLanguage 再按 ${providerID}/${model.id} 缓存具体 model client。来源:packages/opencode/src/provider/provider.ts:1679-1696。
packages/opencode/src/provider/provider.ts
packages/opencode/src/provider/provider.ts:1679-1696
1679 const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {选择模型或 provider。1680 const s = yield* InstanceState.get(state)等待 Effect 结果。1681 const envs = yield* env.all()等待 Effect 结果。1682 const key = `${model.providerID}/${model.id}`选择模型或 provider。1683 if (s.models.has(key)) return s.models.get(key)!按条件进入分支。16841685 const provider = s.providers[model.providerID]选择模型或 provider。1686 return yield* EffectPromise.refineRejection(等待 Effect 结果。1687 async () => {1688 const sdk = await resolveSDK(model, s, envs)1689 const language = s.modelLoaders[model.providerID]选择模型或 provider。1690 ? await s.modelLoaders[model.providerID](sdk, model.api.id, {选择模型或 provider。1691 ...provider.options,选择模型或 provider。1692 ...model.options,1693 })1694 : sdk.languageModel(model.api.id)1695 s.models.set(key, language)1696 return language返回给上一层。
两级缓存解决不同复用粒度:相同 provider 配置可共享 SDK,同一模型可复用 language model。缓存键与初始化行为由源码证明;性能收益是结构上的设计解释。
6.4 第四站:system prompt 先组合,再允许插件修改
Section titled “6.4 第四站:system prompt 先组合,再允许插件修改”system 的基础顺序是:
agent.prompt(若有)否则 provider system prompt + input.system + last user message 上的 system来源:packages/opencode/src/session/llm.ts:109-124。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:109-124
109 // TODO: move this to a proper hook110 const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"111112 const system: string[] = []113 system.push(114 [115 // use agent prompt otherwise provider prompt116 ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),选择模型或 provider。117 // any custom prompt passed into this call118 ...input.system,119 // any custom prompt from last user message120 ...(input.user.system ? [input.user.system] : []),121 ]122 .filter((x) => x)123 .join("\n"),124 )
然后触发 experimental.chat.system.transform。若插件扩展出多个 system 项,且 header 没被改动,代码把剩余项重新合并,维持两段结构以利缓存。来源:packages/opencode/src/session/llm.ts:126-137。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:126-137
126 const header = system[0]127 yield* plugin.trigger(调用插件扩展点。128 "experimental.chat.system.transform",129 { sessionID: input.sessionID, model: input.model },选择模型或 provider。130 { system },131 )132 // rejoin to maintain 2-part structure for caching if header unchanged133 if (system.length > 2 && system[0] === header) {按条件进入分支。134 const rest = system.slice(1)135 system.length = 0136 system.push(header, rest.join("\n"))137 }
OpenAI OAuth 是特殊分支:system 被写入 options.instructions,messages 中不再前置 system message;GitLab workflow 也有独立处理。普通路径则把 system 数组转换成 role: "system" 的 messages。来源:packages/opencode/src/session/llm.ts:139-168。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:139-168
139 const variant =140 !input.small && input.model.variants && input.user.model.variant141 ? input.model.variants[input.user.model.variant]142 : {}143 const base = input.small144 ? ProviderTransform.smallOptions(input.model)选择模型或 provider。145 : ProviderTransform.options({选择模型或 provider。146 model: input.model,选择模型或 provider。147 sessionID: input.sessionID,148 providerOptions: item.options,选择模型或 provider。149 })150 const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)151 if (isOpenaiOauth) {按条件进入分支。152 options.instructions = system.join("\n")153 }154155 const isWorkflow = language instanceof GitLabWorkflowLanguageModel156 const messages = isOpenaiOauth157 ? input.messages158 : isWorkflow159 ? input.messages160 : [161 ...system.map(162 (x): ModelMessage => ({163 role: "system",164 content: x,165 }),166 ),167 ...input.messages,168 ]
6.5 第五站:options 不是覆盖,而是有顺序的合并
Section titled “6.5 第五站:options 不是覆盖,而是有顺序的合并”普通请求先从 ProviderTransform.options(...) 得到 provider/model 默认值,再依次深合并:
base options <- model.options <- agent.options <- selected variant越靠后的层优先级越高。来源:packages/opencode/src/session/llm.ts:139-153。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:139-153
139 const variant =140 !input.small && input.model.variants && input.user.model.variant141 ? input.model.variants[input.user.model.variant]142 : {}143 const base = input.small144 ? ProviderTransform.smallOptions(input.model)选择模型或 provider。145 : ProviderTransform.options({选择模型或 provider。146 model: input.model,选择模型或 provider。147 sessionID: input.sessionID,148 providerOptions: item.options,选择模型或 provider。149 })150 const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)151 if (isOpenaiOauth) {按条件进入分支。152 options.instructions = system.join("\n")153 }
接着 chat.params hook 可以修改 temperature、topP、topK、maxOutputTokens 与 options;chat.headers hook 单独修改 headers。来源:packages/opencode/src/session/llm.ts:170-202。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:170-202
170 const params = yield* plugin.trigger(调用插件扩展点。171 "chat.params",172 {173 sessionID: input.sessionID,174 agent: input.agent.name,175 model: input.model,选择模型或 provider。176 provider: item,选择模型或 provider。177 message: input.user,178 },179 {180 temperature: input.model.capabilities.temperature181 ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))选择模型或 provider。182 : undefined,183 topP: input.agent.topP ?? ProviderTransform.topP(input.model),选择模型或 provider。184 topK: ProviderTransform.topK(input.model),选择模型或 provider。185 maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, flags.outputTokenMax),选择模型或 provider。186 options,187 },188 )189190 const { headers } = yield* plugin.trigger(调用插件扩展点。191 "chat.headers",192 {193 sessionID: input.sessionID,194 agent: input.agent.name,195 model: input.model,选择模型或 provider。196 provider: item,选择模型或 provider。197 message: input.user,198 },199 {200 headers: {},201 },202 )
为什么 parameters 与 headers 分两个 hook?代码没有写设计说明;从接口形状可解释为分别治理模型行为与传输元数据,这是明确标注的设计解释。
6.6 第六站:工具要经过两次筛选与稳定排序
Section titled “6.6 第六站:工具要经过两次筛选与稳定排序”resolveTools 合并 agent/session permission,移除被 disabled 的工具,也尊重 user message 上旧 tools[k] === false 的覆盖。来源:packages/opencode/src/session/llm.ts:512-518。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:512-518
512function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {定义一段可复用逻辑。513 const disabled = Permission.disabled(514 Object.keys(input.tools),515 Permission.merge(input.agent.permission, input.permission ?? []),516 )517 return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))返回给上一层。518}
之后工具按名称排序:
1const sortedTools = Object.fromEntries(2 Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)),3)
来源:packages/opencode/src/session/llm.ts:204-225
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:204-225
204 const tools = resolveTools(input)205206 // GitHub Copilot may require the tools parameter when message history contains207 // tool calls but no tools are active (e.g. compaction). Inject a stub tool that208 // is never meant to be invoked. LiteLLM-backed providers are excluded.209 if (按条件进入分支。210 input.model.providerID.includes("github-copilot") &&选择模型或 provider。211 Object.keys(tools).length === 0 &&212 hasToolCalls(input.messages)213 ) {214 tools["_noop"] = aiTool({215 description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",216 inputSchema: jsonSchema({217 type: "object",218 properties: {219 reason: { type: "string", description: "Unused" },220 },221 }),222 execute: async () => ({ output: "", title: "", metadata: {} }),工具真正执行入口。223 })224 }225 const sortedTools = Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)))
这不是决定模型会调用谁,而是稳定工具声明顺序。对 GitHub Copilot 的“历史含 tool call、当前无工具”特殊情况,源码还注入永不应调用的 _noop 兼容工具。来源:packages/opencode/src/session/llm.ts:206-224。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:206-224
206 // GitHub Copilot may require the tools parameter when message history contains207 // tool calls but no tools are active (e.g. compaction). Inject a stub tool that208 // is never meant to be invoked. LiteLLM-backed providers are excluded.209 if (按条件进入分支。210 input.model.providerID.includes("github-copilot") &&选择模型或 provider。211 Object.keys(tools).length === 0 &&212 hasToolCalls(input.messages)213 ) {214 tools["_noop"] = aiTool({215 description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",216 inputSchema: jsonSchema({217 type: "object",218 properties: {219 reason: { type: "string", description: "Unused" },220 },221 }),222 execute: async () => ({ output: "", title: "", metadata: {} }),工具真正执行入口。223 })224 }
6.7 第七站:headers 最后汇合
Section titled “6.7 第七站:headers 最后汇合”OpenCode 自有 provider 会加入 project/session/request/client headers;其他 provider 获得 session affinity、可选 parent session id 与 User-Agent。最后再合并 model headers 和 plugin headers。来源:packages/opencode/src/session/llm.ts:330-350。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:330-350
330 const opencodeProjectID = input.model.providerID.startsWith("opencode")选择模型或 provider。331 ? (yield* InstanceState.context).project.id等待 Effect 结果。332 : undefined333334 const requestHeaders = {335 ...(input.model.providerID.startsWith("opencode")选择模型或 provider。336 ? {337 ...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),338 "x-opencode-session": input.sessionID,339 "x-opencode-request": input.user.id,340 "x-opencode-client": flags.client,341 "User-Agent": `opencode/${InstallationVersion}`,342 }343 : {344 "x-session-affinity": input.sessionID,345 ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),346 "User-Agent": `opencode/${InstallationVersion}`,347 }),348 ...input.model.headers,349 ...headers,350 }
这里能确认合并顺序:后面的 input.model.headers 与 plugin headers 可覆盖前面的同名字段。是否应该允许覆盖某个具体 header,要结合配置安全策略判断,源码本身只证明当前行为。
6.8 第八站:native 先试,unsupported 就回退
Section titled “6.8 第八站:native 先试,unsupported 就回退”只有 experimentalNativeLlm 开启时才尝试 native runtime。native 当前检查 provider id、SDK package、OAuth 与 API key;不支持时返回带原因的 unsupported,LLM.run 记录原因并继续 AI SDK。来源:packages/opencode/src/session/llm.ts:352-393、packages/opencode/src/session/llm/native-runtime.ts:39-60。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:352-393
352 if (flags.experimentalNativeLlm) {按条件进入分支。353 const native = LLMNativeRuntime.stream({354 model: input.model,选择模型或 provider。355 provider: item,选择模型或 provider。356 auth: info,357 llmClient,358 isOpenaiOauth,359 system,360 messages,361 tools: sortedTools,362 toolChoice: input.toolChoice,363 temperature: params.temperature,364 topP: params.topP,365 topK: params.topK,366 maxOutputTokens: params.maxOutputTokens,367 providerOptions: params.options,选择模型或 provider。368 headers: requestHeaders,369 abort: input.abort,370 })371 if (native.type === "supported") {按条件进入分支。372 yield* Effect.logInfo("llm runtime selected").pipe(Effect 异步工作流。373 Effect.annotateLogs({Effect 异步工作流。374 "llm.runtime": "native",375 "llm.provider": input.model.providerID,选择模型或 provider。376 "llm.model": input.model.id,377 }),378 )379 return {返回给上一层。380 type: "native" as const,381 stream: native.stream,382 }383 }384 yield* Effect.logInfo("llm runtime selected").pipe(Effect 异步工作流。385 Effect.annotateLogs({Effect 异步工作流。386 "llm.runtime": "ai-sdk",387 "llm.provider": input.model.providerID,选择模型或 provider。388 "llm.model": input.model.id,389 "llm.native_unsupported_reason": native.reason,390 }),391 )392 l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason })393 }
packages/opencode/src/session/llm/native-runtime.ts
packages/opencode/src/session/llm/native-runtime.ts:39-60
39export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus {选择模型或 provider。40 const providerID = input.model.providerID选择模型或 provider。41 if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))选择模型或 provider。42 return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }选择模型或 provider。43 const npm = input.model.api.npm44 if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/anthropic")按条件进入分支。45 return { type: "unsupported", reason: "provider package is not OpenAI or Anthropic" }选择模型或 provider。46 if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }按条件进入分支。4748 const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key选择模型或 provider。49 if (!apiKey) return { type: "unsupported", reason: "API key is not configured" }按条件进入分支。5051 return {返回给上一层。52 type: "supported",53 apiKey,54 baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,选择模型或 provider。55 }56}5758export function stream(input: StreamInput): StreamResult {对外暴露模块成员。59 const current = status(input)60 if (current.type === "unsupported") return current按条件进入分支。
native runtime 支持检查与请求入口
packages/opencode/src/session/llm/native-runtime.ts:39-80
unsupported 是正常能力判断,不等同于模型请求失败。
39export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus {选择模型或 provider。40 const providerID = input.model.providerID选择模型或 provider。41 if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))选择模型或 provider。42 return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }选择模型或 provider。43 const npm = input.model.api.npm44 if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/anthropic")按条件进入分支。45 return { type: "unsupported", reason: "provider package is not OpenAI or Anthropic" }选择模型或 provider。46 if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }按条件进入分支。4748 const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key选择模型或 provider。49 if (!apiKey) return { type: "unsupported", reason: "API key is not configured" }按条件进入分支。5051 return {返回给上一层。52 type: "supported",53 apiKey,54 baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,选择模型或 provider。55 }56}5758export function stream(input: StreamInput): StreamResult {对外暴露模块成员。59 const current = status(input)60 if (current.type === "unsupported") return current按条件进入分支。6162 return {返回给上一层。63 ...current,64 stream: input.llmClient.stream({65 request: LLMNative.request({66 model: input.model,选择模型或 provider。67 apiKey: current.apiKey,68 baseURL: current.baseURL,69 system: input.isOpenaiOauth ? input.system : [],70 messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),选择模型或 provider。71 toolChoice: input.toolChoice,72 temperature: input.temperature,73 topP: input.topP,74 topK: input.topK,75 maxOutputTokens: input.maxOutputTokens,76 providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),选择模型或 provider。77 headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },选择模型或 provider。78 }),79 tools: nativeTools(input.tools, input),80 }),
native 支持时直接返回 Stream<LLMEvent>;否则进入下一站。packages/llm/src/protocols/index.ts:1-6 显示 native LLM 包对外组织了 Anthropic、Bedrock、Gemini、OpenAI Chat/Compatible/Responses 等协议模块,但仅凭这个 index 不能推断每个模型此刻都走 native 分支。
packages/llm/src/protocols/index.ts
packages/llm/src/protocols/index.ts:1-6
1export * as AnthropicMessages from "./anthropic-messages"对外暴露模块成员。2export * as BedrockConverse from "./bedrock-converse"对外暴露模块成员。3export * as Gemini from "./gemini"对外暴露模块成员。4export * as OpenAIChat from "./openai-chat"对外暴露模块成员。5export * as OpenAICompatibleChat from "./openai-compatible-chat"对外暴露模块成员。6export * as OpenAIResponses from "./openai-responses"对外暴露模块成员。
6.9 第九站:AI SDK streamText 发出真正请求
Section titled “6.9 第九站:AI SDK streamText 发出真正请求”AI SDK 分支调用 streamText,主要参数包括:
- temperature/topP/topK/providerOptions;
- activeTools/tools/toolChoice;
- maxOutputTokens、abortSignal、maxRetries;
- messages、headers 与 language model;
- telemetry 与 tool-call repair。
来源:packages/opencode/src/session/llm.ts:395-468。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:395-468
395 yield* Effect.logInfo("llm runtime selected").pipe(Effect 异步工作流。396 Effect.annotateLogs({Effect 异步工作流。397 "llm.runtime": "ai-sdk",398 "llm.provider": input.model.providerID,选择模型或 provider。399 "llm.model": input.model.id,400 }),401 )402 return {返回给上一层。403 type: "ai-sdk" as const,404 result: streamText({向模型发起请求。405 onError(error) {406 l.error("stream error", {407 error,408 })409 },410 async experimental_repairToolCall(failed) {411 const lower = failed.toolCall.toolName.toLowerCase()412 if (lower !== failed.toolCall.toolName && sortedTools[lower]) {按条件进入分支。413 l.info("repairing tool call", {414 tool: failed.toolCall.toolName,415 repaired: lower,416 })417 return {返回给上一层。418 ...failed.toolCall,419 toolName: lower,420 }421 }422 return {返回给上一层。423 ...failed.toolCall,424 input: JSON.stringify({425 tool: failed.toolCall.toolName,426 error: failed.error.message,427 }),428 toolName: "invalid",429 }430 },431 temperature: params.temperature,432 topP: params.topP,433 topK: params.topK,434 providerOptions: ProviderTransform.providerOptions(input.model, params.options),选择模型或 provider。435 activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),436 tools: sortedTools,437 toolChoice: input.toolChoice,438 maxOutputTokens: params.maxOutputTokens,439 abortSignal: input.abort,440 headers: requestHeaders,441 maxRetries: input.retries ?? 0,442 messages,443 model: wrapLanguageModel({选择模型或 provider。444 model: language,选择模型或 provider。445 middleware: [446 {447 specificationVersion: "v3" as const,448 async transformParams(args) {449 if (args.type === "stream") {按条件进入分支。450 // @ts-expect-error451 args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)选择模型或 provider。452 }453 return args.params返回给上一层。454 },455 },456 ],457 }),458 experimental_telemetry: {459 isEnabled: cfg.experimental?.openTelemetry,460 functionId: "session.llm",461 tracer: telemetryTracer,462 metadata: {463 userId: cfg.username ?? "unknown",464 sessionId: input.sessionID,465 },466 },467 }),468 }
AI SDK 请求组装
packages/opencode/src/session/llm.ts:395-468
先按参数类别阅读;不要把这段误认成 agent loop。
395 yield* Effect.logInfo("llm runtime selected").pipe(Effect 异步工作流。396 Effect.annotateLogs({Effect 异步工作流。397 "llm.runtime": "ai-sdk",398 "llm.provider": input.model.providerID,选择模型或 provider。399 "llm.model": input.model.id,400 }),401 )402 return {返回给上一层。403 type: "ai-sdk" as const,404 result: streamText({向模型发起请求。405 onError(error) {406 l.error("stream error", {407 error,408 })409 },410 async experimental_repairToolCall(failed) {411 const lower = failed.toolCall.toolName.toLowerCase()412 if (lower !== failed.toolCall.toolName && sortedTools[lower]) {按条件进入分支。413 l.info("repairing tool call", {414 tool: failed.toolCall.toolName,415 repaired: lower,416 })417 return {返回给上一层。418 ...failed.toolCall,419 toolName: lower,420 }421 }422 return {返回给上一层。423 ...failed.toolCall,424 input: JSON.stringify({425 tool: failed.toolCall.toolName,426 error: failed.error.message,427 }),428 toolName: "invalid",429 }430 },431 temperature: params.temperature,432 topP: params.topP,433 topK: params.topK,434 providerOptions: ProviderTransform.providerOptions(input.model, params.options),选择模型或 provider。435 activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),436 tools: sortedTools,437 toolChoice: input.toolChoice,438 maxOutputTokens: params.maxOutputTokens,439 abortSignal: input.abort,440 headers: requestHeaders,441 maxRetries: input.retries ?? 0,442 messages,443 model: wrapLanguageModel({选择模型或 provider。444 model: language,选择模型或 provider。445 middleware: [446 {447 specificationVersion: "v3" as const,448 async transformParams(args) {449 if (args.type === "stream") {按条件进入分支。450 // @ts-expect-error451 args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)选择模型或 provider。452 }453 return args.params返回给上一层。454 },455 },456 ],457 }),458 experimental_telemetry: {459 isEnabled: cfg.experimental?.openTelemetry,460 functionId: "session.llm",461 tracer: telemetryTracer,462 metadata: {463 userId: cfg.username ?? "unknown",464 sessionId: input.sessionID,465 },466 },467 }),468 }
关键的一层在 wrapLanguageModel middleware:请求真正发出前,ProviderTransform.message(...) 再处理 prompt。来源:packages/opencode/src/session/llm.ts:443-457。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:443-457
443 model: wrapLanguageModel({选择模型或 provider。444 model: language,选择模型或 provider。445 middleware: [446 {447 specificationVersion: "v3" as const,448 async transformParams(args) {449 if (args.type === "stream") {按条件进入分支。450 // @ts-expect-error451 args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)选择模型或 provider。452 }453 return args.params返回给上一层。454 },455 },456 ],457 }),
这说明内部 ModelMessage[] 是稳定语义,不保证已经符合每家 provider 的所有格式限制。
6.10 第十站:ProviderTransform 修复“语义相同、格式不兼容”
Section titled “6.10 第十站:ProviderTransform 修复“语义相同、格式不兼容””ProviderTransform.message 的总入口依次处理不支持的附件 part、消息归一化、部分 provider 的缓存标记,以及 providerOptions key 映射。来源:packages/opencode/src/provider/transform.ts:429-474。
packages/opencode/src/provider/transform.ts
packages/opencode/src/provider/transform.ts:429-474
429export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {选择模型或 provider。430 msgs = unsupportedParts(msgs, model)431 msgs = normalizeMessages(msgs, model, options)432 if (按条件进入分支。433 (model.providerID === "anthropic" ||选择模型或 provider。434 model.providerID === "google-vertex-anthropic" ||选择模型或 provider。435 model.api.id.includes("anthropic") ||436 model.api.id.includes("claude") ||437 model.id.includes("anthropic") ||438 model.id.includes("claude") ||439 model.api.npm === "@ai-sdk/anthropic" ||440 model.api.npm === "@ai-sdk/alibaba") &&441 model.api.npm !== "@ai-sdk/gateway"442 ) {443 msgs = applyCaching(msgs, model)444 }445446 // Remap providerOptions keys from stored providerID to expected SDK key447 const key = sdkKey(model.api.npm)448 if (key && key !== model.providerID) {选择模型或 provider。449 const remap = (opts: Record<string, any> | undefined) => {450 if (!opts) return opts按条件进入分支。451 if (!(model.providerID in opts)) return opts选择模型或 provider。452 const result = { ...opts }453 result[key] = result[model.providerID]选择模型或 provider。454 delete result[model.providerID]选择模型或 provider。455 return result返回给上一层。456 }457458 msgs = msgs.map((msg) => {459 if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) }选择模型或 provider。460 return {返回给上一层。461 ...msg,462 providerOptions: remap(msg.providerOptions),选择模型或 provider。463 content: msg.content.map((part) => {464 if (part.type === "tool-approval-request" || part.type === "tool-approval-response") {按条件进入分支。465 return { ...part }返回给上一层。466 }467 return { ...part, providerOptions: remap(part.providerOptions) }选择模型或 provider。468 }),469 } as typeof msg470 })471 }472473 return msgs返回给上一层。474}
normalizeMessages 中可见的真实兼容例子包括:
- 清理非法 surrogate 字符;
- Anthropic/Bedrock 过滤空内容;
- Claude tool-call id 字符清理;
- 特定 Anthropic SDK 调整 tool-call 与非 tool 内容顺序。
来源:packages/opencode/src/provider/transform.ts:58-234。
packages/opencode/src/provider/transform.ts
packages/opencode/src/provider/transform.ts:58-234
58function normalizeMessages(定义一段可复用逻辑。59 msgs: ModelMessage[],60 model: Provider.Model,选择模型或 provider。61 _options: Record<string, unknown>,62): ModelMessage[] {63 const sanitizeToolResultOutput = (content: ToolResultPart) => {64 if (content.output.type === "text" || content.output.type === "error-text") {按条件进入分支。65 content.output.value = sanitizeSurrogates(content.output.value)66 }67 if (content.output.type === "content") {按条件进入分支。68 content.output.value = content.output.value.map((item) => {69 if (item.type === "text") {按条件进入分支。70 item.text = sanitizeSurrogates(item.text)71 }72 return item返回给上一层。73 })74 }75 return content返回给上一层。76 }7778 msgs = msgs.map((msg) => {79 switch (msg.role) {80 case "tool":81 if (!Array.isArray(msg.content)) return msg按条件进入分支。82 msg.content = msg.content.map((content) => {83 if (content.type === "tool-result") {按条件进入分支。84 return sanitizeToolResultOutput(content)返回给上一层。85 }86 return content返回给上一层。87 })88 return msg返回给上一层。8990 case "system":91 msg.content = sanitizeSurrogates(msg.content)92 return msg返回给上一层。9394 case "user":95 if (typeof msg.content === "string") {按条件进入分支。96 msg.content = sanitizeSurrogates(msg.content)97 } else {98 msg.content = msg.content.map((content) => {99 if (content.type === "text") {按条件进入分支。100 content.text = sanitizeSurrogates(content.text)101 }102 return content返回给上一层。103 })104 }105 return msg返回给上一层。106107 case "assistant":108 if (typeof msg.content === "string") {按条件进入分支。109 msg.content = sanitizeSurrogates(msg.content)110 } else {111 msg.content = msg.content.map((content) => {112 if (content.type === "text" || content.type === "reasoning") {按条件进入分支。113 content.text = sanitizeSurrogates(content.text)114 }115 if (content.type === "tool-result") {按条件进入分支。116 return sanitizeToolResultOutput(content)返回给上一层。117 }118 return content返回给上一层。119 })120 }121 return msg返回给上一层。122 }123 })124125 // Anthropic rejects messages with empty content - filter out empty string messages126 // and remove empty text/reasoning parts from array content127 if (model.api.npm === "@ai-sdk/anthropic") {按条件进入分支。128 msgs = msgs129 .map((msg) => {130 if (typeof msg.content === "string") {按条件进入分支。131 if (msg.content === "") return undefined按条件进入分支。132 return msg返回给上一层。133 }134 if (!Array.isArray(msg.content)) return msg按条件进入分支。135 const filtered = msg.content.filter((part) => {136 if (part.type === "text") {按条件进入分支。137 return part.text !== ""返回给上一层。138 }139 if (part.type === "reasoning") {按条件进入分支。140 return (返回给上一层。141 part.text.trim().length > 0 ||142 part.providerOptions?.anthropic?.signature != null ||选择模型或 provider。143 part.providerOptions?.anthropic?.redactedData != null选择模型或 provider。144 )145 }146 return true返回给上一层。147 })148 if (filtered.length === 0) return undefined按条件进入分支。149 return { ...msg, content: filtered }返回给上一层。150 })151 .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")152 }153154 // Bedrock specific transforms155 if (model.api.npm === "@ai-sdk/amazon-bedrock") {按条件进入分支。156 msgs = msgs157 .map((msg) => {158 if (typeof msg.content === "string") {按条件进入分支。159 if (msg.content === "") return undefined按条件进入分支。160 return msg返回给上一层。161 }162 if (!Array.isArray(msg.content)) return msg按条件进入分支。163 const filtered = msg.content.filter((part) => {164 if (part.type === "text") {按条件进入分支。165 return part.text !== ""返回给上一层。166 }167 if (part.type === "reasoning") {按条件进入分支。168 return (返回给上一层。169 part.text.trim().length > 0 ||170 part.providerOptions?.bedrock?.signature != null ||选择模型或 provider。171 part.providerOptions?.bedrock?.redactedData != null选择模型或 provider。172 )173 }174 return true返回给上一层。175 })176 if (filtered.length === 0) return undefined按条件进入分支。177 return { ...msg, content: filtered }返回给上一层。178 })179 .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")180 }181182 if (model.api.id.includes("claude")) {按条件进入分支。183 const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")184 msgs = msgs.map((msg) => {185 if (msg.role === "assistant" && Array.isArray(msg.content)) {按条件进入分支。186 return {返回给上一层。187 ...msg,188 content: msg.content.map((part) => {189 if (part.type === "tool-call" || part.type === "tool-result") {按条件进入分支。190 return { ...part, toolCallId: scrub(part.toolCallId) }返回给上一层。191 }192 return part返回给上一层。193 }),194 }195 }196 if (msg.role === "tool" && Array.isArray(msg.content)) {按条件进入分支。197 return {返回给上一层。198 ...msg,199 content: msg.content.map((part) => {200 if (part.type === "tool-result") {按条件进入分支。201 return { ...part, toolCallId: scrub(part.toolCallId) }返回给上一层。202 }203 return part返回给上一层。204 }),205 }206 }207 return msg返回给上一层。208 })209 }210 if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {按条件进入分支。211 // Anthropic rejects assistant turns where tool_use blocks are followed by non-tool212 // content, e.g. [tool_use, tool_use, text], with:213 // `tool_use` ids were found without `tool_result` blocks immediately after...214 //215 // Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive216 // assistant messages are later merged by the provider/SDK, so preserving the217 // original [tool_use...] then [text] order still produces the invalid payload.218 //219 // The root cause appears to be somewhere upstream where the stream is originally220 // processed. We were unable to locate an exact narrower reproduction elsewhere,221 // so we keep this transform in place for the time being.222 msgs = msgs.flatMap((msg) => {223 if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg]按条件进入分支。224225 const parts = msg.content226 const first = parts.findIndex((part) => part.type === "tool-call")227 if (first === -1) return [msg]按条件进入分支。228 if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg]按条件进入分支。229 return [返回给上一层。230 { ...msg, content: parts.filter((part) => part.type !== "tool-call") },231 { ...msg, content: parts.filter((part) => part.type === "tool-call") },232 ]233 })234 }
消息归一化的第一批兼容规则
packages/opencode/src/provider/transform.ts:58-151
这些规则证明统一内部语义仍需要 provider 边界修形。
58function normalizeMessages(定义一段可复用逻辑。59 msgs: ModelMessage[],60 model: Provider.Model,选择模型或 provider。61 _options: Record<string, unknown>,62): ModelMessage[] {63 const sanitizeToolResultOutput = (content: ToolResultPart) => {64 if (content.output.type === "text" || content.output.type === "error-text") {按条件进入分支。65 content.output.value = sanitizeSurrogates(content.output.value)66 }67 if (content.output.type === "content") {按条件进入分支。68 content.output.value = content.output.value.map((item) => {69 if (item.type === "text") {按条件进入分支。70 item.text = sanitizeSurrogates(item.text)71 }72 return item返回给上一层。73 })74 }75 return content返回给上一层。76 }7778 msgs = msgs.map((msg) => {79 switch (msg.role) {80 case "tool":81 if (!Array.isArray(msg.content)) return msg按条件进入分支。82 msg.content = msg.content.map((content) => {83 if (content.type === "tool-result") {按条件进入分支。84 return sanitizeToolResultOutput(content)返回给上一层。85 }86 return content返回给上一层。87 })88 return msg返回给上一层。8990 case "system":91 msg.content = sanitizeSurrogates(msg.content)92 return msg返回给上一层。9394 case "user":95 if (typeof msg.content === "string") {按条件进入分支。96 msg.content = sanitizeSurrogates(msg.content)97 } else {98 msg.content = msg.content.map((content) => {99 if (content.type === "text") {按条件进入分支。100 content.text = sanitizeSurrogates(content.text)101 }102 return content返回给上一层。103 })104 }105 return msg返回给上一层。106107 case "assistant":108 if (typeof msg.content === "string") {按条件进入分支。109 msg.content = sanitizeSurrogates(msg.content)110 } else {111 msg.content = msg.content.map((content) => {112 if (content.type === "text" || content.type === "reasoning") {按条件进入分支。113 content.text = sanitizeSurrogates(content.text)114 }115 if (content.type === "tool-result") {按条件进入分支。116 return sanitizeToolResultOutput(content)返回给上一层。117 }118 return content返回给上一层。119 })120 }121 return msg返回给上一层。122 }123 })124125 // Anthropic rejects messages with empty content - filter out empty string messages126 // and remove empty text/reasoning parts from array content127 if (model.api.npm === "@ai-sdk/anthropic") {按条件进入分支。128 msgs = msgs129 .map((msg) => {130 if (typeof msg.content === "string") {按条件进入分支。131 if (msg.content === "") return undefined按条件进入分支。132 return msg返回给上一层。133 }134 if (!Array.isArray(msg.content)) return msg按条件进入分支。135 const filtered = msg.content.filter((part) => {136 if (part.type === "text") {按条件进入分支。137 return part.text !== ""返回给上一层。138 }139 if (part.type === "reasoning") {按条件进入分支。140 return (返回给上一层。141 part.text.trim().length > 0 ||142 part.providerOptions?.anthropic?.signature != null ||选择模型或 provider。143 part.providerOptions?.anthropic?.redactedData != null选择模型或 provider。144 )145 }146 return true返回给上一层。147 })148 if (filtered.length === 0) return undefined按条件进入分支。149 return { ...msg, content: filtered }返回给上一层。150 })151 .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
不要把这些规则背成永恒标准。它们明显依赖 provider/SDK 行为,升级时必须用当前源码和集成测试重新验证。
6.11 第十一站:原始流被翻译为 LLMEvent
Section titled “6.11 第十一站:原始流被翻译为 LLMEvent”LLM.stream 为本次调用创建 scoped AbortController。AI SDK 路径把 result.fullStream 转成 Effect Stream,再逐个交给 LLMAISDK.toLLMEvents。来源:packages/opencode/src/session/llm.ts:471-493。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:471-493
471 const stream: Interface["stream"] = (input) =>472 Stream.scoped(473 Stream.unwrap(474 Effect.gen(function* () {Effect 异步工作流。475 const ctrl = yield* Effect.acquireRelease(Effect 异步工作流。476 Effect.sync(() => new AbortController()),用于中断运行任务。477 (ctrl) => Effect.sync(() => ctrl.abort()),Effect 异步工作流。478 )479480 const result = yield* run({ ...input, abort: ctrl.signal })等待 Effect 结果。481482 if (result.type === "native") return result.stream按条件进入分支。483484 const state = LLMAISDK.adapterState()485 return Stream.fromAsyncIterable(result.result.fullStream, (e) =>返回给上一层。486 e instanceof Error ? e : new Error(String(e)),487 ).pipe(488 Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),489 Stream.flatMap((events) => Stream.fromIterable(events)),490 )491 }),492 ),493 )
adapter 维护 step、当前 text/reasoning id 与 tool call id 到名称的映射。典型转换包括:
start-step -> LLMEvent.stepStarttext-delta -> LLMEvent.textDeltatool-call -> LLMEvent.toolCalltool-result -> LLMEvent.toolResultfinish-step -> LLMEvent.stepFinishfinish -> LLMEvent.finisherror -> Effect.fail来源:packages/opencode/src/session/llm/ai-sdk.ts:9-18、packages/opencode/src/session/llm/ai-sdk.ts:61-252。
packages/opencode/src/session/llm/ai-sdk.ts
packages/opencode/src/session/llm/ai-sdk.ts:9-18
9export function adapterState() {对外暴露模块成员。10 return {返回给上一层。11 step: 0,12 text: 0,13 reasoning: 0,14 currentTextID: undefined as string | undefined,15 currentReasoningID: undefined as string | undefined,16 toolNames: {} as Record<string, string>,17 }18}
packages/opencode/src/session/llm/ai-sdk.ts
packages/opencode/src/session/llm/ai-sdk.ts:61-252
61export function toLLMEvents(对外暴露模块成员。62 state: ReturnType<typeof adapterState>,63 event: AISDKEvent,64): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {Effect 异步工作流。65 switch (event.type) {66 case "start":67 return Effect.succeed([])Effect 异步工作流。6869 case "start-step":70 return Effect.succeed([LLMEvent.stepStart({ index: state.step })])Effect 异步工作流。7172 case "finish-step":73 return Effect.sync(() => [Effect 异步工作流。74 LLMEvent.stepFinish({75 index: state.step++,76 reason: finishReason(event.finishReason),77 usage: usage(event.usage),78 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。79 }),80 ])8182 case "finish":83 return Effect.sync(() => {Effect 异步工作流。84 const events = [85 LLMEvent.finish({86 reason: finishReason(event.finishReason),87 usage: usage(event.totalUsage),88 providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined,选择模型或 provider。89 }),90 ]91 // Reset so the adapter can be reused for a follow-up stream without leaking92 // counters or block IDs. adapterState() is the single source of truth for shape.93 Object.assign(state, adapterState())94 return events返回给上一层。95 })9697 case "text-start":98 return Effect.sync(() => {Effect 异步工作流。99 state.currentTextID = currentTextID(state, event.id)100 return [返回给上一层。101 LLMEvent.textStart({102 id: state.currentTextID,103 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。104 }),105 ]106 })107108 case "text-delta":109 return Effect.succeed([Effect 异步工作流。110 LLMEvent.textDelta({111 id: currentTextID(state, event.id),112 text: event.text,113 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。114 }),115 ])116117 case "text-end":118 return Effect.sync(() => {Effect 异步工作流。119 const id = currentTextID(state, event.id)120 state.currentTextID = undefined121 return [返回给上一层。122 LLMEvent.textEnd({123 id,124 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。125 }),126 ]127 })128129 case "reasoning-start":130 return Effect.sync(() => {Effect 异步工作流。131 state.currentReasoningID = currentReasoningID(state, event.id)132 return [返回给上一层。133 LLMEvent.reasoningStart({134 id: state.currentReasoningID,135 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。136 }),137 ]138 })139140 case "reasoning-delta":141 return Effect.succeed([Effect 异步工作流。142 LLMEvent.reasoningDelta({143 id: currentReasoningID(state, event.id),144 text: event.text,145 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。146 }),147 ])148149 case "reasoning-end":150 return Effect.sync(() => {Effect 异步工作流。151 const id = currentReasoningID(state, event.id)152 state.currentReasoningID = undefined153 return [返回给上一层。154 LLMEvent.reasoningEnd({155 id,156 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。157 }),158 ]159 })160161 case "tool-input-start":162 return Effect.sync(() => {Effect 异步工作流。163 state.toolNames[event.id] = event.toolName164 return [返回给上一层。165 LLMEvent.toolInputStart({166 id: event.id,167 name: event.toolName,168 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。169 }),170 ]171 })172173 case "tool-input-delta":174 return Effect.succeed([Effect 异步工作流。175 LLMEvent.toolInputDelta({176 id: event.id,177 name: state.toolNames[event.id] ?? "unknown",178 text: event.delta ?? "",179 }),180 ])181182 case "tool-input-end":183 return Effect.succeed([Effect 异步工作流。184 LLMEvent.toolInputEnd({185 id: event.id,186 name: state.toolNames[event.id] ?? "unknown",187 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。188 }),189 ])190191 case "tool-call":192 return Effect.sync(() => {Effect 异步工作流。193 state.toolNames[event.toolCallId] = event.toolName194 return [返回给上一层。195 LLMEvent.toolCall({196 id: event.toolCallId,197 name: event.toolName,198 input: event.input,199 providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,选择模型或 provider。200 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。201 }),202 ]203 })204205 case "tool-result":206 return Effect.sync(() => {Effect 异步工作流。207 const name = state.toolNames[event.toolCallId] ?? "unknown"208 delete state.toolNames[event.toolCallId]209 return [返回给上一层。210 LLMEvent.toolResult({211 id: event.toolCallId,212 name,213 result: ToolResultValue.make(event.output),214 providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,选择模型或 provider。215 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。216 }),217 ]218 })219220 case "tool-error":221 return Effect.sync(() => {Effect 异步工作流。222 const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")223 delete state.toolNames[event.toolCallId]224 return [返回给上一层。225 LLMEvent.toolError({226 id: event.toolCallId,227 name,228 message: errorMessage(event.error),229 error: event.error,230 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。231 }),232 ]233 })234235 case "error":236 return Effect.fail(event.error)Effect 异步工作流。237238 case "abort":239 case "source":240 case "file":241 case "raw":242 case "tool-output-denied":243 case "tool-approval-request":244 return Effect.succeed([])Effect 异步工作流。245246 default: {247 const _exhaustive: never = event248 void _exhaustive249 return Effect.succeed([])Effect 异步工作流。250 }251 }252}
tool call/result/error 的事件翻译
packages/opencode/src/session/llm/ai-sdk.ts:191-233
session processor 只看到统一 name、id、result 与 error,不再依赖 AI SDK 事件形状。
191 case "tool-call":192 return Effect.sync(() => {Effect 异步工作流。193 state.toolNames[event.toolCallId] = event.toolName194 return [返回给上一层。195 LLMEvent.toolCall({196 id: event.toolCallId,197 name: event.toolName,198 input: event.input,199 providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,选择模型或 provider。200 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。201 }),202 ]203 })204205 case "tool-result":206 return Effect.sync(() => {Effect 异步工作流。207 const name = state.toolNames[event.toolCallId] ?? "unknown"208 delete state.toolNames[event.toolCallId]209 return [返回给上一层。210 LLMEvent.toolResult({211 id: event.toolCallId,212 name,213 result: ToolResultValue.make(event.output),214 providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,选择模型或 provider。215 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。216 }),217 ]218 })219220 case "tool-error":221 return Effect.sync(() => {Effect 异步工作流。222 const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")223 delete state.toolNames[event.toolCallId]224 return [返回给上一层。225 LLMEvent.toolError({226 id: event.toolCallId,227 name,228 message: errorMessage(event.error),229 error: event.error,230 providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。231 }),232 ]233 })
到这里,provider 的职责结束;统一事件继续交回 SessionProcessor 落成 message parts。
7. 五个不能忽略的失败与兼容分支
Section titled “7. 五个不能忽略的失败与兼容分支”7.1 provider 或 model 不存在
Section titled “7.1 provider 或 model 不存在”Provider.getModel 在 provider/model 缺失时返回 ModelNotFoundError,并尽量提供 suggestions。来源:packages/opencode/src/provider/provider.ts:1655-1677。resolveSDK 初始化异常则包装成 InitError。来源:packages/opencode/src/provider/provider.ts:1646-1648。
packages/opencode/src/provider/provider.ts
packages/opencode/src/provider/provider.ts:1655-1677
1655 const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {选择模型或 provider。1656 const s = yield* InstanceState.get(state)等待 Effect 结果。1657 const provider = s.providers[providerID]选择模型或 provider。1658 if (!provider) {选择模型或 provider。1659 const catalogProvider = s.catalog[providerID]选择模型或 provider。1660 const suggestions = catalogProvider选择模型或 provider。1661 ? modelSuggestions(catalogProvider, modelID, runtimeFlags.enableExperimentalModels)选择模型或 provider。1662 : fuzzysort1663 .go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })选择模型或 provider。1664 .map((m) => m.target)1665 return yield* new ModelNotFoundError({ providerID, modelID, suggestions })选择模型或 provider。1666 }16671668 const info = provider.models[modelID]选择模型或 provider。1669 if (!info) {按条件进入分支。1670 const current = modelSuggestions(provider, modelID, runtimeFlags.enableExperimentalModels)选择模型或 provider。1671 const suggestions = current.length1672 ? current1673 : modelSuggestions(s.catalog[providerID], modelID, runtimeFlags.enableExperimentalModels)选择模型或 provider。1674 return yield* new ModelNotFoundError({ providerID, modelID, suggestions })选择模型或 provider。1675 }1676 return info返回给上一层。1677 })
packages/opencode/src/provider/provider.ts
packages/opencode/src/provider/provider.ts:1646-1648
1646 } catch (e) {1647 throw new InitError({ providerID: model.providerID, cause: e })选择模型或 provider。1648 }
7.2 native unsupported 不是请求失败
Section titled “7.2 native unsupported 不是请求失败”unsupported 只是说明当前请求不走 native;随后会回退 AI SDK。只有 native 已选中后的 stream failure 或 AI SDK error 才是本次模型调用错误。
7.3 模型不支持某种附件
Section titled “7.3 模型不支持某种附件”ProviderTransform.message 先调用 unsupportedParts;它会把不支持的附件变成可读错误文本,提示模型告知用户。来源:packages/opencode/src/provider/transform.ts:379-431。这是降级语义,不是让 provider 接收它不支持的二进制。
packages/opencode/src/provider/transform.ts
packages/opencode/src/provider/transform.ts:379-431
379 ) {380 lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)选择模型或 provider。381 continue382 }383 }384385 msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)选择模型或 provider。386 }387388 return msgs返回给上一层。389}390391function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {选择模型或 provider。392 return msgs.map((msg) => {返回给上一层。393 if (msg.role !== "user" || !Array.isArray(msg.content)) return msg按条件进入分支。394395 const filtered = msg.content.map((part) => {396 if (part.type !== "file" && part.type !== "image") return part按条件进入分支。397398 // Check for empty base64 image data399 if (part.type === "image") {按条件进入分支。400 const imageStr = String(part.image)401 if (imageStr.startsWith("data:")) {按条件进入分支。402 const match = imageStr.match(/^data:([^;]+);base64,(.*)$/)403 if (match && (!match[2] || match[2].length === 0)) {按条件进入分支。404 return {返回给上一层。405 type: "text" as const,406 text: "ERROR: Image file is empty or corrupted. Please provide a valid image.",407 }408 }409 }410 }411412 const mime = part.type === "image" ? String(part.image).split(";")[0].replace("data:", "") : part.mediaType413 const filename = part.type === "file" ? part.filename : undefined414 const modality = mimeToModality(mime)415 if (!modality) return part按条件进入分支。416 if (model.capabilities.input[modality]) return part按条件进入分支。417418 const name = filename ? `"${filename}"` : modality419 return {返回给上一层。420 type: "text" as const,421 text: `ERROR: Cannot read ${name} (this model does not support ${modality} input). Inform the user.`,422 }423 })424425 return { ...msg, content: filtered }返回给上一层。426 })427}428429export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {选择模型或 provider。430 msgs = unsupportedParts(msgs, model)431 msgs = normalizeMessages(msgs, model, options)
7.4 tool call 名称错误会尝试修复
Section titled “7.4 tool call 名称错误会尝试修复”AI SDK 分支先尝试把工具名转为 lowercase 并匹配现有工具;仍不匹配时把调用改写给 invalid 工具,并携带原工具名与错误。来源:packages/opencode/src/session/llm.ts:410-430。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:410-430
410 async experimental_repairToolCall(failed) {411 const lower = failed.toolCall.toolName.toLowerCase()412 if (lower !== failed.toolCall.toolName && sortedTools[lower]) {按条件进入分支。413 l.info("repairing tool call", {414 tool: failed.toolCall.toolName,415 repaired: lower,416 })417 return {返回给上一层。418 ...failed.toolCall,419 toolName: lower,420 }421 }422 return {返回给上一层。423 ...failed.toolCall,424 input: JSON.stringify({425 tool: failed.toolCall.toolName,426 error: failed.error.message,427 }),428 toolName: "invalid",429 }430 },
这让无效工具调用进入可观察的工具失败路径,而不是在 provider 边界静默消失。
7.5 取消必须穿透到请求
Section titled “7.5 取消必须穿透到请求”scoped AbortController 在 stream scope 释放时 abort,并作为 abortSignal 传入 AI SDK;native 工具包装同样转发 abort。来源:packages/opencode/src/session/llm.ts:471-480、packages/opencode/src/session/llm/native-runtime.ts:98-116。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:471-480
471 const stream: Interface["stream"] = (input) =>472 Stream.scoped(473 Stream.unwrap(474 Effect.gen(function* () {Effect 异步工作流。475 const ctrl = yield* Effect.acquireRelease(Effect 异步工作流。476 Effect.sync(() => new AbortController()),用于中断运行任务。477 (ctrl) => Effect.sync(() => ctrl.abort()),Effect 异步工作流。478 )479480 const result = yield* run({ ...input, abort: ctrl.signal })等待 Effect 结果。
packages/opencode/src/session/llm/native-runtime.ts
packages/opencode/src/session/llm/native-runtime.ts:98-116
98export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) {对外暴露模块成员。99 return Object.fromEntries(返回给上一层。100 Object.entries(tools).map(([name, item]) => [101 name,102 nativeTool({103 description: item.description ?? "",104 jsonSchema: nativeSchema(item.inputSchema),105 execute: (args: unknown, ctx) =>106 Effect.tryPromise({Effect 异步工作流。107 try: () => {108 if (!item.execute) throw new Error(`Tool has no execute handler: ${name}`)按条件进入分支。109 return item.execute(args, {工具真正执行入口。110 toolCallId: ctx?.id ?? name,111 messages: input.messages,112 abortSignal: input.abort,113 })114 },115 catch: (error) => new ToolFailure({ message: errorMessage(error), error }),集中处理异常。116 }),
8. OpenCode 的选择:替代方案与代价
Section titled “8. OpenCode 的选择:替代方案与代价”| 设计问题 | OpenCode 的选择 | 可选做法 | 收益与代价 |
|---|---|---|---|
| loop 看什么输出 | 统一 LLMEvent | loop 直接处理各 SDK event | session 稳定;需要维护 adapter |
| provider client 如何创建 | 元数据驱动、动态 factory、缓存 | 每家 provider 手写固定 client | 扩展灵活;初始化与缓存键复杂 |
| 兼容规则放哪里 | ProviderTransform 边界集中处理 | 污染 message domain model | 内部语义干净;transform 容易膨胀 |
| runtime 如何演进 | native 支持时使用,否则 AI SDK 回退 | 一次性切换实现 | 可渐进迁移;两条路径都要验证 |
| 配置怎样覆盖 | base -> model -> agent -> variant -> plugin | 单层全局 options | 灵活;最终参数来源不再单一 |
表中选择由源码直接证明;收益与代价是设计解释。
9. TypeScript / Effect:只补四个阅读障碍
Section titled “9. TypeScript / Effect:只补四个阅读障碍”9.1 Record<string, Tool>
Section titled “9.1 Record<string, Tool>”这是工具名到 AI SDK Tool 的映射,不是 Java 的 record。可类比 Map<String, Tool>,但运行时是普通对象。
9.2 Layer 与 Context.Service
Section titled “9.2 Layer 与 Context.Service”LLM.Service 声明能力接口,live/defaultLayer 组合 Auth、Config、Provider、Plugin 等依赖。可以类比 DI container 提供的 bean graph;不同点是依赖和错误在 Effect 类型中显式组合。
9.3 discriminated union 的 runtime 选择
Section titled “9.3 discriminated union 的 runtime 选择”native.type === "supported" 后,TypeScript 知道存在 stream;否则存在 reason。它是轻量的 result union,类似 Java sealed result。
9.4 Stream.mapEffect + flatMap
Section titled “9.4 Stream.mapEffect + flatMap”一个 AI SDK event 可能映射为零个或多个 LLMEvent,所以先 effectful 转换为数组,再把数组摊平成事件流。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:在边界两侧定义稳定语言
Section titled “方法一:在边界两侧定义稳定语言”输入用统一 request model,输出用统一 domain event;provider adapter 只负责翻译。
验证问题:新增 provider 时,SessionProcessor 是否完全不用修改?
方法二:把兼容修形集中在最后一公里
Section titled “方法二:把兼容修形集中在最后一公里”领域消息保持统一,发送前才处理 provider 的空消息、id、附件与 options 规则。
验证问题:某 provider 修复 bug 后,你能否只删除一条 transform,而不迁移历史消息?
方法三:让新 runtime 可回退、可观测
Section titled “方法三:让新 runtime 可回退、可观测”先做 capability/status 判断,记录选择与 unsupported reason,再回退成熟路径。
验证问题:native 覆盖不足时,用户得到的是透明回退还是硬失败?日志能否说明选了哪条路?
11. 费曼复述与练习阶梯
Section titled “11. 费曼复述与练习阶梯”11.1 60 秒复述
Section titled “11.1 60 秒复述”不要看上文,补全:
SessionProcessor 给 LLM 的稳定输入叫 ______。Provider 先把 model 元数据变成 ______。system 由 ______ 组合,options 按 ______ 的顺序覆盖。消息发送前经 ______ 修形。native 不支持时回退到 ______,原始流最后被翻译成 ______。
如果你的解释里仍出现“不同 provider 由 agent loop 分支处理”,请回看 packages/opencode/src/session/llm.ts:471-493。
packages/opencode/src/session/llm.ts
packages/opencode/src/session/llm.ts:471-493
471 const stream: Interface["stream"] = (input) =>472 Stream.scoped(473 Stream.unwrap(474 Effect.gen(function* () {Effect 异步工作流。475 const ctrl = yield* Effect.acquireRelease(Effect 异步工作流。476 Effect.sync(() => new AbortController()),用于中断运行任务。477 (ctrl) => Effect.sync(() => ctrl.abort()),Effect 异步工作流。478 )479480 const result = yield* run({ ...input, abort: ctrl.signal })等待 Effect 结果。481482 if (result.type === "native") return result.stream按条件进入分支。483484 const state = LLMAISDK.adapterState()485 return Stream.fromAsyncIterable(result.result.fullStream, (e) =>返回给上一层。486 e instanceof Error ? e : new Error(String(e)),487 ).pipe(488 Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),489 Stream.flatMap((events) => Stream.fromIterable(events)),490 )491 }),492 ),493 )
11.2 读源码练习
Section titled “11.2 读源码练习”- 入门:列出
StreamInput中与 provider 无关的五个字段。 - 进阶:从
provider.getLanguage追到resolveSDK,画出两级缓存键。 - 辨析:比较
ProviderTransform.message与LLMAISDK.toLLMEvents的方向。 - 失败路径:分别说明 model not found、native unsupported、AI SDK error 最终走向哪里。
11.3 小实现
Section titled “11.3 小实现”为 mini agent 写两个假 provider adapter:一个输出自定义 delta 事件,一个输出 chunk 事件;两者都必须转换成相同 DomainLLMEvent,业务 processor 不允许出现 provider 名称判断。
12. 最后复盘:桥接完成,但模型怎样获得行动能力
Section titled “12. 最后复盘:桥接完成,但模型怎样获得行动能力”本章主链是:
稳定 StreamInput -> provider client + system/options/tools/headers -> provider-specific message transform -> native 或 AI SDK stream -> 统一 LLMEvent你现在应该能回答中心问题:切换 provider 不重写 agent loop,是因为变化被夹在稳定输入与统一事件之间。
下一章还有一个关键缺口:LLM 请求里的 tools 从哪里来?description、schema 与真正的 execute 如何绑定,权限和输出截断又在哪一层发生?接下来进入“Tool 调用系统”。