跳转到内容

SDK / API / 对外扩展点

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度中等
预计阅读40 分钟
  • 章节 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
  • packages/opencode/src/server/server.ts
  • packages/opencode/src/server/routes/instance/httpapi/api.ts
  • packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
  • packages/sdk/js/src/client.ts
  • packages/sdk/js/src/server.ts
  • packages/opencode/src/plugin/index.ts
  • packages/plugin/src/index.ts

源码基线:v1.18.16(提交 a3647eb025c7)。本章示例是基于源码组合出的典型请求,不是一次实际抓包记录。

读完本章,你应该能:

  • 画出 HTTP API、handler、runtime service、generated SDK 和 plugin hook 的关系。
  • 沿一次 session.prompt 请求,从 typed endpoint 追到 SessionPrompt.Service
  • 解释 SDK wrapper 为什么不只是把 URL 包成函数。
  • 区分“从外部调用已有能力”和“在内部生命周期中扩展行为”。
  • 识别异步 prompt、插件顺序执行和进程启动 helper 的失败边界。

OpenCode 用 typed HTTP API 定义稳定能力边界,用 generated SDK 把边界变成客户端方法,再用 plugin hooks 在明确时点修改输入或输出;API 是门,SDK 是门卡,hook 是预留插槽。

中心问题是:外部程序要让 agent 做事,为什么既需要 API/SDK,又需要 plugin,而不能只开放几个内部函数?

  • 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.paramstool.execute.beforeshell.env 等时点调整数据。

把两者混在一起会产生危险设计:客户端为了改一个 header 侵入 runtime,或者插件绕过 API 自己创建第二套 session。

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 output

OpenCode 的 trigger 正是顺序遍历 hooks 并返回被修改的 output,见 packages/opencode/src/plugin/index.ts

机制主要用户方向稳定边界典型风险
HTTP API任意语言/进程外部调用 runtimemethod/path/schema网络、认证、版本
JS SDKTS/JS 客户端类型化调用 APIgenerated 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 调用。

  1. packages/opencode/src/server/routes/instance/httpapi/api.ts:API group 总装图。
  2. packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:payload 与 path。
  3. packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:prompt/command/shell 契约。
  4. packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:同步与异步 prompt handler。
  5. packages/opencode/src/server/server.ts:75-103:in-process app 与真实 listener。
  6. packages/sdk/js/src/client.ts:SDK wrapper。
  7. packages/sdk/js/src/server.ts:server/TUI 进程 helper。
  8. packages/opencode/src/plugin/index.ts:插件输入与加载起点。
  9. packages/opencode/src/plugin/index.ts:事件分发与 trigger。
  10. 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 生成和类型检查。

SessionApiInstanceHttpApi 的一个 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:

  1. requireSession(sessionID)
  2. 把 path 中的 sessionID 与 payload 合并;
  3. 调用 promptSvc.prompt(...)
  4. 把错误映射为 BadRequest
  5. 以 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”会被误解成任务成功。

Server.Default() 暴露一个可直接 fetch(Request) 的 app handler,见 packages/opencode/src/server/server.tsServer.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 包括:

  • eventconfigtoolauthprovider
  • chat.messagechat.paramschat.headers
  • permission.ask
  • command.execute.before
  • tool.execute.before/after
  • shell.env

packages/plugin/src/index.ts

当 runtime 触发 tool.execute.before 时,把 { tool, sessionID, callID } 作为 input,把 { args } 作为可修改 output。插件按加载顺序逐个执行,后一个插件会看到前一个已修改的 output。

这是从 Hooks 类型和 Plugin.trigger 控制流得到的明确结论,见 packages/plugin/src/index.tspackages/opencode/src/plugin/index.ts

风险也随之明确:hook 顺序可观察,慢插件会增加延迟,抛错是否被上层处理要看具体 trigger 调用点,不能一概宣称“插件错误都被隔离”。

bus event 订阅处直接对每个 hook 调用 hook.event?.(...),见 packages/opencode/src/plugin/index.ts;二参数变换型 hook 才走通用 trigger

这就是为什么读源码时要先看函数签名,不能把所有 hook 想象成同一种 middleware。

选择好处代价
typed API groupcontract、server、SDK 更一致schema 演进成本前置
generated client + 小 wrapper大部分自动生成,产品语义集中补充生成流程必须纳入 CI
同时支持 in-process fetch本地客户端复用 API 且开销低仍需真实网络测试
同步与异步 prompt 分开调用者可选择等待或事件驱动两种完成语义必须写清楚
顺序 plugin hooks简单、可组合、容易理解顺序依赖和延迟会累积

方法一:让 contract 成为生成与校验的共同输入

Section titled “方法一:让 contract 成为生成与校验的共同输入”

不要分别维护 server DTO、OpenAPI 文档和 SDK 类型。

验证问题:改一个 payload 字段时,类型检查能否同时暴露 handler 与 client 的不一致?

generated client 负责协议机械细节,薄 wrapper 负责 directory、认证、错误等稳定横切语义。

验证问题:wrapper 是否开始手写每个 endpoint?如果是,生成边界已经失效。

{ input, output } 明确什么是上下文、什么允许改变,并记录顺序与异常策略。

验证问题:两个插件同时修改 args 时,结果是否可预测、可测试?

请回答:

  1. API、SDK 与 plugin 分别解决什么问题?
  2. 为什么异步 prompt 的 NoContent 不能代表成功?
  3. 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.before hook,并写出两个插件冲突时的测试。

最后复盘:扩展首先是边界设计

Section titled “最后复盘:扩展首先是边界设计”
typed contract
-> handler 适配
-> runtime service
-> generated SDK 调用
runtime checkpoint
-> ordered hooks
-> controlled output mutation

有了边界还不够:generated SDK 是否及时更新?核心包修改后该跑哪些检查?下一章会把 OpenCode 的 monorepo 任务图还原成一条最小但可信的交付链。