# 模型调用层：AI SDK 与原生双轨

> LLM.stream 如何组装请求、参数与请求头，默认的 Vercel AI SDK 链路与可选的 @opencode-ai/llm 原生链路如何分流，又如何汇合成同一种 LLMEvent 流。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.org/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/opencode/llm-bridge
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

[处理器](https://daiw.org/manual/opencode/processor)只认一种输入：`LLMEvent` 流。把会话数据变成这条流的，是 `packages/opencode/src/session/llm.ts` 的 `LLM.Service`，以及它身后 `session/llm/` 目录里的三个适配器。

## 边界

`session/llm/AGENTS.md` 开宗明义：

> `../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection.

| 文件 | 职责 |
| --- | --- |
| `llm.ts` | 会话侧编排：认证、配置、插件、权限、请求头、运行时选择 |
| `llm/request.ts` | 组装 system、合并参数、筛选工具、生成请求头 |
| `llm/ai-sdk.ts` | 把 AI SDK 的 `fullStream` 事件转成 `LLMEvent`（默认路径） |
| `llm/native-request.ts` | 把会话数据转换成 `@opencode-ai/llm` 的 `LLMRequest`，不负责执行 |
| `llm/native-runtime.ts` | 原生路径的准入判断与工具桥接，交给 `LLMClient` 发送 |

```mermaid
flowchart LR
  P["处理器"] --> L["LLM.Service"]
  L --> G{"开了原生开关且支持？"}
  G -- 否 --> A["AI SDK streamText"]
  A --> T["ai-sdk.ts 转换事件"]
  G -- 是 --> R["native-request.ts 组请求"]
  R --> C["LLMClient 发送"]
  T --> E["LLMEvent 流"]
  C --> E
  E --> P
```

## stream：一次调用一个作用域

`llm.ts:357`：

```ts
    const stream: Interface["stream"] = (input) =>
      Stream.scoped(
        Stream.unwrap(
          Effect.gen(function* () {
            const ctrl = yield* Effect.acquireRelease(
              Effect.sync(() => new AbortController()),
              (ctrl) => Effect.sync(() => ctrl.abort()),
            )

            const result = yield* run({ ...input, abort: ctrl.signal })

            if (result.type === "native") return result.stream

            // Adapter seam: both runtimes expose the same LLMEvent stream. Native
            // already returns one; AI SDK streams are converted here.
            const state = LLMAISDK.adapterState()
            return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
              e instanceof Error ? e : new Error(String(e)),
            ).pipe(
              Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
              Stream.flatMap((events) => Stream.fromIterable(events)),
            )
          }),
        ),
      )
```

`AbortController` 的生命周期绑定在流的作用域上：作用域一关闭（用户中断，或处理器提前停止消费），`abort()` 就会执行，底层 HTTP 请求随之取消。适配器状态也是每次调用新建一份，互不串扰。

## run 与 prepare

`run`（`llm.ts:85`）先并行取语言模型实例、配置、提供方信息与认证，再调用 `LLMRequestPrep.prepare`（`llm/request.ts:56`）组装请求：

- **system**：拼装规则见[系统提示词](https://daiw.org/manual/opencode/system-prompt)。OpenAI 走 OAuth 时，system 改放进 `options.instructions`，不再作为 system 消息发送。
- **提供方参数**：按“`ProviderTransform` 的默认值 → 模型配置 → agent 配置 → 当前 variant”的顺序深度合并；小模型（如生成标题）改用 `smallOptions`。
- **采样参数**：`temperature`、`topP`、`topK`、`maxOutputTokens` 先取 agent 设置或按模型给出的默认值，再交给插件钩子 `chat.params` 修改；请求头同理经过 `chat.headers`。
- **工具**：按权限规则与 user 消息的 `tools` 开关过滤后按名字排序。OpenAI、Azure 等 Responses 系提供方给每个工具加 `strict: false`，注释说这是与 Codex 保持一致，让不满足结构化输出约束的 MCP 与动态 schema 也能注册；Copilot 在回放历史工具调用但当前没有工具时，会补一个声明为“永远不要调用”的 `_noop` 工具。
- **请求头**：OpenCode 自家提供方带 `x-opencode-project`、`x-opencode-session`、`x-opencode-request`、`x-opencode-client`；其他提供方带 `x-session-affinity` 与 `X-Session-Id`；子代理会话再加 `x-parent-session-id`；`User-Agent` 统一为 opencode 加版本号。

之后 `run` 处理两个特例——GitLab Workflow 模型的工具调用被接回 OpenCode 的工具系统执行，开启 `experimental.openTelemetry` 时给每个 span 打上 `session.id`——然后选择运行时。`ProviderTransform` 本身的规则见[提供方适配层](https://daiw.org/manual/opencode/provider-transform)。

## 默认路径：AI SDK

`streamText` 的调用（`llm.ts:280`）里几个参数值得注意：

| 参数 | 设置 | 说明 |
| --- | --- | --- |
| `maxRetries` | `input.retries ?? 0` | 重试交给会话层的 `SessionRetry`；主循环不传此值，生成标题时传 2 |
| `experimental_repairToolCall` | 工具名大小写不符就改成小写，否则转给 `invalid` 工具 | 失败原因作为 `invalid` 的输入回报给模型 |
| `activeTools` | 排除 `invalid` | 不向模型主动暴露兜底工具 |
| `includeRawChunks` | 仅 GitHub Copilot 开启 | 注释说 Copilot 的计费数据只出现在原始分块里 |
| `model` | 用 `wrapLanguageModel` 包一层中间件 | 发送前调用 `ProviderTransform.message` 按提供方修整消息 |

`llm/ai-sdk.ts` 的 `toLLMEvents` 再把 `fullStream` 逐个映射成 `LLMEvent`：`start-step` 变 `step-start`，`finish-step` 变 `step-finish`，文本、推理、工具输入、调用、结果、错误各有对应，`abort`、`source`、`file` 等被丢弃；`switch` 末尾用 `never` 做穷尽检查，AI SDK 新增事件类型时编译期就会报错。归一化的细节包括：

- 结束原因不在 `FinishReason` 枚举里就记为 `unknown`；
- 推理 token 取自 `outputTokenDetails`，缓存读写取自 `inputTokenDetails`；
- 提供方没给文本或推理块 ID 时，按序合成 `text-0`、`reasoning-0` 这样的 ID；
- 原始结束原因为 `network_error` 时转成可重试的 `ResponseStreamError`；
- 从原始分块里抽出 Copilot 的计费数值挂到 step-finish 上，注释称这是临时桥接，等 Copilot 迁到原生运行时再移走。

## 可选路径：原生 @opencode-ai/llm

`llm.ts:224`：

```ts
      // Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It
      // either returns a ready LLMEvent stream or a concrete fallback reason.
      if (flags.experimentalNativeLlm) {
```

开关是 `OPENCODE_EXPERIMENTAL_NATIVE_LLM`。`llm/AGENTS.md` 称总开关 `OPENCODE_EXPERIMENTAL` 也能打开它，但在 `packages/opencode/src/effect/runtime-flags.ts:54` 里它是独立的布尔值，并不跟随总开关。

`LLMNativeRuntime.status` 的准入条件：提供方是 `openai`、`anthropic` 或以 `opencode` 开头；模型包是 `@ai-sdk/openai`、`@ai-sdk/openai-compatible` 或 `@ai-sdk/anthropic`；OAuth 只放行带自定义 fetch 的 OpenAI；必须配置了 API key。不满足就返回具体原因，`llm.ts` 记下 `llm.native_unsupported_reason` 后回退到 AI SDK。判定逐请求进行，同一会话里可以有的调用走原生、有的回退。

通过准入后，`native-request.ts` 把 AI SDK 形状的消息转换成 `LLMRequest`，并按包名选路由门面（`OpenAI.responses`、`Anthropic.model`、`OpenAICompatible` 等）；它是适配层里唯一允许构造 `@opencode-ai/llm` 请求对象的文件。提供方参数则原样透传，注释的说法是：

> Both sides intentionally use OpenAI's official wire field names, so this is identity, not translation

工具执行依然归 OpenCode：`native-runtime.ts` 把每个 AI SDK 工具包成原生 `Tool`，其 `execute` 回调原来的实现；流里出现本地 `tool-call` 时交给 `ToolRuntime.dispatch` 执行，结果先进队列，等提供方的流结束后接到流尾。两条路径最终吐出的是同一种 `LLMEvent`，处理器无从分辨也无需分辨。`@opencode-ai/llm` 包本身见[packages/llm：模型协议层](https://daiw.org/manual/opencode/llm-package)。

下一篇：[快照与撤销](https://daiw.org/manual/opencode/snapshot-revert)——基于影子 git 仓库的文件快照，以及 revert 与 unrevert 的实现。
