OpenCode 插件开发体验:用 Benchmark 插件摸透扩展机制

OpenCode 提供了一套基于 TypeScript 的插件系统,允许开发者为 AI 编码助手注入自定义工具。本文通过一个实际的 Benchmark 插件,拆解 OpenCode 插件的开发原理、核心 API 和最佳实践。

引言:为什么要给 AI 编码助手写插件

AI 编码工具已经成为日常开发的一部分。但每个团队的工作流都有差异——有人需要对接内部服务,有人需要自定义代码检查,有人需要测量不同模型的响应速度。通用的 AI 助手不可能覆盖所有场景。

OpenCode 的解决方案是开放插件系统:你可以用 TypeScript 编写插件,注册自定义工具(Tool),这些工具直接暴露给 AI Agent 调用。当你在对话中说”帮我跑一下模型基准测试”,AI 就会调用你写的 benchmark_models 工具,而不是猜测或拒绝。

这篇文章分两部分:先讲 OpenCode 插件的运行原理,再用一个 Benchmark 插件的完整实现说明怎么从零开始写一个插件。

背景:OpenCode 的扩展体系

OpenCode 是一个终端内运行的 AI 编码助手,支持多种 LLM 提供商(OpenAI、Anthropic、Google 等)。它的扩展体系有两个核心概念:

  • Plugin(插件):TypeScript 代码,运行在 OpenCode 进程中,注册自定义工具和生命周期钩子。AI Agent 可以像调用内置工具一样调用插件注册的工具。
  • Skill(技能):纯 Markdown 文件(SKILL.md),不包含可执行代码,通过自然语言指令影响 AI Agent 的行为模式。技能告诉 AI”在什么场景下使用什么工具、如何使用”。

两者配合使用:Plugin 提供能力(Tool),Skill 提供策略(When & How)。


第一部分:OpenCode 插件原理

1.1 插件的发现与加载

OpenCode 从多个来源发现和加载插件,按优先级排列:

来源 路径 适用范围
项目插件目录 .opencode/plugins/ 当前项目
全局插件目录 ~/.config/opencode/plugins/ 所有项目
项目配置 opencode.json 中的 plugin 字段 当前项目
全局配置 ~/.config/opencode/opencode.json 所有项目

对于目录中的插件,OpenCode 启动时会自动扫描 .ts 文件,用 Bun 编译并加载——放进去就生效,不需要额外注册。

对于 npm 包形式的插件,在 opencode.json 中声明即可:

1
2
3
{
"plugin": ["opencode-helicone-session", "@my-org/custom-plugin"]
}

一个典型的项目级插件目录结构:

1
2
3
4
5
6
7
8
9
10
项目根目录/
├── .opencode/
│ ├── plugins/
│ │ └── benchmark.ts # 插件源码
│ ├── skills/
│ │ └── benchmark-models/
│ │ └── SKILL.md # 配套技能文件
│ ├── package.json # 声明依赖
│ └── node_modules/ # 安装的依赖
└── ...

1.2 核心 API:Plugin 接口

一个 OpenCode 插件本质上是一个异步函数,接收上下文,返回钩子集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import type { Plugin } from "@opencode-ai/plugin"

export const MyPlugin: Plugin = async (ctx) => {
// ctx 包含:
// - ctx.client: OpenCode SDK 客户端,可调用所有内部 API
// - ctx.project: 当前项目信息
// - ctx.directory: 项目目录路径
// - ctx.worktree: Git worktree 根路径
// - ctx.serverUrl: OpenCode 服务地址
// - ctx.$: Bun shell,可执行系统命令

return {
tool: { /* 注册自定义工具 */ },
event: async ({ event }) => { /* 监听事件 */ },
auth: { /* 自定义认证 */ },
// ...更多钩子
}
}

PluginInput 上下文对象 是插件的核心入口,提供了与 OpenCode 运行时交互的全部能力:

属性 类型 说明
client OpencodeClient SDK 客户端,可操作 session、provider、config 等
project Project 当前项目元数据
directory string 当前项目目录
worktree string Git worktree 根路径
serverUrl URL OpenCode 本地服务地址
$ BunShell Bun 原生 shell,可执行系统命令

1.3 Tool 定义:让 AI 能调用的工具

插件最常见的用途是注册自定义工具。OpenCode 用 tool() 辅助函数定义工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { tool } from "@opencode-ai/plugin"

const z = tool.schema // 就是 zod,用于参数校验

const myTool = tool({
description: "工具的自然语言描述,AI 根据这段文字决定何时调用",
args: {
name: z.string().describe("参数说明"),
count: z.number().optional().describe("可选参数"),
},
async execute(args, context) {
// args 是经过 zod 校验的参数
// context 包含 sessionID, messageID, agent, directory 等
return "返回给 AI 的文本结果"
},
})

关键设计

  • description:这是 AI Agent 决定是否调用这个工具的唯一依据。写得越清晰,AI 调用得越准确。
  • args:基于 Zod schema 定义参数,自动生成 JSON Schema 供 LLM 理解。
  • execute:异步执行函数,返回字符串。AI 会将返回值作为工具输出呈现给用户。
  • context:执行上下文,包含当前 session、message、agent 信息,以及 abort 信号用于取消长时间运行的任务。

1.4 Hooks:生命周期钩子

除了注册工具,插件还可以通过钩子介入 OpenCode 的各个生命周期阶段:

钩子 触发时机 典型用途
tool 注册自定义工具 扩展 AI 能力
event 接收系统事件 日志、监控、统计
config 配置加载时 动态修改配置
auth 认证流程 接入自定义 OAuth/API Key
chat.message 新消息到达 消息预处理、路由
chat.params LLM 调用前 调整 temperature、topP 等参数
chat.headers HTTP 请求前 注入自定义 Header
permission.ask 权限请求时 自动授权或拒绝
tool.execute.before 工具执行前 参数拦截、修改
tool.execute.after 工具执行后 结果后处理
shell.env Shell 执行前 注入环境变量

这套钩子系统覆盖了从消息接收到工具调用到结果返回的完整链路,理论上可以对 OpenCode 的任何行为进行定制。

值得一提的是 OpenCode 的工具覆盖机制:如果插件注册的工具与内置工具同名,插件版本会覆盖内置版本。这让插件可以拦截并修改内置工具的行为——比如有人用这个机制包装 shell 工具,在执行前后注入自定义逻辑。

1.5 Plugin 与 Skill 的协作模式

理解 Plugin 和 Skill 的关系是写好插件的关键:

1
2
3
4
5
6
7
8
9
Plugin (代码层)                     Skill (提示层)
┌─────────────────────┐ ┌──────────────────────────┐
│ 注册 benchmark_models │ │ SKILL.md: │
│ 工具,定义参数和执行逻 │ │ "当用户问模型速度时, │
│ 辑 │ ←配合→ │ 直接调用 benchmark_models │
│ │ │ 工具,完整输出每个模型" │
└─────────────────────┘ └──────────────────────────┘
↑ ↑
提供能力(How) 提供策略(When & What)

没有 Skill 的 Plugin 也能工作——AI 会根据 description 自行决定何时调用。但配合 Skill 可以让调用时机更精确,输出格式更可控。


第二部分:Benchmark 插件的实现与使用

2.1 需求场景

在使用 OpenCode 时,通常会配置多个 LLM 提供商和模型。不同模型的响应延迟差异很大——有些模型 1 秒内返回,有些可能需要 20 秒。在选择模型时,我们需要一个快速的基准测试来量化各模型的实际响应速度。

这就是 Benchmark 插件要解决的问题:向所有已配置的模型发送一条简单消息,测量每个模型的端到端响应延迟,按速度排序输出。

2.2 插件实现详解

完整的插件代码约 240 行,我们逐段拆解。

2.2.1 插件骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import type { Plugin } from "@opencode-ai/plugin"
import { tool } from "@opencode-ai/plugin"

const z = tool.schema

export const BenchmarkPlugin: Plugin = async (ctx) => {
return {
tool: {
benchmark_models: tool({
description: "Benchmark all configured AI models by sending 'hello' "
+ "and measuring response latency. Returns results sorted "
+ "from fastest to slowest.",
args: {
provider: z.string().optional().describe(
"Filter by provider ID (e.g. 'openai'). Omit to test all."
),
timeout: z.number().optional().describe(
"Per-model timeout in seconds. Default: 30"
),
},
async execute(args, _context) {
// 核心逻辑
},
}),
},
}
}

几个要点:

  • 插件只注册了一个工具 benchmark_models
  • 两个可选参数:provider 用于过滤特定提供商,timeout 设置超时
  • description 写得足够清晰,AI 能准确判断何时调用

2.2.2 模型发现:收集所有测试目标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Target = { providerID: string; modelID: string; name: string }
const targets: Target[] = []

// 主路径:通过 provider.list() 获取所有已连接的提供商和模型
const resp = await ctx.client.provider.list()
const allProviders = resp.data?.all ?? []
const connected = resp.data?.connected ?? []

for (const prov of allProviders) {
if (!connected.includes(prov.id)) continue // 跳过未连接的
if (providerFilter && prov.id !== providerFilter) continue // 按需过滤

for (const [modelID, modelCfg] of Object.entries(prov.models)) {
targets.push({
providerID: prov.id,
modelID,
name: modelCfg?.name ?? modelID,
})
}
}

这里体现了 ctx.client 的核心价值——插件可以直接调用 OpenCode 的内部 API 获取运行时信息。provider.list() 返回所有已配置的提供商及其模型列表,connected 数组标记了哪些提供商处于已连接状态。

代码还实现了降级策略:如果 provider.list() 失败(比如旧版本 API 不支持),会 fallback 到 config.get() 读取配置文件,再 fallback 到 config.providers() 获取默认配置。三层降级确保在不同 OpenCode 版本下都能工作。

2.2.3 并行基准测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const results = await Promise.all(
targets.map(async ({ providerID, modelID, name }) => {
let sessionId: string | null = null
try {
// 1. 创建临时 session
const sessionResp = await ctx.client.session.create({ body: {} })
sessionId = sessionResp.data.id

// 2. 发送 "hello" 并计时
const t0 = Date.now()
await Promise.race([
ctx.client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID, modelID },
parts: [{ type: "text", text: "hello" }],
},
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), TIMEOUT_MS)
),
])
const latency = Date.now() - t0

return { name, id: `${providerID}/${modelID}`, latency, ok: true }
} catch (e) {
// 错误分类:timeout / auth / parse / error
return { name, id: `${providerID}/${modelID}`, latency: -1, ok: false }
} finally {
// 3. 清理临时 session
if (sessionId) {
await ctx.client.session.delete({ path: { id: sessionId } })
}
}
}),
)

核心设计

  1. 并行执行:所有模型同时测试(Promise.all),而不是串行等待。30 个模型只需等最慢的那个,不是所有时间之和。
  2. 独立 Session:每个测试创建独立的临时 session,测试完立即删除。不会污染用户的对话历史。
  3. 超时控制Promise.race 实现单模型超时,默认 30 秒。避免某个模型无响应时阻塞整个测试。
  4. 错误分类:catch 中根据错误信息分类为 timeout(超时)、auth(认证问题)、parse(响应解析错误)和通用 error

2.2.4 结果格式化输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 成功的按延迟排序,失败的单独分组
const ok = results.filter(r => r.ok).sort((a, b) => a.latency - b.latency)
const bad = results.filter(r => !r.ok)

// 排名输出
for (let i = 0; i < ok.length; i++) {
lines.push(` ${i + 1}. ${r.id.padEnd(maxLen)} ${fmt(r.latency)}`)
}

// 错误输出(带分类图标)
const errIcons = { timeout: "⏱", auth: "🔑", parse: "⚠", error: "✗" }

// 统计摘要
lines.push(
`Fastest: ${fastest.id} (${fastest.latency}ms)` +
` | Median: ${median.latency}ms` +
` | ${ok.length}/${results.length} ok`
)

输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Model Latency Benchmark
────────────────────────────────────────────────────────────

1. provider-a/gpt-4o-mini 892ms
2. provider-b/claude-sonnet-4-20250514 1.2s
3. provider-a/gpt-4o 1.8s
...

Errors:
⏱ provider-c/some-model
timeout

────────────────────────────────────────────────────────────
Fastest: provider-a/gpt-4o-mini (892ms) | Median: 1800ms | 8/9 ok
Tested 9 model(s) in parallel | Timeout: 30s

2.3 配套 Skill 文件

仅有 Plugin 就够用了,但配合 Skill 可以优化 AI 的调用体验。SKILL.md 内容非常简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
---
name: benchmark-models
description: Benchmark all configured AI models by testing response latency.
USE WHEN user asks to benchmark models, test model speed, check which model
is fastest, or compare latency.
compatibility: opencode
---

Call the `benchmark_models` tool immediately.

Do not explain or announce what you are about to do — just invoke the tool
and present the results table.

**MUST output the COMPLETE results table with every single model listed
individually.** Do NOT collapse, merge, or summarize rows.
Every model must have its own row.

这段 Skill 做了三件事:

  1. 触发条件(frontmatter 的 description):告诉 AI 在用户问到”模型速度”、”哪个模型最快”、”比较延迟”等场景时激活这个技能。
  2. 行为指令:直接调用工具,不要多余的解释。
  3. 输出约束:必须完整输出每一行,禁止 AI 偷懒合并行。这一条非常实用——大模型有时会”贴心地”把结果压缩为”剩余 22 个模型 4.5s-27.3s”,Skill 约束可以阻止这种行为。

2.4 依赖管理

插件的依赖声明在 .opencode/package.json 中:

1
2
3
4
5
{
"dependencies": {
"@opencode-ai/plugin": "1.3.13"
}
}

@opencode-ai/plugin 包含了:

  • PluginHooks 等类型定义
  • tool() 辅助函数
  • tool.schema(即 Zod)用于参数校验
  • @opencode-ai/sdk 作为传递依赖,提供 ctx.client 的类型

安装依赖后(bun installnpm install),OpenCode 启动时会自动编译并加载插件。

2.5 使用方式

插件安装后,有两种使用方式:

方式一:自然语言触发

在 OpenCode 对话中直接说:

1
帮我测一下所有模型的响应速度

AI 会识别到 benchmark-models 技能,自动调用 benchmark_models 工具。

方式二:斜杠命令触发

1
/benchmark-models

技能自动注册为斜杠命令,效果相同。

方式三:指定 provider 过滤

1
只测试 openai 的模型速度

AI 会传入 provider: "openai" 参数,只测试该提供商下的模型。


开发体验小结

写完这个插件后,有几点体会:

上手门槛低Plugin 接口很直觉——接收上下文、返回钩子。tool() 的 Zod schema 设计让参数定义和校验一体化。不需要学习额外的框架概念。

ctx.client 是核心。插件的能力上限取决于 SDK 暴露了多少 API。Benchmark 插件用到了 provider.list()session.create()session.prompt()session.delete()config.get()——这些 API 让插件能深度操作 OpenCode 运行时。

Plugin + Skill 的分离设计合理。代码逻辑和 AI 行为策略分开管理,各自有清晰的职责边界。修改 AI 的调用时机或输出格式不需要改代码,编辑 SKILL.md 即可。

约定优于配置。插件放进 .opencode/plugins/ 自动加载,技能放进 .opencode/skills/ 自动注册为命令,不需要额外的配置文件声明。这降低了不少心智负担。

如果你也在用 OpenCode,不妨试试写一个自己的插件——可以是接入内部 API 的工具、自定义的代码检查器、或者团队特有的工作流自动化。门槛比你想象的低。