提供方目录与 models.dev
models.dev 目录从哪来、怎么刷新,Provider 状态如何由目录、环境变量、凭据、插件与配置层层叠加,以及 getLanguage 如何拿到 AI SDK 模型。
OpenCode 没有在仓库里手写模型清单。“有哪些模型、各有什么能力和价格”交给社区维护的 models.dev 目录,OpenCode 只回答另一个问题:此刻在这个项目里,哪些提供方真正可用。前者是目录,后者是 Provider 状态。现役实现在 packages/opencode/src/provider/provider.ts,数据源在 packages/core/src/models-dev.ts。
先看怎么用
| 场景 | 做法 |
|---|---|
| 列出模型 | opencode models [provider],--verbose 看元数据,--refresh 强制刷新目录 |
| 默认模型 | 配置 model 与 small_model,格式为 provider/model |
| 会话中切换 | TUI 里用 /models 选模型,ctrl+t 轮换推理档位(variant) |
| 限定提供方 | disabled_providers 为黑名单,enabled_providers 为白名单 |
| 离线或内网 | OPENCODE_DISABLE_MODELS_FETCH、OPENCODE_MODELS_PATH、OPENCODE_MODELS_URL |
models.dev:磁盘、快照、网络三级
ModelsDev.Service 的 get() 求值一次后永久缓存,首次求值按顺序找数据(packages/core/src/models-dev.ts:217,删减后):
const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return fromDisk
const snapshot = yield* loadSnapshot
if (snapshot) return snapshot
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
// ...
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)磁盘缓存是 ~/.cache/opencode/models.json(或 OPENCODE_MODELS_PATH 指定的文件),读不出来就删掉;快照由 packages/opencode/script/build.ts 通过 Bun 的 define 把 OPENCODE_MODELS_DEV 编进二进制,发布版首次启动不必等网络;两级都落空才联网拉取,10 秒超时、失败重试两次,写入时持有跨进程文件锁。
后台纤程在启动时先调一次 refresh(),之后每 60 分钟一次:缓存 5 分钟内刷新过就跳过,否则下载、让内存缓存失效并发布 Refreshed 事件;opencode models --refresh 与 opencode auth login 会强制刷新。运行时默认源是 https://models.opencode.ai,packages/opencode/script/generate.ts 生成快照时则默认拉 https://models.dev。
Provider 状态:一层层叠上去
状态按项目实例(InstanceState)构建,入口在 provider.ts:1400。models.dev 先被转成完整目录 database;另一份 providers 起初为空,只有“有理由启用”的提供方才会经 mergeProvider() 从 database 复制进来:
- 环境变量:models.dev 为每家列出
env(如ANTHROPIC_API_KEY),任一有值即启用。 - 配置即启用:出现在配置
provider里的提供方会以source: config进入providers,哪怕没有密钥。 - custom loader:
custom()(provider.ts:174起)按提供方 ID 返回autoload、getModel、vars、options等钩子。autoload决定“没有别的理由时要不要主动启用”:amazon-bedrock发现 AWS profile、访问密钥、Bearer token 等凭据就启用;opencode(OpenCode Zen)没有密钥时删掉收费模型、把apiKey设为"public",让免费模型开箱即用。getModel决定“调 SDK 的哪个工厂方法”:openai、xai固定走sdk.responses();github-copilot默认让 GPT-5 及以上(gpt-5-mini除外)走 Responses。
最后过滤:enabled_providers、disabled_providers 不放行的整家删除;deprecated 模型总被删除,alpha 模型要设 OPENCODE_ENABLE_EXPERIMENTAL_MODELS=true 才保留;提供方级的 whitelist、blacklist 按模型 ID 过滤;删空了的提供方一并移除。
默认模型与 small model
defaultModel()(provider.ts:2008)依次尝试:配置的 model;~/.local/state/opencode/model.json 的 recent 列表(TUI 切模型时写入)里第一个仍可用的;最后取第一个可用提供方,用 sort() 取第一。sort() 对关键词数组 ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] 取 findIndex 后降序排列,越靠后的关键词优先级越高,不命中的排最后。
getSmallModel() 服务于生成会话标题这类轻量调用(session/prompt.ts:220):先看配置 small_model,再问插件钩子 experimental.provider.small_model,最后按家族优先级挑最新的一个,默认顺序是 gemini-flash、gpt-nano、claude-haiku;都找不到就退回主模型。
getLanguage:从 Model 到 LanguageModelV3
getLanguage(model)(provider.ts:1896)以 providerID/modelID 为键缓存,未命中时由 resolveSDK() 拿 SDK 实例,再调 custom loader 的 getModel,没有就用 sdk.languageModel(api.id)。resolveSDK() 先合并提供方 options、模型 headers、apiKey 与 baseURL(URL 里的 ${VAR} 占位用 vars loader 的值和环境变量替换),再包一层 fetch,给响应头与 SSE 分块间隔各加 300 秒超时(headerTimeout、chunkTimeout,设 false 关闭)。最后加载工厂:先查 BUNDLED_PROVIDERS 里 24 个随二进制打包的工厂(其中 @ai-sdk/github-copilot 这个键指向 OpenCode 自带的 packages/core/src/github-copilot/),查不到才现场安装:
const installedPath = await (async () => {
if (model.api.npm.startsWith("file://")) {
return model.api.npm
}
const item = await Npm.add(model.api.npm)
if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
return item.entrypoint
})()
// ...
const mod = await import(importSpec)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!](provider.ts:1842,删减后)
Npm.add()(packages/core/src/npm.ts:115)用 @npmcli/arborist 把包装进 ~/.cache/opencode/packages/ 下的独立目录,设 ignoreScripts: true,装过就复用;加载后取第一个以 create 开头的导出当工厂。因此任何遵循 AI SDK 约定的第三方包,配置里写上 npm 就能接入,本地开发还能用 file:// 路径。拿到的模型如何进入 streamText,见模型调用层。
配置覆盖与 variants
{
"model": "anthropic/claude-sonnet-4-5",
"provider": {
"anthropic": {
"models": { "claude-sonnet-4-5": { "variants": { "max": { "disabled": true } } } }
},
"local": {
"npm": "@ai-sdk/openai-compatible",
"options": { "baseURL": "http://localhost:8000/v1" },
"models": { "qwen3-coder": { "limit": { "context": 131072, "output": 16384 } } }
}
}
}覆盖已有提供方时,options 与目录深合并,模型字段逐项覆盖,没写的回落到目录值;新提供方写 npm、options.baseURL 与 models 即可,npm 缺省为 @ai-sdk/openai-compatible。variants 是“档位名 → 选项片段”的表:优先由 models.dev 的 reasoning_options 生成,否则用 ProviderTransform.variants() 的手写规则,再与配置的 variants 深合并,disabled: true 删掉一档。
V2 对照:可重放的 Catalog
迁移中的 packages/core/src/catalog.ts 把“启动时一次性叠加”换成“可重放的变换”:插件用 ctx.catalog.transform() 注册回调,按注册顺序作用于草稿,全部跑完后在 finalize 里执行 provider.use 策略(将取代 enabled_providers、disabled_providers)。models.dev 刷新、凭据变化或插件卸载都只需重放一遍。注册顺序中与目录相关的部分(packages/core/src/plugin/internal.ts:108)是 ModelsDevPlugin → 34 个提供方插件 → ConfigExternalPlugin → ConfigProviderPlugin → VariantPlugin,配置因而能覆盖目录与内置插件写入的值。注意 specs/v2/provider-model.md 里的 endpoint、options、enabled 等字段已被代码中的 api、request、disabled 取代,以代码为准;V2 会话如何选模型见 V2 会话内核。
下一篇:提供方适配层——拿到模型之后,transform.ts 如何抹平各家在消息、推理参数与工具 schema 上的差异。