ARTICLE · 1276239
OpenCode V2 也要一切皆插件?
打开 OpenCode V2,敲一句话。
此刻有 90 个插件在同时工作——模型供应商、配置加载、工具、提示词、联网搜索,全是插件。

这些不是第三方装的,是它自己带的。
所以问题就来了:OpenCode V2 到底有多少东西是插件?为什么要把自己拆成这样?官方管这套架构叫什么?
这篇一次讲清楚。
一、先看 V1:同样的事,它是怎么做的
要理解 V2 为什么这么设计,得先看 V1 的代码长什么样。
V1 最新版本是 1.19.4。它的模型供应商适配,全在一个文件里:
// packages/opencode/src/provider/provider.ts
import { createAmazonBedrock } from"@ai-sdk/amazon-bedrock"
import { createAnthropic } from"@ai-sdk/anthropic"
import { createAzure } from"@ai-sdk/azure"
import { createGoogleGenerativeAI } from"@ai-sdk/google"
import { createVertex } from"@ai-sdk/google-vertex"
import { createOpenAI } from"@ai-sdk/openai"
import { createOpenRouter } from"@openrouter/ai-sdk-provider"
import { createXai } from"@ai-sdk/xai"
import { createMistral } from"@ai-sdk/mistral"
import { createGroq } from"@ai-sdk/groq"
// ……还有十几个
然后是 BUNDLED_PROVIDERS 映射表,再然后是一个叫 custom() 的函数——近 800 行,按 anthropic、openai、azure、amazon-bedrock 一个个分支写特例。
整个文件 2097 行。
同一时期,其他核心文件也不小:
provider/provider.ts | ||
config/config.ts | ||
tool/registry.ts | ||
session/system.ts |
system.ts 最典型,就是个 if-else 链:
exportfunctionprovider(model: Provider.Model) {
if (model.api.id.includes("muse")) return [PROMPT_META]
if (model.api.id.includes("gpt-4") || ...) return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) {
if (model.api.id.includes("gpt-6")) return [PROMPT_ASTRA]
if (model.api.id.includes("codex")) return [PROMPT_CODEX]
return [PROMPT_GPT]
}
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
// ……
return [PROMPT_DEFAULT]
}
想支持一个新模型,就在这里加一行 includes。
V1 的插件系统也帮不上忙。 它只有一套钩子:
exporttypePlugin = (input: PluginInput, options?: PluginOptions) =>Promise<Hooks>
exportinterfaceHooks {
dispose?: () =>Promise<void>
event?: (input: { event: Event }) =>Promise<void>
config?: (input: Config) =>Promise<void>
tool?: { ... }
auth?: AuthHook
provider?: ProviderHook
}
插件的职责是「在事件前后插一脚」,不能提供功能。所以 V1 内置的 10 个插件全是认证类:Codex、Copilot、GitLab、Poe、Cloudflare、Azure、DigitalOcean、xAI、Snowflake。
真正的功能——33 家供应商、14 个工具、18 类配置——全在核心代码里硬编码。
加一家供应商要做什么?改 import、改映射表、改 custom() 分支、处理特例。四步全在同一个文件里。
二、V2 的 90 个内置插件
V2 把这些全拆了。
启动时可以通过本地接口拿到完整清单,90 个内置插件,分六类。
模型供应商:33 个
opencode.provider.*,每个供应商对应一个插件:
amazon.bedrock | ||
azure | ||
cerebras | ||
chatgpt | ||
cloudflare.ai.gateway | ||
cloudflare.workers.ai | ||
cohere | ||
databricks | ||
digitalocean | ||
github.copilot | ||
gitlab | ||
google.vertex | ||
kilo | ||
llmgateway | ||
lmstudio | ||
modal | ||
neon | ||
nvidia | ||
ollama | ||
opencode | ||
openai | ||
openai-context-caps | ||
openrouter | ||
perplexity | ||
poe | ||
prompt-cache-key | ||
sap.ai.core | ||
snowflake.cortex | ||
vercel | ||
vllm | ||
xai | ||
zenmux | ||
dynamic |
「能否关闭」这一列,对应的是 plugins 配置里的负号写法:
{"plugins":["-opencode.provider.openai-context-caps"]}
标「不可」的两个——opencode.provider.opencode 和 opencode.config.policy——是官方特意保护的:前者是 Console 连接,负责下发组织策略;后者负责策略执行。仓库配置不能把它们关掉,否则有人提交一个配置文件就能绕过组织策略。
配置加载器:18 个
opencode.config.*,每个配置段落对应一个插件:
agent | ||
command | ||
compaction | ||
compatibility | ||
formatter | ||
image | ||
instruction | ||
location-watcher | ||
mcp | ||
policy | ||
provider | ||
reference | ||
shell | ||
skill | ||
snapshot | ||
tool-output | ||
websearch | ||
worktree |
工具:14 个
opencode.tool.*,每个工具一个插件:
read | ||
write | ||
edit | ||
glob | ||
grep | ||
shell | ||
patch | ||
question | ||
skill | ||
subagent | ||
todo | ||
webfetch | ||
websearch | ||
input-repair |
提示词:7 个
opencode.prompt.*:
openai | ||
anthropic | ||
kimi | ||
arcee | ||
meta | ||
verbosity | ||
identity |
搜索后端:5 个
opencode.websearch.*,每个搜索服务一个插件:
exa | ||
firecrawl | ||
parallel | ||
tavily | ||
tinyfish |
运行时能力:13 个
agent | ||
plan | ||
command | ||
skill | ||
vcs.git | ||
vcs.hg | ||
models.dev | ||
mcp.codemode | ||
browser | ||
warming | ||
wellknown | ||
tools | ||
mcp-resources |
三、和 V1 的区别
同样一件事,两代的实现方式完全不同:
[...builtin, ...custom] | ||
最直观的差别是「加一家供应商」这件事。
V1 要改四步,全在别人的文件里。
V2 只要新建一个文件,写几十行:
exportconstOpenAIPlugin = define({
id: "opencode.provider.openai",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((editor) => {
editor.method.update(browser(ctx.app)) // 浏览器登录
editor.method.update(headless(ctx.app)) // 设备码登录
})
yield* ctx.provider.transform((providers) => {
providers.update("openai", (provider) => {
provider.headers = Provider.mergeHeaders(provider.headers, { originator: "opencode" })
})
})
yield* ctx.model.transform((models) => { /* 按账号启停模型 */ })
yield* ctx.session.hook("model.request", (evt) => { /* 改请求头 */ },
{ providerID: "openai" })
}),
})
不碰别人的代码,只在注册表里加一行 import。
四、90 个插件怎么不打架
这是 V2 最核心的设计。
90 个插件都要改同一份状态:改供应商列表、改工具注册表、改配置。如果每个插件都直接改,顺序一变就全乱。
V2 的做法,官方文档叫 transforms(变换):
Transforms are synchronous edits to OpenCode's domain state, and each builds on earlier registrations in its domain.
翻译过来:变换是对 OpenCode 领域状态的同步编辑,每一个都建立在同领域更早的注册之上。
关键在于它不是「改」,而是「重放」:
Any registration, removal, or
reload()marks the registry changed; the next read rebuilds it by replaying every active transform in registration order onto a fresh value.
任何注册、移除或 reload 都会标记注册表已变更;下一次读取时,会按注册顺序把所有活跃的 transform 重放到一个全新的值上。
代码层面是这样:
// 每次读取,从空状态开始,把所有 transform 按顺序重放
constget = () => {
const next = options.initial() // 空状态
const editor = options.editor(next)
for (const transform of transforms) {
transform.run(editor) // 每个插件贡献的变换
}
return next // 全新值,旧值不受影响
}
插件不持有状态,只注册一个变换函数:
yield* ctx.tool.transform((editor) => {
editor.add({ name: "todowrite", ... })
})
这个设计带来四个好处:
1. 热重载
改插件 → 卸掉它的 transform → 下次读取自然重建。不用重启进程。
2. 失败隔离
单个插件抛错,把这一组的 transform 同步剥离,其余插件不受影响:
try { transform.run(editor) }
catch (cause) {
if (!transform.group) throw cause
disable(transform.group, { state, cause })
}
插件状态变成 failed,UI 里显示错误,OpenCode 还能用。
3. 顺序可编排
transform 按注册顺序重放,所以内置插件分成 pre 和 post 两阶段:
const pre = [
ToolInputRepairPlugin,
ConfigWorktreePlugin,
ConfigMcpPlugin,
...
...ProviderPlugins,
...WebSearchPlugins,
...OptimizePlugin.Plugins,
...各种 Tool 插件,
]
const post = [
ConfigInstructionPlugin,
ConfigReferencePlugin,
...
ConfigPolicyPlugin,
]
为什么要分两阶段?因为配置加载本身也是插件。需要配置才能初始化的东西,只能排在后面。
4. 状态可回滚
每个 transform 绑在作用域上,作用域关闭即移除,不需要手写 undo。
官方文档还特别提醒插件作者:
Keep transforms cheap and repeatable. A value you have already read is never modified by later rebuilds.
变换要轻、要可重复。你已经读到的值,不会被后续重建修改。
用个比喻:V1 是大家轮流改同一张纸;V2 是每个人写一张透明胶片,读的时候按顺序叠起来看。
五、和 DSH 的区别
同样是把产品拆成插件,DeepSeek 官方的 agent 框架 DSH(DeepSeek Harness)走了另一条路。
先说结论:
OpenCode V2:插件化是为了方便自己开发扩展 DSH:插件化是为了让用户自由搭建
这两种目标的差别,直接体现在组合方式上。
OpenCode:代码里的数组
V2 的 90 个内置插件,顺序写死在源码的 pre / post 数组里。
用户可以启用或禁用(plugins 里加 -id),但不能重排、不能替换某一行实现。
插件通过 transforms 修改状态,本质是「内核的一部分」。
DSH:配置里的树
DSH 把运行中的应用描述成一棵由有序层叠出来的插件树:
profile 的 bundles
→ cordis.patch.yml
→ home patch
→ --patch 覆盖层
profile:一套命名组合( web、headless、sdk、acp)bundle:配置行 + 挂载代码的发行包 patch:按行 id 替换整行配置,或插入新行
整棵树可以直接打印:
dsh --profile web --dump-config
输出里的每一行,都能被上层 patch 换掉。
插件通过注册服务、监听事件工作,本质是「往清单上写一行」。
放在一起看
一个把「可组装」做到了代码层:插件是数组里的元素,顺序由源码决定。
一个做到了产品层:整个应用是一棵 YAML 树,启动时按层叠出来。
对普通用户来说这个差别几乎无感——装哪个都是个能用的编程工具。但对想改它的人来说,差别很大。
六、为什么这么设计?官方怎么说
先说动机。
V1 的 provider.ts 已经 2097 行,tool/registry.ts 455 行,config.ts 701 行还在往外拆包。当单文件逼近两千行、每加一家供应商都要改同一个文件时,插件化就不再是架构偏好,而是唯一出路。
至于好处,从前面的拆解能看到四条:
改动隔离:加供应商只动自己的文件 可单独关闭:任何一个内置插件都能用 -id禁用失败隔离:一个插件坏了,不影响其他 插件 API 不落后:内置功能用同一套接口,第三方能做的,官方自己也做
第四条最关键。只有自己的功能也跑在同一套接口上,插件 API 才不会永远落后于产品一个版本。
那官方管这套架构叫什么?
我查了 V2 的文档、changelog 和代码,没有找到「一切皆插件」这种提法。
官方的用词是:
1. built-in plugins(内置插件)
文档里明确写了:
Two built-in plugins ignore removals so that a repository cannot switch off policy enforcement
CLI 里也有对应的命令:
opencode plugin list --builtin
2. transforms(变换)
官方对插件工作机制的描述:
Transforms are synchronous edits to OpenCode's domain state
3. replay(重放)
the next read rebuilds it by replaying every active transform in registration order onto a fresh value
所以更准确的说法是:
OpenCode V2 把内置功能做成了 built-in plugins,插件通过 transforms 修改领域状态,状态由 replay 重建。
「一切皆插件」是我们对这种结构的概括,不是官方口号。
但它确实做到了——90 个内置插件,覆盖供应商、配置、工具、提示词、搜索、运行时,一个不落。
总结
回头看 V1 到 V2 的变化:
provider.ts从 2097 行变成 33 个插件system.ts从 if-else 链变成 7 个插件工具注册表从静态数组变成可变注册表 插件从「只能挂钩子」变成「能改状态」
这不是简单的代码搬家,而是把「核心 + 分支」换成了「宿主 + 变换」。
宿主只负责提供状态容器和生命周期,功能由 90 个插件拼出来。
这大概是 V2 这一轮重构里最值得记住的决策。
注:本文数据来自 OpenCode v1.19.4 与 v2.2.0 源码、OpenCode V2 官方插件文档,以及运行时
/api/plugin接口返回。DSH 处于 developer preview,接口可能变化。


































