从 0 到 1 · 手把手搭建 opencode 网页对话网关—— 02 · 认识 opencode API

2 阅读5分钟

本文档梳理 opencode 的 REST 与 SSE 接口,明确"它返回什么、请求里该传什么",为客户端封装提供依据。


2.1 接口概览

opencode serve 是一个标准 OpenAPI 3.1 服务。本项目只用到 6 类接口:

  1. 健康检查
  2. 会话管理(创建 / 查询 / 删除)
  3. 历史消息查询
  4. 发送消息(阻塞式返回)
  5. 撤回 / 恢复
  6. 实时事件流 /event(SSE) ← 流式打字机的关键

2.2 素材来源

  • 在线文档:http://127.0.0.1:4096/doc(Swagger UI)
  • 离线规范:curl http://127.0.0.1:4096/doc -o doc.json 下载

打开 doc.json 可以看到结构:

{
  "openapi": "3.1.0",
  "info": { "title": "opencode", "version": "1.0.0" },
  "paths": { ... },
  "components": { "schemas": { ... } }
}

提示:doc.json 有 4 万行,别用编辑器硬翻。用 grep 或 Python 脚本定位 paths 下的路径即可。也可以在 Swagger UI 里点。

2.3 会话管理

创建会话

POST /session
Content-Type: application/json

请求体(可选的字段很多,最小可用只要空对象或 title):

{
  "title": "新对话",
  "agent": "build"
}
  • parentID(以 ses 开头):继承父会话
  • agent:指定 agent(build 是默认的编程 agent)
  • model{ "id": "...", "providerID": "..." }
  • permissionPermissionRuleset,可以给单个会话单独设权限
  • workspaceID(以 wrk 开头):指定工作区

响应:一个 Session 对象,必填字段为 id, slug, projectID, directory, title, version, time。我们要的是 idses_... 开头的 ULID)。

列出 / 获取 / 删除

GET  /session           → 会话数组
GET  /session/{id}      → 单个 Session
DELETE /session/{id}    → 200 / 204
PATCH /session/{id}     → 改 title 等(可选)

2.4 消息

拉取历史消息

GET /session/{id}/message

响应是一个数组,每个元素:

[
  {
    "info": {
      "id": "msg_fd77...",          // ULID,以 msg 开头
      "sessionID": "ses_...",
      "role": "user",               // user | assistant
      "time": { "created": 1786027609749 },
      "agent": "build",
      "model": { "providerID": "opencode", "modelID": "deepseek-..." }
    },
    "parts": [
      { "id": "prt_...", "messageID": "msg_fd77...", "type": "text", "text": "你好" }
    ]
  }
]

要点:

  • info 是消息元数据,parts 是内容块(文本 / 推理 / 工具调用 / 文件等)
  • MessageUserMessageAssistantMessage 的联合类型,靠 role 区分
  • assistant 消息的 info 里还有 parentID(回复的是哪条)、finishstop / error 等)、tokenscost
  • 文本内容在 parts[].text

发送消息(阻塞式)

POST /session/{id}/message
Content-Type: application/json

请求体:

{
  "messageID": "msg_...",            // 可选!强烈建议不要填(见 2.6 大坑)
  "parts": [ { "type": "text", "text": "你好" } ],
  "agent": "build",                  // 可选
  "model": { "providerID": "...", "modelID": "..." },  // 可选
  "system": "...",                   // 可选,附加系统提示
  "tools": { "webSearch": true }     // 可选,开关工具
}

响应:{ "info": <AssistantMessage>, "parts": [<Part>] },即完整的 assistant 回复

⚠️ 这个请求会一直阻塞到 AI 回复完成。在终端里感觉不到,但如果在网页后端里直接 await 它,用户就看不到打字机效果。解决办法是并发订阅 /event,见 08 · SSE 流式对话

2.5 撤回与恢复

POST /session/{id}/revert
POST /session/{id}/unrevert

请求体(revert 需要指定撤回哪条):

{ "messageID": "msg_..." }
  • revert:把某条消息之后的所有内容标记为已撤回,等价于 AI 界面里的"改改写写"
  • unrevert:恢复所有已撤回的消息

⚠️ 文档注明两个接口都可能返回 409 SessionBusyError(会话忙时撤回会失败)。客户端要注意处理。

另外 opencode 还提供了更精细的 v2 分阶段撤回接口(/api/session/{id}/revert/stage|clear|commit),本项目用不到,知道有这回事即可。

2.6 ⭐ 大坑:不要自己生成 messageID

POST /message 请求体里有一个可选的 messageID 字段,服务端规范允许客户端自定义(pattern: "^msg")。

本项目踩过这个坑,务必注意:

opencode 每条消息的 ID 是有序的 ULID(形如 msg_fd77e42d...)。在对话循环里,服务端判断"本轮是否结束"依赖消息 ID 的大小顺序(见 SessionPrompt.runLoop,条件类似 lastUser.id < lastAssistant.id 时退出循环)。

  • 如果你传一个自己生成的 msg_<12位uuid十六进制>,它在字典序上小于服务端 ULIDmsg_fd...
  • 结果:第一轮正常,从第二轮开始,AI 会"假装收到"你的新消息,但实际上一眼认定"已处理过",直接返回上一条旧回复,什么都不生成
  • 更糟的变体:如果你用 msg_zzzz... 这类大于 ULID 的 ID,消息甚至可能不入库、接口挂起

结论:客户端永远不要传 messageID,让服务端自己生成单调递增的 ULID。

本项目 app/opencode_client.pysend_message 注释里专门记录了这个坑:

# NOTE: no client-generated ``messageID`` is sent. opencode's run loop
# exits when the newest user message id sorts below the last assistant
# message id ... a client id like ``msg_<uuid hex>`` (which sorts below
# server ULIDs ``msg_fd...``) makes every follow-up message silently
# produce no reply.

2.7 ⭐ 实时事件流 /event(SSE)

GET /event?directory=&workspace=
  • 这是一个 Server-Sent Events(SSE) 长连接,服务端持续推送 JSON 事件
  • 可选 query 参数:directoryworkspace(用来过滤事件,本项目不传,靠手动过滤 session)
  • 每个事件的标准形态:
{
  "id": "evt_...",
  "type": "message.part.delta",
  "properties": { "sessionID": "ses_...", "messageID": "msg_...", "partID": "prt_...", "field": "text", "delta": "你" }
}

我们最关心的几种事件

typeproperties 要点用途
message.updatedsessionID, info: Messageassistant 消息完成/更新,info.finish 有值时表示回复结束
message.part.updatedsessionID, part: Part, time某个内容块创建/落定
message.part.deltasessionID, messageID, partID, field, delta流式增量文本,打字机靠它
session.statussessionID, `status: {type: idlebusy}`会话忙闲状态
session.updatedsessionID, info: Session会话信息更新(含 title)
session.errorsessionID, error出错了

状态机小抄(写客户端必须懂)

SessionStatus 是这三种之一:

{ "type": "idle" }        // 空闲
{ "type": "busy" }        // 忙(AI 正在跑)
{ "type": "retry", ... }  // 重试中

⚠️ 又一个坑:opencode 在保存用户消息之后、AI 开始生成之前,会先发一个 idle 事件。所以不能idle 当作"回复结束"信号——否则 SSE 监听器会过早退出,POST 被取消,AI 永不回复。(08 会讲正确的完成判定。)

2.8 用 Python 快速看一眼 SSE 流

import httpx

with httpx.stream("GET", "http://127.0.0.1:4096/event", timeout=30) as r:
    for line in r.iter_lines():
        print(line)

打开另一个终端给这个会话发条消息,就能看到事件像瀑布一样打出来。这是 08 并发设计的依据。

2.9 小结

接口方法用途阻塞?
/global/healthGET健康检查
/sessionPOST创建会话
/session/{id}/messageGET拉历史
/session/{id}/messagePOST发消息
/session/{id}/revert unrevertPOST撤回/恢复
/eventGET(SSE)实时事件流长连接

三个必须记住的教训:

  1. 别自己生成 messageID
  2. POST 发消息是阻塞的,流式要靠 /event
  3. idle 不能当完成信号