SDK、协议与客户端
旧 JS SDK 由 openapi.json 生成,新契约拆成 schema、protocol、server、client 四层;附一个用 SDK 驱动 opencode 的最小例子。
服务端的 HTTP API 有两类消费者:OpenCode 自己的前端,以及想把 opencode 嵌进自己程序的第三方。v1.18.30 里同时存在三套客户端:
| 包 | 状态 | 来源 |
|---|---|---|
packages/sdk/js(@opencode-ai/sdk) | 现役,对外发布 | openapi.json 经 Hey API 生成 |
packages/client(@opencode-ai/client) | 私有 | 直接从 Effect HttpApi 生成 |
packages/sdk-next | 私有,过渡 | 进程内宿主,组合 client、core 与 server |
旧 SDK:从 OpenAPI 生成
生成链条分两步。opencode generate 调 Server.openapi(),即 OpenApi.fromApi(PublicApi),给每个操作补上 x-codeSamples 后打印出来;packages/sdk/js/script/build.ts 把输出存成 openapi.json 交给 @hey-api/openapi-ts:
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)(packages/sdk/js/script/build.ts:14)
配置里的 paramsStructure: "flat" 决定了调用手感:路径、查询、请求体参数被摊平成一个对象,写 session.prompt({ sessionID, parts }) 即可。产物在 src/v2/gen/,sdk.gen.ts 有 7219 行、types.gen.ts 有 13625 行;脚本最后还用正则给生成结果打了几个补丁,没打上就直接报错。包的根入口与 ./client、./server 是早期接口,./v2 系列才是现役的,TUI、ACP、opencode run 都从 @opencode-ai/sdk/v2 引入。
createOpencodeClient(src/v2/client.ts)在生成代码外包了一层:
directory变成 URL 编码后的x-opencode-directory请求头,experimental_workspaceID变成x-opencode-workspace;- 请求拦截器把 GET、HEAD 请求上的这两个头改写成查询参数,
/api/路径还会同时写location[directory]; - 响应拦截器见到
text/html就报错“服务端版本不支持该请求”:服务端不认识的路径会落到 Web 界面兜底路由返回 HTML,这是 SDK 与服务端版本错配的典型症状。
createOpencodeServer 负责起进程:执行 opencode serve --hostname=127.0.0.1 --port=4096,把配置序列化进 OPENCODE_CONFIG_CONTENT 环境变量,等标准输出出现 opencode server listening 那一行再解析出 URL。createOpencode() 把起进程与建客户端合二为一。
最小例子
下面的示例连接一个已在运行的服务端:建会话、异步发送提示词、打印增量文本,遇到权限询问放行一次,会话空闲即退出。用到的方法与字段都可在 src/v2/gen/sdk.gen.ts 中查到:
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
const client = createOpencodeClient({ baseUrl: "http://127.0.0.1:4096", directory: process.cwd() })
const events = await client.event.subscribe()
const session = await client.session.create({ title: "demo" }, { throwOnError: true })
const sessionID = session.data.id
await client.session.promptAsync({ sessionID, parts: [{ type: "text", text: "列出仓库的顶层目录" }] })
for await (const event of events.stream) {
if (event.type === "message.part.delta" && event.properties.sessionID === sessionID)
process.stdout.write(event.properties.delta)
if (event.type === "permission.asked")
await client.permission.reply({ requestID: event.properties.id, reply: "once" })
if (event.type === "session.status" && event.properties.sessionID === sessionID && event.properties.status.type === "idle")
break
}几点说明:先订阅再发提示词,才不会漏掉开头的事件;promptAsync 对应 POST /session/:sessionID/prompt_async,立即返回 204,而 prompt 要等整轮结束才返回助手消息;/event 按目录过滤,只会收到这个目录实例的事件(事件类型见事件与同步)。
新契约的四层
旧 SDK 隔着一层 OpenAPI:Effect Schema 先翻译成 JSON Schema,public.ts 还得写一大段 matchLegacyOpenApi 把它修回旧形状,再交给第三方生成器。新契约跳过这一层,直接从 HttpApi 定义生成客户端,并拆成四个包:
packages/schema:会话、消息、事件、Prompt 等纯 Effect Schema 类型。packages/protocol:groups/下 18 个HttpApiGroup(agent、command、credential、event、fs、health、integration、location、message、model、permission、project-copy、provider、pty、question、reference、session、skill),外加错误类型与中间件标签。packages/server:18 个 handler 用 core 的服务实现这些分组;routes.ts提供createRoutes()与不带认证的createEmbeddedRoutes()。packages/client:生成的 Promise 客户端(根入口)与 Effect 客户端(/effect)。
根 AGENTS.md 把依赖方向写成了规则:
Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server;
sdk-nextcomposes Client, Core, and Server.
(AGENTS.md:3)
最巧妙的一处在 protocol/src/api.ts:makeApi 与 makeDefaultApi 负责组装分组、决定哪个分组挂哪种中间件,中间件的具体“键”却由调用方传入:
Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream.
(packages/protocol/src/api.ts:25)
服务端传入的 LocationMiddleware 会提供 core 的 Location 服务;客户端则在 src/contract.ts 里另造两个同名的空标签,让同一份 API 定义在不引入 core 的前提下完成类型构造。protocol 因此永远不知道 core 的存在,client 也就能打进浏览器包——README 的原话是 /effect “depends only on Effect, Schema, and Protocol and is browser-bundle safe”。
生成客户端
改了 protocol 或 server 的 HttpApi 后,在 packages/client 里跑 bun run generate,CI 用 check:generated 检查生成物是否与提交一致。脚本核心只有一行:
const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })(packages/client/script/build.ts:7)
compile 来自私有包 @opencode-ai/httpapi-codegen,把 HttpApi 反射成中间契约,再由 emitPromise 写 src/generated、emitEffectImported 写 src/generated-effect。contract.ts 负责产品命名(server.session 分组叫 sessions,server.event 叫 events),并排除 fs.read、pty.connect、pty.connectToken 这些需要自定义传输的端点。codegen 的几条已定规则:参数摊平成一个对象、重名直接报错;恰为 { data: A } 的成功信封自动拆开;流式响应在 Promise 端是 AsyncIterable、在 Effect 端是 Stream,都不自动重连;各类失败统一映射为 ClientError。调用时写 OpenCode.make({ baseUrl }),再调 sessions.list()、events.subscribe() 等。
V1 服务器里的 V2 API
新契约没有另起进程。routes/instance/httpapi/server.ts 的 serverRoutes 把 packages/server 的 Api 与 handlers 挂进了现役服务端,前缀 /api(见HTTP 服务端)。OpenAPI 里也包含这部分,于是旧 SDK 生成出一个 client.v2 命名空间,TUI 的 context/data.tsx 就是通过 sdk.client.v2.session.get(...)、sdk.client.v2.agent.list(...) 读 V2 数据的。
sdk-next:没有端口的宿主
packages/sdk-next 计划接替旧 SDK。它不起服务端,而是把 server 的路由在内存里变成 web handler,再伪装成 fetch 交给生成的 Effect 客户端:
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), {
preconnect: () => undefined,
}) satisfies typeof globalThis.fetch
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
// ...(packages/sdk-next/src/opencode.ts:32,删减后)
这与 TUI 的 Worker 隧道、opencode run 的本地模式是同一个思路:handler 只认 Request 与 Response,传输可以是网络、postMessage,也可以只是一次函数调用。它还暴露了只在本地有意义的 tools.register(...),用于注册自定义工具。
下一篇:终端界面——TUI 如何只通过 SDK 与后端交谈,以及它从主包里拆出来的十个阶段。