跳到主要内容

数据截至 (上游 commit 0004b748b71c)

模型层:500+ 模型怎么统一,以及一套自研的原生运行时

30 秒导读: 会话主循环只会做一件事——「拿着一堆消息去问模型」。但「模型」这个词背后是 500+ 个型号、几十家厂商、六种 wire 协议、三套认证方式。这一章讲 Kilo Code 怎么把它们全部压成同一个函数调用,以及为什么它还额外自研了一套 LLM 运行时。


1. 这是什么(零基础也能懂)

一句话定义: 模型层是位于「会话循环」和「厂商 HTTP API」之间的适配层,负责回答四个问题——有哪些模型、这个模型怎么连、这个模型支持什么、这次请求该发什么参数。

它要解决的痛: 你在 CLI 里敲 --model anthropic/claude-sonnet-4-6,明天换成 openai/gpt-5.2,后天换成 kilo/moonshotai/kimi-k2。这三次切换背后差异极大:

差异点anthropicopenaikilo 网关
SDK 包@ai-sdk/anthropic@ai-sdk/openai@kilocode/kilo-gateway
入口方法sdk.languageModel(id)sdk.responses(id)sdk.languageModel(id)
认证API key headerAPI key 或 ChatGPT OAuthKilo token + 组织 id
思考力度参数thinking.budgetTokensreasoningEffortreasoning.effort
缓存标记消息级 cacheControl服务端隐式内容级 cacheControl

上层代码不该知道这五行。 模型层的职责就是把这张表吃掉,对外只暴露一个 LanguageModelV3

用起来什么样。 从用户视角,切换模型只是换一个字符串:

$ kilo run --model openai/gpt-5.2-codex "把这个函数拆成两个"
$ kilo run --model kilo/anthropic/claude-opus-4-8 "同样的活"

这个 provider/model 字符串在代码里由一个 5 行函数拆开(packages/opencode/src/provider/provider.ts:2120 parseModel),后面所有事情都从这两个 id 展开。

一句话直觉: 把模型层当成万能电源转接头 + 一张随身携带的规格表。转接头解决「插得上」(SDK 装载与认证),规格表解决「插上之后能给多少伏」(能力探测与参数整形)。


2. 顶层全景(它大概怎么转)

这条链路从左到右单向流动,每一格只解决一个问题,前一格的产物是后一格的输入:

┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ ① 目录 │ → │ ② 解析 │ → │ ③ 装载 │ → │ ④ 整形 │ → │ ⑤ 运行时 │
│ 有哪些模型 │ │ 选中哪一个 │ │ 怎么连上去 │ │ 发什么参数 │ │ 谁去发请求 │
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
models.dev parseModel BUNDLED_ transform.ts AI SDK
+ Kilo 网关 getModel PROVIDERS request.ts ─或─
+ 本地 config getLanguage 懒加载表 packages/llm
(opt-in)


LLMEvent 流

怎么读这张图: ①②③ 一次性把「模型」变成一个可调用的 SDK 对象并缓存住;④ 每次请求都重新算一遍;⑤ 是两条并存的通道,但吐出的事件流是同一种

各部件的一句话职责:

部件干什么在哪个文件
上游目录抓 models.dev 的 api.json,磁盘缓存 + 每 60 分钟刷新packages/core/src/models-dev.ts
目录合并把 Kilo 网关模型、Apertis 模型叠加进上游目录packages/opencode/src/provider/models.ts
模型缓存网关模型的 5 分钟 TTL 缓存与失败记录packages/opencode/src/provider/model-cache.ts
Provider 注册表目录 → env → apikey → 插件 → custom loader → config,六轮叠加packages/opencode/src/provider/provider.ts
SDK 懒加载表npm 包名 → createXxx 工厂的动态 import()同上,BUNDLED_PROVIDERS
认证OAuth 授权/回调编排 + provider 插件的 fetch 改写provider/auth.tsplugin/*
请求整形按模型 id / 发布日期推导温度、思考档位、缓存标记packages/opencode/src/provider/transform.ts
请求准备options 三层 merge、plugin 钩子、归因 headerpackages/opencode/src/session/llm/request.ts
运行时选择判定原生运行时是否支持,不支持就回退packages/opencode/src/session/llm/native-runtime.ts
自研运行时schema-first 的 protocol / endpoint / auth / framing 四轴分解packages/llm/

3. 目录:500+ 模型从哪来

3.1 三个来源叠成一张表

要解决的小问题: 「有哪些模型可选」这件事,没有任何单一权威。开源目录 models.dev 知道公开模型的价格和上下文长度,但不知道你的 Kilo 账号能用哪些;你的 config.json 里可能还挂着一个自建的 vLLM。

思路: 分层叠加,后来者覆盖先来者。

models.dev/api.json ← 公共目录(磁盘缓存,60 分钟刷新)

├── overlay(...) ← Kilo 自家的 desktop overlay
├── providers.kilo ← 网关实时拉取(5 分钟 TTL)
└── providers.apertis ← 同上


catalog: Record<ProviderID, Info>

上游目录的抓取在 packages/core/src/models-dev.ts:186 fetchApi:它去 ${source}/api.json 拿全量 JSON,写进 ~/.cache/.../models.json,然后用 Effect.cachedInvalidateWithTTL 做进程内永久缓存(models-dev.ts:202)。刷新是后台 fork 的 Schedule.spaced("60 minutes")models-dev.ts:229)。

一个容易忽略的细节: 缓存文件的写入用了跨进程文件锁 Flock.effect(lockKey)models-dev.ts:195,后台刷新那条路径在 :210 也上同一把锁),因为同一台机器上可能同时跑好几个 CLI 实例,都在抢这一个 models.json

Kilo 自家的两个 provider 在 packages/opencode/src/provider/models.ts:45get 里注入:先 delete providers.kilo 抹掉上游可能带的同名条目,再用 ModelCache 拉一次真实模型列表塞回去(models.ts:84)。拉空了就 fork 一个后台 refresh(models.ts:92),不阻塞启动。

3.2 模型的规格表长什么样

每个模型最终被归一化成一个 Provider.Modelprovider/provider.ts:966)。核心字段分四组:

字段组内容谁在用
api{ id, npm, url } —— 真实模型 id、SDK 包名、base URLSDK 装载(③)
capabilitiesreasoning / temperature / toolcall / attachment / 各模态输入输出请求整形(④)
limit / cost上下文窗口、输出上限、单价与缓存单价上下文管理
variants思考档位(low / high / xhigh …)到 provider 参数的映射请求整形(④)

从上游 schema 到这个结构的转换在 fromModelsDevModelprovider.ts:1124)。注意最后一步:variants 不是抄来的,而是当场算出来的——ProviderTransform.variants(base)provider.ts:1172)。这正是第 6 节要讲的「能力探测表」。

fromModelsDevProviderprovider.ts:1176)还会把上游的 experimental.modes 展开成独立的模型条目:一个 model.id 加一个 mode 后缀就变成一个新模型(provider.ts:1181),带自己的价格和 body 覆盖。这就是为什么模型总数会比厂商官网列的多。

3.3 注册表的六轮叠加

state 的构建(provider.ts:1254)是整个文件最长的一段。它不是「读配置」,而是六轮按优先级叠加,每轮都可能新增 provider 或改写已有 provider:

catalog(models.dev + Kilo)

├─ 1. plugin.provider.models() 插件重写模型列表 provider.ts:1311
├─ 2. config.provider 用户配置扩充/新建 provider.ts:1339
├─ 3. env 环境变量里有 key 就点亮 provider.ts:1439
├─ 4. auth(api 类型) 存过 API key 就点亮 provider.ts:1459
├─ 5. plugin.auth.loader() OAuth 插件注入 fetch provider.ts:1471
├─ 6. custom loader 21 个内置特判 provider.ts:1494
└─ 7. config 再刷一遍 让用户配置压过一切 provider.ts:1516


providers(只保留「连得上」的)+ 逐模型过滤

最后一轮过滤(provider.ts:1547)做三件事:删掉 deprecated 状态的模型、在没开实验开关时删掉 alpha 模型(provider.ts:1568)、应用用户的 blacklist / whitelistprovider.ts:1570)。一个 provider 被过滤到 0 个模型就整个删掉(provider.ts:1590)。

为什么第 2 步和第 7 步都是 config? 因为第 2 步是「用 config 扩充目录」(新增模型条目),第 7 步是「用 config 覆盖 provider 元信息」(name / options / env)。中间夹着的 env、auth、插件可能改写这些字段,所以 config 要再压一次。这里还有一处 fork 特有的修补:当 OAuth 插件和 config 同时存在时,source 不被改回 "config",以免打断 OAuth 链路(provider.ts:1519-1522)。

3.4 三个查询入口

函数输入输出位置
parseModel"kilo/anthropic/claude-opus-4-8"{ providerID, modelID }provider.ts:1974
getModelproviderID + modelIDModel 规格,或带模糊建议的 ModelNotFoundErrorprovider.ts:1783
getLanguageModelLanguageModelV3(可直接给 AI SDK)provider.ts:1809

parseModel 只做一次 split("/") 然后把剩下的重新 join —— 所以 provider id 不能含斜杠,而 model id 可以(kilo/anthropic/claude-opus-4-8 会被拆成 kilo + anthropic/claude-opus-4-8)。

getModel 找不到时不是简单报错,而是用 fuzzysort 给三个最接近的候选(provider.ts:1221 modelSuggestions);如果连 provider 都不存在,还会退到 catalog 里再找一次(provider.ts:1787),这样「你装了但没连上」和「压根没这个模型」会给出不同的提示。

getLanguage 的缓存键是 ${providerID}/${id}provider.ts:1812)——同一个模型在一个进程里只构造一次

3.5 网关模型的缓存:三个不显然的设计

model-cache.ts 只有 285 行,但塞进了三条值得抄的规则:

  1. 失败不缓存。 evaluate 在 cause 出现时立刻 invalidateprovider/model-cache.ts:234),所以一次网络抖动不会把「空模型列表」钉在缓存里 5 分钟。
  2. 版本号防覆盖。 每次 fetch / refresh 先给 provider 的版本号 +1,commit 时如果版本对不上就丢弃结果(model-cache.ts:219)。这挡住了「慢请求回来把新结果冲掉」。
  3. 缓存键包含凭据。 key()baseURL / 组织 id / token 一起编进键(model-cache.ts:186)——换账号等于换缓存槽,不会读到上一个账号的模型列表。

登录成功后主动清缓存这件事发生在认证侧:ProviderAuth.callback 最后一行 cache.clear(providerID)provider/auth.ts:244)。

模型状态是一个四值枚举 ["alpha","beta","deprecated","active"]provider/model-status.ts:5),比上游的三值多一个 active 作为默认。


4. SDK 动态装载:一张 npm → createXxx 的懒加载表

4.1 为什么是懒加载

小问题: 支持 20 多个 SDK 包,但一次会话只会用到一个。全部静态 import 的代价是启动时把二十几个包全解析一遍。

做法: 一张「包名 → 返回工厂函数的 async 函数」的表,只在真的要用时才 import()

// 示意,非源码
const BUNDLED = {
"@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
"@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
}
// 用的时候才付出加载成本
const factory = await BUNDLED[model.api.npm]()
const sdk = factory({ name: model.providerID, ...options })

真实的表在 provider/provider.ts:123 BUNDLED_PROVIDERS,字面量里写死 23 个条目(:124-148),覆盖 anthropic / openai / azure / google / google-vertex / bedrock / openrouter / xai / mistral / groq / cohere / perplexity / gitlab / github-copilot 等。

末尾还有一句 ...KILO_BUNDLED_PROVIDERSprovider.ts:149)展开 fork 追加的条目——目前只有 @kilocode/kilo-gatewaypackages/opencode/src/kilocode/provider/provider.ts:30),合计 24 条。

表里没有的包怎么办? 走 npm 安装兜底:Npm.add(model.api.npm) 装到本地,拿到 entrypoint,import() 之后用命名约定找工厂——取第一个以 create 开头的导出(provider.ts:1767)。这是整条链路里最"江湖"的一行,但也是「用户自己写一个 provider 包丢进 config 就能用」的关键。file:// 开头的路径则直接当本地包加载(provider.ts:1757)。

4.2 「同一家 SDK 三种入口」的兼容分支

装载出来的 sdk 对象并不统一。有的只有 languageModel(id),有的还有 responses(id) / chat(id) / messages(id)。Kilo 用两个小函数消化这件事:

// provider.ts:170
function useLanguageModel(sdk: any) {
return sdk.responses === undefined && sdk.chat === undefined
}

这行判断的意思是:如果这个 SDK 连 responseschat 都没有,那它只有一条路可走。 GitHub Copilot 的 custom loader 就是先问这一句,再决定 gpt-5 及以上走 Responses、其余走 Chat(provider.ts:232-239,配合 shouldUseCopilotResponsesApiprovider.ts:49)。

Azure 的分支更长,因为 Azure 部署可能对应任意一种上游 API:

// provider.ts:174
function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) {
if (useChat && sdk.chat) return sdk.chat(modelID)
if (sdk.responses) return sdk.responses(modelID)
if (sdk.messages) return sdk.messages(modelID)
if (sdk.chat) return sdk.chat(modelID)
return sdk.languageModel(modelID)
}

azureazure-cognitive-services 两个 custom loader 都调它(provider.ts:279provider.ts:299)。

4.3 CustomLoader:给二十来个 provider 开的口子

custom(dep)provider.ts:182)返回一张 providerID → loader 的表,21 个条目(:184anthropic:879kilo)。每个 loader 可以返回四样东西:

返回字段作用典型用户
autoload没有 key 也把这个 provider 点亮opencode(免费模型)、google-vertex(有 ADC 就亮)
getModel覆盖「怎么从 sdk 拿模型」openai(强制 responses)、azuregithub-copilot
options注入固定的 SDK 参数 / headeranthropicanthropic-beta 的两个 beta flag)、openrouterHTTP-Referer / X-Title
vars给 base URL 模板里的 ${VAR} 提供值azureAZURE_RESOURCE_NAME
discoverModels运行时向 provider 反查模型列表gitlabprovider.ts:603

anthropic 的 loader 是最短的一个,也最能说明这套机制的意义:

// provider.ts:184
anthropic: () => Effect.succeed({
autoload: false,
options: { headers: {
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
} },
}),

一整个 provider 的特殊需求被压缩成一个 header 常量,而不是散落在请求路径上的 if。

base URL 的模板替换发生在 resolveSDKprovider.ts:1640):先用 varsLoaders 提供的值替换,再用环境变量替换剩下的 ${...}

4.4 fetch 包装:三层超时保护

所有 SDK 都接受一个 fetch 覆盖,Kilo 在这里塞了一整套超时防线(provider.ts:1685)。这段是 fork 改动最集中的地方之一。

请求发出

├── ① 连接阶段超时 buildTimeoutSignal(options) 默认 5 分钟(kilocode)
├── ② header 超时 timeoutController(headerTimeout) OpenAI 默认 10 秒
│ └─ 触发 → HeaderTimeoutError

headers 到达 ──► ①② 都 clear()

└── ③ chunk 超时 wrapSSE(res, chunkTimeout, ctl)
└─ 某个 SSE chunk 迟迟不来 → ResponseStreamError

三个 signal 用 AbortSignal.any 合并(provider.ts:1699)。HeaderTimeoutErrorResponseStreamError 都定义在 provider/error.ts:6:14

为什么 header 超时要单独一层? 因为流式请求没有整体超时的概念——一个 20 分钟的回答是正常的。真正要抓的异常是「请求发出去了,但 20 秒还没回 header」。OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000provider.ts:48)就是给 OpenAI 默认开的这一层。

同一个 fetch 包装里还藏着一条 OpenAI 特有的修补:当 store !== true 时,把请求 body 里每个 input item 的 id 字段删掉(provider.ts:1703-1718),注释说这是跟着 codex 的做法。

构造好的 SDK 按 Hash.fast({providerID, npm, options}) 缓存(provider.ts:1669)——options 变了就是新 SDK,这样换 base URL / 换 key 不会复用旧连接配置。


5. 认证:三种 OAuth,三种做法

5.1 骨架:授权在核心,实现在插件

provider/auth.ts 本身不知道任何一家 provider 的 OAuth 细节。它只提供三个方法:

方法做什么位置
methods()把所有插件声明的登录方式(含交互 prompt)列出来给 UIprovider/auth.ts:138
authorize()调插件的 authorize(),拿到跳转 URL,把 pending 状态存起来provider/auth.ts:170
callback()调插件的 callback(),把返回的 key / refresh token 落库provider/auth.ts:200

真正的协议实现全在 provider 插件里,通过 Hooks.auth 挂进来。插件的 loader(getAuth) 返回一个 SDK options 补丁,最常见的形状是 { apiKey, fetch } —— 用一个假 apiKey 骗过 SDK 的必填校验,真正的凭据在自己的 fetch 里注入。

5.2 三个真实插件的对比

插件认证形态特殊处理
plugin/openai/codex.tsChatGPT OAuth(本地回调 + 设备码两种)改写 URL 到 codex 端点、加 ChatGPT-Account-Id、按 OAuth 身份重写模型价格为 0
plugin/github-copilot/copilot.tsGitHub 设备码解析请求 body 判断这次是不是 agent 调用 / 是否含图,动态加 x-initiatorCopilot-Vision-Request
plugin/xai.tsxAI 设备码 OAuth单飞刷新(single-flight)+ 直接读 JWT exp 判过期

三个例子各说明一件事:

Codex —— 认证身份能改变模型目录。 provider.models(provider, ctx) 钩子在 OAuth 登录时只保留白名单和 5.4 以上的 gpt 模型,并把 cost 全部改成 0(plugin/openai/codex.ts:395-433)——因为走 ChatGPT 订阅不按 token 计费。认证不只是加个 header,它会改写「有哪些模型、多少钱」。

Copilot —— header 要看请求内容。 它把 init.body 解析出来,分别识别 Completions / Responses / Messages 三种 body 形态,判断最后一条消息是不是用户发的,从而决定 x-initiator: agent | userplugin/github-copilot/copilot.ts:107-161)。Copilot 按这个 header 计费额度。

xAI —— 刷新 token 的并发陷阱。 refresh_token 是一次性的:两个并发请求同时刷新,第二个必挂。所以插件用一个 refreshPromise 变量把并发折叠成一次(plugin/xai.ts:563:614)。源码注释还诚实标注了一个未解决的跨进程限制:如果 auth.set 落盘失败,磁盘上留着已被消费的旧 token,下次刷新会 4xx 并强制重新登录(plugin/xai.ts:596-600)。

一个共性技巧: 三个插件都要「删掉 SDK 自己加的 Authorization,换成自己的」。Copilot 的做法是 delete headers["authorization"]copilot.ts:172);xAI 用 Headers.set() 的大小写不敏感特性一行覆盖(plugin/xai.ts:638,注释明确解释了为什么这样写)。


6. 请求整形:一张能力探测表,不是 if-else 堆

6.1 四个纯函数

provider/transform.ts 对外的核心是四个纯函数:给一个 Model,返回该发什么参数。没有 IO,没有状态。

函数回答的问题位置
message(msgs, model, options)消息数组要怎么改(剔不支持的模态、打缓存标记、重映射 providerOptions 键)transform.ts:624
temperature / topP / topK这个模型该用什么采样参数transform.ts:588 / :507 / :517
variants(model)这个模型有哪些「思考档位」,每档对应什么 provider 参数transform.ts:786
options({model, sessionID, providerOptions})每次请求固定要带的 provider optionstransform.ts:1437

6.2 message:三件事

msgs

├─ unsupportedParts() 模型不支持 pdf/image?把附件替换成一行 ERROR 文本
│ transform.ts:403
├─ normalizeMessages() 规整空消息、工具结果形态等
│ transform.ts:67
├─ applyCaching() 给「前 2 条 system + 最后 2 条非 system」打缓存断点
│ transform.ts:352(仅 Claude 系 / alibaba)
└─ providerOptions 键重映射 "github-copilot" → "copilot" 等
transform.ts:459,映射表在 sdkKey()(transform.ts:32)

unsupportedParts 的处理方式值得单独说:模型不支持图片时,它不报错、不静默丢弃,而是把这一段替换成 "ERROR: Cannot read image (this model does not support image input). Inform the user."transform.ts:494)——让模型自己把这个坏消息讲给用户。

applyCaching 里那个 providerOptions 对象(transform.ts:388)一次性写了 6 家的缓存标记字段名(cacheControl / cachePoint / cache_control / copilot_cache_control),全部挂上去,不匹配的 SDK 自己会忽略。这是「宁可多写几个键,也不做条件分支」的选择。缓存的整体策略见上下文经济学

6.3 采样参数:一张按 id 匹配的查找表

// transform.ts:488
export function temperature(model: Provider.Model) {
const id = model.id.toLowerCase()
if (id.includes("qwen")) return 0.55
if (id.includes("claude")) return undefined // 交给厂商默认
if (id.includes("gemini")) return 1.0
...
}

这看着像 if-else 堆,实质是厂商推荐值的记录表:qwen 官方推荐 0.55、gemini 推荐 1.0、Claude 官方不建议动。topK 里那行 minimax-m2 的分支甚至区分了子版本(m2. / m25 / m21 用 40,其余用 20,transform.ts:624)。

6.4 思考档位:这是本章最能体现「探测表」的一段

小问题: 用户想让模型「想久一点」。但每家的表达方式不同,甚至同一家不同版本的可选档位都不同:

  • GPT-5 有 minimal,GPT-5.1 把它换成了 none
  • xhigh 档 OpenAI 是 2025-12-04 才上线的,早于这天发布的模型收到 reasoning_effort: "xhigh" 会 400;
  • Claude Opus 4.7+ 支持到 max,4.6 只到 high
  • Gemini 3 Flash 支持四档,Gemini 3 Pro Image 只支持 high

思路:不查厂商文档,就从模型 id + 发布日期反推。 这是「能力探测表」的核心:

apiId + release_date

├─ 含 "deep-research"? → ["medium"]
├─ gpt5ChatReasoningEfforts() → chat 变体特判 transform.ts:574
├─ GPT5_PRO_RE 命中? → ["high"]
├─ gpt5CodexReasoningEfforts() → codex 按版本号分档 transform.ts:566
├─ versionedGpt5ReasoningEfforts() → 5.1 / 5.2+ 分档 transform.ts:558
└─ 兜底 ["low","medium","high"]
├─ 是 gpt-5 家族 → 前面塞 "minimal"
├─ release_date ≥ 2025-11-13 → 前面塞 "none"
└─ release_date ≥ 2025-12-04 → 后面塞 "xhigh"

对应源码 openaiReasoningEffortstransform.ts:687),两个日期常量在 transform.ts:646:544,注释直接写明了「早于这天的模型收到 none 会 400」。

版本号从 id 里正则抠出来(transform.ts:654-657 的四个 GPT5_*_RE),注释还特意说明了为什么锚定在行首或 /避免 gpt-50 / gpt-5o 被误判成 gpt-5 家族。

另外两家的探测函数同理:

函数规则位置
anthropicAdaptiveEffortsopus 4.7+ → 五档含 max+xhigh;opus/sonnet 4.6 → 四档;其余 nulltransform.ts:729
googleThinkingLevelEfforts非 gemini-3 → 两档;flash-image → minimal/high;pro-image → 仅 hightransform.ts:747

anthropicOpus45provider/transform.ts:725,早期叫 anthropicOpus47OrLater,当前已改名并收敛到 4.5 基线)用正则抠出主次版本号做数值比较,而不是穷举字符串——所以新 opus 上线当天不用改代码就自动进入五档(数值比较而非穷举字符串)。

这些档位最终变成什么? variants(model)transform.ts:786)把 effort 名映射成 provider 真正认的参数形状,同一个 "high" 在四家是四种写法:

SDK 包high 档的实际参数
@ai-sdk/openai{ reasoningEffort: "high" }
@openrouter/ai-sdk-provider{ reasoning: { effort: "high" } }
@ai-sdk/anthropic(GLM-5.2){ effort: "high" }
@ai-sdk/googlethinkingConfig.thinkingLevel 路线

而对于 Kilo 网关和 openai-compatible,如果目录里已经带了 variants,就直接采信目录,不再推导(transform.ts:788-794,fork 改动)——网关比客户端更清楚自己后面挂的是什么。

6.5 options:每次请求都带的固定参数

transform.ts:1437 是一串独立的 if,每条都是一个具体厂商的坑,例如:

  • OpenAI 系一律 store: falsetransform.ts:1452)——不让厂商留存对话;
  • Azure 额外带 promptCacheKey = sessionIDprovider/transform.ts:1556);
  • OpenRouter / Kilo 网关带 usage.include = true,否则拿不到用量(transform.ts:1471);
  • 阿里 DashScope 的推理模型必须显式 enable_thinking: true,否则永远不吐 reasoning(transform.ts:1534,注释列了受影响的模型);
  • Gemini 系要 thinkingConfig.includeThoughts = true 才能看到思考内容(transform.ts:1503)。

最后 providerOptions(model, options)transform.ts:1666)把这些扁平 options 装进 SDK 期望的命名空间;Vercel AI Gateway 那一支还要按 model.api.id 的前缀切出上游 slug(amazonbedrocktransform.ts:1652)。


7. 请求准备层:三层 merge + 插件钩子 + 归因头

LLMRequestPrep.preparepackages/opencode/src/session/llm/request.ts:69)是模型层和会话层的接缝。它把「这次对话」和「这个模型」合成一份可直接执行的请求。

四步,顺序不能换:

① 拼 system agent prompt / provider prompt / 用户 system
↓ 钩子: experimental.chat.system.transform request.ts:81
② options 三次 merge base ← model.options ← agent.options ← variant
request.ts:103
③ 钩子: chat.params 插件可改 temperature / maxOutputTokens / options
request.ts:123
④ 钩子: chat.headers 插件可加 header,然后叠归因头 request.ts:150

第 ② 步的 base 有两个来源:小模型(标题生成之类)走 smallOptions,主模型走 optionsrequest.ts:101-107)。合并方向是后者覆盖前者,所以用户在 agent 里写的 options 能压过 transform 的推导,而用户选的 variant(思考档位)压过一切。

第 ④ 步的 header 分两种形态(request.ts:233):

场景header
非 Kilo providerx-session-affinity、可选 x-parent-session-idUser-Agent
Kilo providerx-kilo-project / x-kilo-session / x-kilo-request / x-kilo-client,外加网关归因头

网关归因头是 fork 独有的一组(request.ts:249-256):project id、machine id、task id、parent task id、feature 标记,常量来自 @kilocode/kilo-gatewaypackages/kilo-gateway/src/headers.ts)。它们让网关侧能把用量归到「哪个项目、哪台机器、哪个子会话」。

一个只有 Copilot 才需要的补丁: 如果历史消息里有工具调用、但这轮一个工具都没启用,会塞一个名叫 _noop 的假工具(request.ts:211),描述里写明「永远不要调用它」。因为 Copilot 在回放带工具调用的历史时要求 tools 字段非空。

工具集合最后按名字排序输出(request.ts:230)——顺序稳定,缓存前缀才稳定。工具权限的过滤在 resolveToolsrequest.ts:263),细节见工具层与权限闸门


8. 两条运行时并存

8.1 为什么要自研

AI SDK 已经能跑通所有 provider,Kilo 却在 packages/llm/ 里另起了一套。README 给的定位是 schema-first:Effect Schema 类是唯一的运行时数据模型,provider 差异关在 adapter 里,不外泄到调用方(packages/llm/README.md:3)。

它的价值不在「更快」,在分解方式。一条路由由四个正交轴组成:

Route = Protocol × Endpoint × Auth × Framing
│ │ │ │
│ │ │ └─ 字节 → 帧(SSE / AWS event-stream)
│ │ └───────── 每请求认证(Bearer / SigV4 签名)
│ └────────────────── URL 构造(baseURL + path + query)
└───────────────────────────── 语义契约:LLMRequest → 原生 body
原生 event → LLMEvent

Protocol 的定义和四个类型参数的含义在 packages/llm/src/route/protocol.ts:36,文档注释解释得很清楚:protocol 不知道 URL、header、认证,那些是部署问题。

这个分解买到了什么? DeepSeek、TogetherAI、Cerebras、Fireworks、Groq、DeepInfra、Baseten 全部复用同一份 OpenAIChat.protocol。协议侧只写一次 6 行的 Route.makeprotocols/openai-compatible-chat.ts:17)。

每家再补一行「provider 名 + baseURL」的 profile(providers/openai-compatible-profile.ts:7-15,共 9 条),然后 define(profiles.x) 一句就是一个 provider(providers/openai-compatible.ts:59-65)。不是各自 300 行的复制粘贴——一个协议 bug 修一次,所有消费者同时受益。

目录布局:

目录内容
src/schema/规范数据模型(LLMRequest / LLMEvent / Message / 错误)
src/protocols/6 种 wire protocol:anthropic-messages、bedrock-converse、gemini、openai-chat、openai-compatible-chat、openai-responses(protocols/index.ts 逐条导出)。目录下共 9 个 .ts,另外三个不是协议——bedrock-event-stream.ts 是 framing、shared.ts 是公共工具、index.ts 是桶文件
src/providers/10 个 provider 命名空间(目录 13 个 .ts,其中 index.ts / openai-options.ts / openai-compatible-profile.ts 不是 facade):先 configure() 配端点认证,再选模型
src/route/Route.make + LLMClient + RequestExecutor + transport(http / websocket)
src/tool-runtime.ts工具循环编排(stepCountIs 之类的停止条件)

packages/llm 还把提示缓存默认打开:每个 LLMRequest 默认 cache: "auto",自动在「最后一个工具定义 / 最后一段 system / 最新一条用户消息」放三个断点(packages/llm/README.md:38-44)。README 里给了这个默认值的算术依据:Anthropic 5 分钟缓存写入 1.25×、读取 0.1×,一次复用就已经回本

8.2 闸门:什么时候走原生

选择发生在 packages/opencode/src/session/llm.ts:303,只在 KILO_EXPERIMENTAL_NATIVE_LLM=true 时才尝试(flag 定义在 packages/opencode/src/effect/runtime-flags.ts:64)。

判定函数是 LLMNativeRuntime.statussession/llm/native-runtime.ts:46),四道关,任何一道不过就返回一个带文字理由unsupported

关卡条件不过时的理由
1providerID ∈ {openai, anthropic, opencode*}provider is not openai, opencode, or anthropic
2npm ∈ {@ai-sdk/openai, @ai-sdk/openai-compatible, @ai-sdk/anthropic}provider package is not ...
3OAuth 认证必须配 provider fetch 覆盖OAuth auth requires a provider fetch override
4有 API keyAPI key is not configured

回退不是静默的:llm.ts:319-327 会打一条带 llm.native_unsupported_reason 的日志。闸门是逐请求的——同一个会话里主模型走原生、标题小模型回退 AI SDK,完全可能。

8.3 汇流:两条路吐同一种事件

┌──────────────────────┐
│ LLM.Service (llm.ts)│
└──────────┬───────────┘

┌── 不支持 ────┴──── 支持 ───┐
▼ ▼
┌───────────────────┐ ┌──────────────────┐
│ streamText(...) │ │ native-request │
│ AI SDK 执行 │ │ 降为 LLMRequest │
└────────┬──────────┘ └────────┬─────────┘
│ │
▼ ▼
┌───────────────────┐ ┌──────────────────┐
│ ai-sdk.ts │ │ LLMClient │
│ fullStream→LLMEvent│ │ 协议层直接产出 │
└────────┬──────────┘ └────────┬─────────┘
└──────────┬─────────────────┘

┌─────────────────┐
│ LLMEvent 流 │ ← 会话处理器只认这个
└─────────────────┘

(结构依据 packages/opencode/src/session/llm/AGENTS.md 的 File Structure / Runtime selection 两节。)

这张图只画模型层内部的分叉与汇流。同一条路的会话侧视角——LLMRequestPrep.prepare 之后先做溢出预检、预检不过就抛 PreflightError 让主循环转去压缩——在 02-agent-loop §5.2 画过,这里不重复。

汇流点在 llm.ts:433:原生返回的已经是 Stream<LLMEvent>,直接返回;AI SDK 的 fullStream 要过一遍 LLMAISDK.toLLMEventssession/llm/ai-sdk.ts:80)转换。转换器带一个可变的 adapterStateai-sdk.ts:11)用来补 AI SDK 缺失的 id:没给 text id 就自己生成 text-0text-1ai-sdk.ts:70)。

工具执行在两条路里都归 opencode 所有。 原生路径只是把 @opencode-ai/llm 的工具调用适配回 AI SDK 的 Tool.execute 形状(native-runtime.ts:169 nativeTools,注释明确写了这一点)。

native-runtime.ts:79-88 那段长注释解释了一处刻意的「不翻译」:ProviderTransform.providerOptions 产出的键名和原生包 OpenAIOptions.* 读的键名是同一套(store / reasoningEffort / reasoningSummary / include / textVerbosity / promptCacheKey),因为两边都用 OpenAI 的官方 wire 字段名。这是恒等映射,不是转换——注释还预留了「哪天真要分叉,翻译写在这里,不要拆到两个包」。


9. 巧妙之处(可以直接抄的)

① 能力探测用「发布日期 + 版本正则」,而不是硬编码模型白名单。 新版本模型上线当天就自动获得正确的档位(transform.ts:687:609)。代价是要维护两个日期常量,但这两个常量有清晰的物理意义(厂商上线该档位的日子),比白名单好维护得多。

② 失败不进缓存。 provider/model-cache.ts:234 一带(evaluate 出错即清缓存),把「一次网络抖动导致 5 分钟内模型列表为空」这个体验问题消灭掉。

③ 版本号丢弃过期结果。 model-cache.ts:219if ((versions.get(providerID) ?? 0) !== version) return,用一个整数解决「慢请求回来覆盖新结果」。

④ 不支持的模态转成给模型看的错误文本。 transform.ts:494 —— 让模型把坏消息讲给用户,比抛异常中断会话友好得多。

⑤ header 超时是独立的一层。 流式请求不能设整体超时,但「发出去 10 秒没回 header」一定是坏了(provider.ts:103:1691)。

⑥ 回退带理由,而不是静默降级。 原生运行时的四道关每一道都返回一句人话(native-runtime.ts:46-71),出现在日志的 llm.native_unsupported_reason 字段里。调试「为什么没走原生」不用读代码。

⑦ 缓存标记全都打上去。 applyCaching 一次写 6 家的字段名(transform.ts:388),不做 provider 分支——多余的键会被 SDK 忽略,代码却少了一堆条件。

⑧ fork 改动全部带 kilocode_change 标记。 见下一节。


10. 边界与局限

10.1 这是一个 fork,而且没打算掩饰

Kilo CLI 是 OpenCode 的 fork(README.md:171)。整个 packages/ 目录里有 3789 行kilocode_change 标记,散在 500 个文件里;单是 provider/provider.ts 就有 66 行、transform.ts 39 行。

统计口径:grep -rc kilocode_change packages/ 逐文件求和,即含该标记的行数(同一行写两次只算一次)。换成按出现次数数(grep -ro)是 3807 次。上面三个数字用的是同一把尺,可直接互相比较。

标记的用法有三种:

形态含义
// kilocode_change - new file整个文件是 fork 新增的(如 provider/models.tsmodel-cache.ts
// kilocode_change startend一段改动的起止
行尾 // kilocode_change单行改动

fork 还刻意做了改动隔离:Kilo 特有的 provider 逻辑被抽到 packages/opencode/src/kilocode/provider/provider.ts,上游 provider.ts 只在若干注入点调它,文件头注释写明这是「为了最小化与上游 opencode 的合并冲突」。这套纪律本身就是可抄的工程实践——它让「跟上游」这件事从「每次手工 diff」变成「grep 一个标记」。

代价也很清楚:provider.ts 已经 1982 行、transform.ts 1512 行,两个文件都是「上游逻辑 + fork 补丁」的交错体,读起来需要一直分辨这一行是谁写的。

10.2 会崩在哪

场景现象依据
provider 不给终止事件协议层靠 terminal 谓词判定结束(TERMINAL_TYPES 只认 response.completed/incomplete/failed)。上游若既不发这三种、连接又不断,流就悬着,只能靠外层的 chunk 超时(wrapSSE)救packages/llm/src/protocols/openai-responses.ts:613:909provider.ts:55
上下文压缩耗尽溢出错误的识别靠一张 19 条正则的模式表,逐家 provider 抄错误文案。表外的措辞识别不出来,就不会触发压缩而是直接报错。源码注释诚实标注了两类未覆盖情形:z.ai 可能静默接受溢出,Cerebras / Mistral 常返回无 body 的 400/413provider/error.ts:24-62
自研 npm provider 找不到工厂兜底路径靠「取第一个以 create 开头的导出」这条命名约定,不符合约定的包会在 fn is not a function 处炸,包成 InitErrorprovider.ts:1767:1775
xAI token 跨进程失效refresh_token 轮换后落盘失败 → 磁盘上是已消费的旧 token → 下次刷新 4xx,强制重新登录。源码注释称之为 "a known cross-process limitation"plugin/xai.ts:596-600
目录抓取全挂models.dev 抓不到且无磁盘缓存、无内嵌快照时返回空目录,所有 provider 归零packages/core/src/models-dev.ts:222-242

刻意不做的事:

  • packages/llm 不管模型能力目录。它不知道某模型支不支持工具调用——不支持的请求形状在协议降级阶段才失败(packages/llm/AGENTS.md Routes 节)。能力元信息属于 provider/provider.ts 那一侧。
  • 原生运行时不是全量替代。它当前只覆盖 OpenAI / opencode-managed OpenAI-compatible / Anthropic 三条 API-key 路径,OAuth 和其余厂商一律回退(session/llm/AGENTS.md Safety boundary 节)。

工具执行本身的边界(外部目录权限、命令白名单)不在本章范围,见工具层与权限闸门;上下文超限后的压缩策略见上下文经济学


11. 代码地图(导航索引)

主题文件路径符号名
上游目录抓取与 60 分钟刷新packages/core/src/models-dev.tsfetchApipopulaterefresh
模型 / Provider 的上游 schemapackages/core/src/models-dev.tsModelProviderCatalogModelStatus
Kilo 网关 / Apertis 注入目录packages/opencode/src/provider/models.tslayeraddApertisbaseURL
网关模型缓存(TTL / 版本 / 失败不缓存)packages/opencode/src/provider/model-cache.tscellevaluatecommitkey
模型状态枚举packages/opencode/src/provider/model-status.tsModelStatus
SDK 懒加载表packages/opencode/src/provider/provider.tsBUNDLED_PROVIDERS
「同一家 SDK 三种入口」兼容packages/opencode/src/provider/provider.tsuseLanguageModelselectAzureLanguageModelshouldUseCopilotResponsesApi
21 个 provider 特判packages/opencode/src/provider/provider.tscustomCustomLoaderCustomModelLoader
超时三层保护packages/opencode/src/provider/provider.tstimeoutControllerwrapSSEresolveSDK
注册表六轮叠加packages/opencode/src/provider/provider.tslayerInstanceState.make<State>)、mergeProvider
目录条目 → 内部模型packages/opencode/src/provider/provider.tsfromModelsDevModelfromModelsDevProvider
三个查询入口packages/opencode/src/provider/provider.tsparseModelgetModelgetLanguage
默认模型与小模型挑选packages/opencode/src/provider/provider.tsdefaultModelgetSmallModelsort
超时 / 流错误 / 溢出识别packages/opencode/src/provider/error.tsHeaderTimeoutErrorResponseStreamErrorOVERFLOW_PATTERNS
OAuth 授权编排packages/opencode/src/provider/auth.tsmethodsauthorizecallback
ChatGPT OAuth + 模型改写packages/opencode/src/plugin/openai/codex.tsprovider.modelsauth.loader
Copilot 动态 headerpackages/opencode/src/plugin/github-copilot/copilot.tsCopilotAuthPluginauth.loader
xAI 单飞刷新packages/opencode/src/plugin/xai.tsXaiAuthPluginrefreshAccessToken
消息整形与缓存标记packages/opencode/src/provider/transform.tsmessageunsupportedPartsapplyCachingsdkKey
采样参数查找表packages/opencode/src/provider/transform.tstemperaturetopPtopK
思考档位探测表packages/opencode/src/provider/transform.tsopenaiReasoningEffortsanthropicAdaptiveEffortsgoogleThinkingLevelEffortsvariants
每请求固定 optionspackages/opencode/src/provider/transform.tsoptionssmallOptionsproviderOptionsmaxOutputTokens
请求准备(merge / 钩子 / 归因头)packages/opencode/src/session/llm/request.tsprepareresolveToolshasToolCalls
运行时选择与 AI SDK 调用packages/opencode/src/session/llm.tsrunstream
AI SDK 事件适配packages/opencode/src/session/llm/ai-sdk.tsadapterStatetoLLMEvents
原生运行时闸门与工具桥packages/opencode/src/session/llm/native-runtime.tsstatusstreamnativeTools
原生请求降级packages/opencode/src/session/llm/native-request.tsRequestInputrequest
自研包的四轴分解packages/llm/src/route/protocol.tsProtocolProtocolBodyProtocolStream
路由组装与客户端packages/llm/src/route/client.tsRoute.makeLLMClientprepareWith
Provider facade 范例packages/llm/src/providers/openai.tsconfigureroutes
原生工具循环packages/llm/src/tool-runtime.tsRunOptionsstepCountIs
实验开关packages/opencode/src/effect/runtime-flags.tsexperimentalNativeLlmoutputTokenMax

相邻章节: 运行骨架 讲这些服务怎么被 Effect Layer 组装起来;会话主循环 讲本章产出的 LLMEvent 流被谁消费;上下文经济学limit.context 和缓存断点在上层怎么用。回到总览