跳转到内容

模型 Provider / LLM 调用

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度较难
预计阅读50 分钟
  • 章节 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
  • packages/opencode/src/session/llm.ts
  • packages/opencode/src/session/llm/ai-sdk.ts
  • packages/opencode/src/session/llm/native-runtime.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/provider/transform.ts
  • packages/llm/src/protocols/index.ts

本章以 OpenCode 源码版本 eec0843ce422 为证据基线。我们追踪一条典型 AI SDK 路径:session 已准备好消息、system 内容和工具;当 native runtime 未启用或不支持当前 provider 时,OpenCode 怎样构造请求、调用 provider,再把流还原为统一事件?这条路径由源码证明,但不是一次真实网络请求录屏。

学完这一章,你应该能够:

  1. 画出 SessionProcessor -> LLM.stream -> Provider -> native/AI SDK -> LLMEvent 的适配地图。
  2. 区分 provider 元数据、language model client、请求参数与 provider-specific transform。
  3. 沿源码解释 system、messages、tools、options 与 headers 怎样合成一次模型请求。
  4. 说明为什么同一份内部消息在发给不同 provider 前仍需要归一化。
  5. 判断 native runtime 何时回退、工具何时被过滤、异常事件怎样进入统一错误通道。
  6. 把“稳定内核 + 边界适配器”的方法迁移到自己的多模型 Agent。

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 -> text

coding 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
v
LLM.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 -> LLMStreamInputsession 如何准备历史,不泄漏给 provider
ProviderModelInfogetLanguageSDK 包、认证、base URL、model client
LLM -> sessionLLMEventAI SDK/native 原始流事件被适配掉

通用 Agent 内核只需要“输入一份模型上下文,返回一条事件流”;OpenCode 的 provider catalog、插件 hooks、native 实验路径、GitLab workflow 与大量兼容规则是产品层。

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.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”,但不能直接发请求。

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-deltatool-callfinish-step 等事件经 LLMAISDK.toLLMEvents 转成统一事件;native runtime 原生返回同一事件类型。来源:packages/opencode/src/session/llm/ai-sdk.ts:61-252packages/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" }) 同时获取:

  1. provider.getLanguage(input.model)
  2. 全局 config;
  3. provider info;
  4. 当前 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      }

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;不支持时返回带原因的 unsupportedLLM.run 记录原因并继续 AI SDK。来源:packages/opencode/src/session/llm.ts:352-393packages/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.stepStart
text-delta -> LLMEvent.textDelta
tool-call -> LLMEvent.toolCall
tool-result -> LLMEvent.toolResult
finish-step -> LLMEvent.stepFinish
finish -> LLMEvent.finish
error -> Effect.fail

来源:packages/opencode/src/session/llm/ai-sdk.ts:9-18packages/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. 五个不能忽略的失败与兼容分支”

Provider.getModel 在 provider/model 缺失时返回 ModelNotFoundError,并尽量提供 suggestions。来源:packages/opencode/src/provider/provider.ts:1655-1677resolveSDK 初始化异常则包装成 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      }

unsupported 只是说明当前请求不走 native;随后会回退 AI SDK。只有 native 已选中后的 stream failure 或 AI SDK error 才是本次模型调用错误。

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)

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 边界静默消失。

scoped AbortController 在 stream scope 释放时 abort,并作为 abortSignal 传入 AI SDK;native 工具包装同样转发 abort。来源:packages/opencode/src/session/llm.ts:471-480packages/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 看什么输出统一 LLMEventloop 直接处理各 SDK eventsession 稳定;需要维护 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:只补四个阅读障碍”

这是工具名到 AI SDK Tool 的映射,不是 Java 的 record。可类比 Map<String, Tool>,但运行时是普通对象。

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。

一个 AI SDK event 可能映射为零个或多个 LLMEvent,所以先 effectful 转换为数组,再把数组摊平成事件流。

方法一:在边界两侧定义稳定语言

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 覆盖不足时,用户得到的是透明回退还是硬失败?日志能否说明选了哪条路?

不要看上文,补全:

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      )
  1. 入门:列出 StreamInput 中与 provider 无关的五个字段。
  2. 进阶:从 provider.getLanguage 追到 resolveSDK,画出两级缓存键。
  3. 辨析:比较 ProviderTransform.messageLLMAISDK.toLLMEvents 的方向。
  4. 失败路径:分别说明 model not found、native unsupported、AI SDK error 最终走向哪里。

为 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 调用系统”。