SDK / API / 对外扩展点
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
12-sdk-api-extension - 章节摘要:沿一次 session prompt API 调用,理解 typed HTTP contract、generated SDK、SSE 事件与 plugin hook 如何组成受控扩展边界。
- 教程版本:
v1.18.16 - 源码基线:
a3647eb025c7615159d417dcc49fc39fdaeba65b - 章节元数据:/versions/v1-18-16/data/chapters.json
- 源码映射:/versions/v1-18-16/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/server/server.tspackages/opencode/src/server/routes/instance/httpapi/api.tspackages/opencode/src/server/routes/instance/httpapi/groups/session.tspackages/opencode/src/server/routes/instance/httpapi/handlers/session.tspackages/sdk/js/src/client.tspackages/sdk/js/src/server.tspackages/opencode/src/plugin/index.tspackages/plugin/src/index.ts
源码基线:
v1.18.16(提交a3647eb025c7)。本章示例是基于源码组合出的典型请求,不是一次实际抓包记录。
0. 本章学习目标
Section titled “0. 本章学习目标”读完本章,你应该能:
- 画出 HTTP API、handler、runtime service、generated SDK 和 plugin hook 的关系。
- 沿一次
session.prompt请求,从 typed endpoint 追到SessionPrompt.Service。 - 解释 SDK wrapper 为什么不只是把 URL 包成函数。
- 区分“从外部调用已有能力”和“在内部生命周期中扩展行为”。
- 识别异步 prompt、插件顺序执行和进程启动 helper 的失败边界。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 用 typed HTTP API 定义稳定能力边界,用 generated SDK 把边界变成客户端方法,再用 plugin hooks 在明确时点修改输入或输出;API 是门,SDK 是门卡,hook 是预留插槽。
中心问题是:外部程序要让 agent 做事,为什么既需要 API/SDK,又需要 plugin,而不能只开放几个内部函数?
v1.18.16 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”- HTTP API 继续拆成 root 与 instance 两组契约,并由
OpenCodeHttpApi合并;handler 依赖 typed group,而不是手写字符串路由。 - JS SDK 的
createOpencodeClient仍是 generated client 上的薄配置层,server/TUI helper 继续负责进程生命周期。 - plugin service 新增更多 runtime flag 与内部 plugin 分支,但
trigger(name, input, output)仍是统一扩展合同。
Root、Instance 与 OpenCode API 组合
packages/opencode/src/server/routes/instance/httpapi/api.ts:48-85
对外契约先组合,再由 handler 绑定 runtime service。
48export const ServerApi = makeApi({对外暴露模块成员。49 definitions: EventManifest.Latest.values().toArray(),50 locationMiddleware: LocationMiddleware,51 sessionLocationMiddleware: SessionLocationMiddleware,52})5354export const RootHttpApi = HttpApi.make("opencode-root")对外暴露模块成员。55 .addHttpApi(ControlApi)56 .addHttpApi(ControlPlaneApi)57 .addHttpApi(GlobalApi)读写本地文件。58 .middleware(SchemaErrorMiddleware)执行前先处理中间件。59 .middleware(Authorization)执行前先处理中间件。6061export const InstanceHttpApi = HttpApi.make("opencode-instance")对外暴露模块成员。62 .addHttpApi(ConfigApi)63 .addHttpApi(ExperimentalApi)64 .addHttpApi(FileApi)65 .addHttpApi(InstanceApi)66 .addHttpApi(McpApi)67 .addHttpApi(ProjectApi)68 .addHttpApi(ProjectCopyApi)69 .addHttpApi(PtyApi)70 .addHttpApi(QuestionApi)71 .addHttpApi(PermissionApi)72 .addHttpApi(ProviderApi)选择模型或 provider。73 .addHttpApi(SessionApi)74 .addHttpApi(SyncApi)75 .addHttpApi(TuiApi)76 .addHttpApi(WorkspaceApi)77 .middleware(SchemaErrorMiddleware)执行前先处理中间件。7879export const OpenCodeHttpApi = HttpApi.make("opencode")对外暴露模块成员。80 .addHttpApi(RootHttpApi)81 .addHttpApi(EventApi)82 .addHttpApi(InstanceHttpApi)83 .addHttpApi(ServerApi)84 .addHttpApi(PtyConnectApi)85 .annotate(HttpApi.AdditionalSchemas, [
SDK client wrapper
packages/sdk/js/src/client.ts:1-50
directory 等公共参数在 wrapper 层注入 generated client。
1export * from "./gen/types.gen.js"对外暴露模块成员。23import { createClient } from "./gen/client/client.gen.js"引入需要的模块。4import { type Config } from "./gen/client/types.gen.js"引入需要的模块。5import { OpencodeClient } from "./gen/sdk.gen.js"引入需要的模块。6import { wrapClientError } from "./error-interceptor.js"引入需要的模块。7export { type Config as OpencodeClientConfig, OpencodeClient }对外暴露模块成员。89function pick(value: string | null, fallback?: string) {定义一段可复用逻辑。10 if (!value) return按条件进入分支。11 if (!fallback) return value按条件进入分支。12 if (value === fallback) return fallback按条件进入分支。13 if (value === encodeURIComponent(fallback)) return fallback按条件进入分支。14 return value返回给上一层。15}1617function rewrite(request: Request, directory?: string) {定义一段可复用逻辑。18 if (request.method !== "GET" && request.method !== "HEAD") return request按条件进入分支。1920 const value = pick(request.headers.get("x-opencode-directory"), directory)21 if (!value) return request按条件进入分支。2223 const url = new URL(request.url)24 if (!url.searchParams.has("directory")) {按条件进入分支。25 url.searchParams.set("directory", value)26 }2728 const next = new Request(url, request)29 next.headers.delete("x-opencode-directory")30 return next返回给上一层。31}3233export function createOpencodeClient(config?: Config & { directory?: string }) {创建 SDK 客户端。34 if (!config?.fetch) {按条件进入分支。35 const customFetch: any = (req: any) => {36 // @ts-ignore37 req.timeout = false38 return fetch(req)返回给上一层。39 }40 config = {41 ...config,42 fetch: customFetch,43 }44 }4546 if (config?.directory) {按条件进入分支。47 config.headers = {读取运行配置。48 ...config.headers,读取运行配置。49 "x-opencode-directory": encodeURIComponent(config.directory),读取运行配置。50 }
2. 先画地图:调用与扩展是两条路
Section titled “2. 先画地图:调用与扩展是两条路”外部客户端 / UI -> generated SDK + wrapper -> typed HTTP endpoint -> handler -> Session / Provider / Tool / Permission service
插件模块 -> Plugin loader -> Hooks[] -> Plugin.trigger(name, input, output) -> 在受控时点观察事件或修改 output第一条路是调用能力:发 prompt、查 session、回复权限。
第二条路是参与能力:在 chat.params、tool.execute.before、shell.env 等时点调整数据。
把两者混在一起会产生危险设计:客户端为了改一个 header 侵入 runtime,或者插件绕过 API 自己创建第二套 session。
3. 最小机制
Section titled “3. 最小机制”API/SDK 的通用内核可以缩成:
contract = endpoint(method, path, params, payload, result, errors)handler = bind(contract, applicationService)client = generate(contract)
client.call(input) -> validate/encode request -> handler(input) -> applicationService(input) -> encode result插件内核则是:
hooks = loadPlugins(context)
trigger(name, input, output): for hook in hooks: if hook[name]: await hook[name](input, output) return outputOpenCode 的 trigger 正是顺序遍历 hooks 并返回被修改的 output,见 packages/opencode/src/plugin/index.ts。
4. API、SDK、Plugin 到底差在哪
Section titled “4. API、SDK、Plugin 到底差在哪”| 机制 | 主要用户 | 方向 | 稳定边界 | 典型风险 |
|---|---|---|---|---|
| HTTP API | 任意语言/进程 | 外部调用 runtime | method/path/schema | 网络、认证、版本 |
| JS SDK | TS/JS 客户端 | 类型化调用 API | generated types + wrapper | 生成物与服务不一致 |
| Server helper | 自动化程序 | 管理 opencode 子进程 | stdout/port/lifecycle | 启动超时、进程泄漏 |
| Plugin hook | 受信任扩展代码 | 参与 runtime 内部时点 | Hooks 接口 | 顺序副作用、异常隔离 |
Java 类比:API 像 Controller 契约,SDK 像 OpenAPI client,plugin 像 SPI + interceptor。类比的边界是:OpenCode 的 Effect service 装配、in-process fetch 和 hooks 的可变 output 并不等同于 Spring bean 调用。
5. 最小源码路径
Section titled “5. 最小源码路径”packages/opencode/src/server/routes/instance/httpapi/api.ts:API group 总装图。packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:payload 与 path。packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:prompt/command/shell 契约。packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:同步与异步 prompt handler。packages/opencode/src/server/server.ts、:75-103:in-process app 与真实 listener。packages/sdk/js/src/client.ts:SDK wrapper。packages/sdk/js/src/server.ts:server/TUI 进程 helper。packages/opencode/src/plugin/index.ts:插件输入与加载起点。packages/opencode/src/plugin/index.ts:事件分发与 trigger。packages/plugin/src/index.ts、:222-280:公开 plugin 契约。
6. 一条具体源码旅程:外部客户端发送 prompt
Section titled “6. 一条具体源码旅程:外部客户端发送 prompt”假设一个 Node 客户端要向已有 session 发送“解释这个项目”。下面只追一条同步路径。
第一步:SDK 方法来自 typed contract
Section titled “第一步:SDK 方法来自 typed contract”Session group 把不含 sessionID 的 prompt body 定义为 PromptPayload;session id 来自 path param,见 packages/opencode/src/server/routes/instance/httpapi/groups/session.ts。
endpoint 再声明 POST path、payload、成功结果和错误:packages/opencode/src/server/routes/instance/httpapi/groups/session.ts。
这比手写 Controller 的关键优势不是“少代码”,而是同一份契约能参与 server 校验、OpenAPI/SDK 生成和类型检查。
第二步:API group 被装进总 API
Section titled “第二步:API group 被装进总 API”SessionApi 是 InstanceHttpApi 的一个 group,而 InstanceHttpApi 又与 Root、Event、PtyConnect 一起组成 OpenCodeHttpApi,见 packages/opencode/src/server/routes/instance/httpapi/api.ts。
SessionApi -> InstanceHttpApi -> OpenCodeHttpApi目录不是架构本身;真正的总装关系由这些 .addHttpApi(...) 表达。
第三步:handler 做边界工作,再交给 service
Section titled “第三步:handler 做边界工作,再交给 service”同步 prompt handler:
requireSession(sessionID);- 把 path 中的
sessionID与 payload 合并; - 调用
promptSvc.prompt(...); - 把错误映射为
BadRequest; - 以 JSON stream 返回 message。
证据在 packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts。
handler 没有重写 agent loop。HTTP 边界只做协议适配和错误映射,业务仍在 SessionPrompt.Service。
第四步:SDK wrapper 补上工作区语义
Section titled “第四步:SDK wrapper 补上工作区语义”createOpencodeClient 基于 generated createClient,但额外做三件事:
- 默认 fetch 关闭请求 timeout;
- 将
directory编码进x-opencode-directory; - 对 GET/HEAD 将 directory 改写到 query,并注册统一错误 interceptor。
见 packages/sdk/js/src/client.ts。
因此 SDK wrapper 不是重复 generated client,而是在“生成契约”和“OpenCode 工作区路由语义”之间做适配。
第五步:结果与事件不能混为一谈
Section titled “第五步:结果与事件不能混为一谈”同步 prompt 返回最终 message stream;长过程中的 part、tool、permission 等状态仍需要事件通道。一个请求返回值无法替代整个实时状态模型。
这也是上一章 UI 同时需要 command path 与 event path 的原因。
7. 同步 prompt 与异步 prompt 的失败语义
Section titled “7. 同步 prompt 与异步 prompt 的失败语义”异步 handler 在验证 session 后,把 promptSvc.prompt fork 到 scope,立即返回 NoContent,见 packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts。
后台失败不会再变成这个 HTTP 响应,而是:
- 记录
prompt_async failed; - 向 bus 发布
Session.Event.Error。
同步:调用者等待结果,错误映射到本次 response异步:调用者只知道已接收,后续成功/失败靠 event这是 API 设计的真实分叉。选择异步 endpoint,就必须保证客户端订阅事件,否则“202/NoContent”会被误解成任务成功。
8. in-process server 与真实 listener
Section titled “8. in-process server 与真实 listener”Server.Default() 暴露一个可直接 fetch(Request) 的 app handler,见 packages/opencode/src/server/server.ts。Server.listen(...) 则构造真实 listener 和 stop 生命周期,见 packages/opencode/src/server/server.ts。
二者复用 API/handler,但并不等价:
- in-process 避开端口与网络;
- listener 才覆盖地址绑定、跨进程、真实认证/代理等问题。
测试 API handler 可以优先用 in-process fetch;交付 server 时仍需一组真实 listener 冒烟测试。
9. 插件怎样获得能力,又被限制在 hook 边界
Section titled “9. 插件怎样获得能力,又被限制在 hook 边界”插件初始化时,OpenCode 给出 PluginInput:SDK client、project、worktree、directory、workspace adapter 注册点、server URL 与 Bun shell,见 packages/opencode/src/plugin/index.ts。
公开类型在 packages/plugin/src/index.ts。这是一份能力清单:插件能做什么,应从 input 与 hooks 判断,而不是因为它是 JS 就假设它“只能安全地做这些”。插件代码仍运行在进程信任边界内。
公开 Hooks 包括:
event、config、tool、auth、provider;chat.message、chat.params、chat.headers;permission.ask;command.execute.before;tool.execute.before/after;shell.env。
见 packages/plugin/src/index.ts。
一个具体 hook:修改工具参数
Section titled “一个具体 hook:修改工具参数”当 runtime 触发 tool.execute.before 时,把 { tool, sessionID, callID } 作为 input,把 { args } 作为可修改 output。插件按加载顺序逐个执行,后一个插件会看到前一个已修改的 output。
这是从 Hooks 类型和 Plugin.trigger 控制流得到的明确结论,见 packages/plugin/src/index.ts、packages/opencode/src/plugin/index.ts。
风险也随之明确:hook 顺序可观察,慢插件会增加延迟,抛错是否被上层处理要看具体 trigger 调用点,不能一概宣称“插件错误都被隔离”。
event hook 与 trigger hook 不同
Section titled “event hook 与 trigger hook 不同”bus event 订阅处直接对每个 hook 调用 hook.event?.(...),见 packages/opencode/src/plugin/index.ts;二参数变换型 hook 才走通用 trigger。
这就是为什么读源码时要先看函数签名,不能把所有 hook 想象成同一种 middleware。
10. OpenCode 的选择
Section titled “10. OpenCode 的选择”| 选择 | 好处 | 代价 |
|---|---|---|
| typed API group | contract、server、SDK 更一致 | schema 演进成本前置 |
| generated client + 小 wrapper | 大部分自动生成,产品语义集中补充 | 生成流程必须纳入 CI |
| 同时支持 in-process fetch | 本地客户端复用 API 且开销低 | 仍需真实网络测试 |
| 同步与异步 prompt 分开 | 调用者可选择等待或事件驱动 | 两种完成语义必须写清楚 |
| 顺序 plugin hooks | 简单、可组合、容易理解 | 顺序依赖和延迟会累积 |
11. 可以带走的方法
Section titled “11. 可以带走的方法”方法一:让 contract 成为生成与校验的共同输入
Section titled “方法一:让 contract 成为生成与校验的共同输入”不要分别维护 server DTO、OpenAPI 文档和 SDK 类型。
验证问题:改一个 payload 字段时,类型检查能否同时暴露 handler 与 client 的不一致?
方法二:wrapper 只补产品语义
Section titled “方法二:wrapper 只补产品语义”generated client 负责协议机械细节,薄 wrapper 负责 directory、认证、错误等稳定横切语义。
验证问题:wrapper 是否开始手写每个 endpoint?如果是,生成边界已经失效。
方法三:hook 必须声明可修改面
Section titled “方法三:hook 必须声明可修改面”用 { input, output } 明确什么是上下文、什么允许改变,并记录顺序与异常策略。
验证问题:两个插件同时修改 args 时,结果是否可预测、可测试?
12. 费曼复述与练习
Section titled “12. 费曼复述与练习”请回答:
- API、SDK 与 plugin 分别解决什么问题?
- 为什么异步 prompt 的 NoContent 不能代表成功?
- SDK wrapper 为什么处理 directory,而不是让每个调用者自己拼 query?
练习阶梯:
- 入门:画出
SessionApi -> handler -> SessionPrompt.Service。 - 进阶:为 mini agent 定义同步
/prompt与异步/prompt_async的完成语义。 - 源码追踪:从
packages/sdk/js/src/client.ts追到packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts。 - 迁移:设计一个
tool.execute.beforehook,并写出两个插件冲突时的测试。
最后复盘:扩展首先是边界设计
Section titled “最后复盘:扩展首先是边界设计”typed contract -> handler 适配 -> runtime service -> generated SDK 调用
runtime checkpoint -> ordered hooks -> controlled output mutation有了边界还不够:generated SDK 是否及时更新?核心包修改后该跑哪些检查?下一章会把 OpenCode 的 monorepo 任务图还原成一条最小但可信的交付链。