opencode HTTP 路由规范:HttpApiBuilder 处理器分层、错误边界与 OpenAPI 兼容策略
本文基于 packages/opencode/specs/effect/routes.md 这份路由编写规范展开,系统讲解 opencode 服务进程中 packages/opencode/src/server/routes/instance/httpapi 目录下的 HTTP 路由模式:何时使用 HttpApiBuilder.group(...)、何时退回裸 HttpRouter,域错误如何在处理器边界翻译成公开的 HTTP 错误契约,以及 OpenAPI/SDK 兼容层 public.ts 的演进策略。读完后你能按仓库既有规范编写新的路由组、声明公开错误 schema,并在改动会影响生成的 SDK 时执行正确的自检流程。
路由代码在哪里,规范解决什么问题
opencode 的服务端 HTTP 表面集中在 packages/opencode/src/server/routes/instance/httpapi 目录,按职责分为三层:
groups/:用HttpApi/HttpApiGroup/HttpApiEndpoint声明每条端点的路径、query、payload、成功与错误 schema,并挂载 OpenAPI 注解;handlers/:用HttpApiBuilder.group(...)编写各端点的具体实现;middleware/+server.ts:授权、实例上下文、工作区路由等横切关注点,以及最终的路由树组装。
routes.md 这份文档的定位是给路由代码的当前编写指南(原文标题为 "HTTP Route Patterns"),它约束的核心问题是:处理器层如何取用服务、错误在哪里翻译、OpenAPI 兼容层如何逐步收缩。同一目录下的 AGENTS.md 提供了更细的模式说明(含 SSE 与 handleRaw 的写法),与本文互为补充。
处理器形态:稳定服务只 yield 一次
规范第一条规则是:普通的 JSON 与流式 HTTP 端点一律使用 HttpApiBuilder.group(...)。构建处理器层时,在 Effect.gen 里一次性 yield 所有稳定服务,然后让各端点实现闭包捕获这些服务,而不是在每个请求内重新解析依赖:
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
return handlers.handle("list", () => session.list())
}),
)
仓库中 session 路由组是这一模式的完整示范。handlers/session.ts 中,sessionHandlers 在构建期集中 yield 了十多个稳定服务(Session.Service、SessionShare.Service、SessionPrompt.Service、SessionRevert.Service、SessionCompaction.Service、Permission.Service、EventV2Bridge.Service 以及 Scope.Scope),随后通过一条链式调用把约 25 个端点逐一绑定:
return handlers
.handle("list", list)
.handle("get", get)
.handleRaw("create", createRaw)
.handle("prompt", prompt)
.handle("revert", revert)
// ...
从源码结构看,这带来几个可验证的实践细节:
- 请求级上下文才允许在请求内解析。例如
messages处理器在返回分页链接时需要真实请求对象来构造Link头,才在请求内yield* HttpServerRequest.HttpServerRequest(handlers/session.ts#L112-L144)。规范强调“不要在请求处理器里重建稳定层”,正是为了让这类请求级解析成为唯一的例外场景。 - 流式响应仍留在
HttpApiBuilder.group内。prompt端点通过HttpServerResponse.stream(...)返回流而不是普通 JSON 响应体(handlers/session.ts#L295-L309),说明"streaming HTTP API endpoint"同样适用本模式,不必降级到裸路由。 - 需要原始请求体时改用
handleRaw。create与fork端点声明为可接受空 body([HttpApiSchema.NoContent, Session.CreateInput]),处理器实现createRaw先取原始request.text,手动JSON.parse+Schema.decodeUnknownEffect解码(handlers/session.ts#L159-L176)。这保留了端点的中间件、路由上下文与 OpenAPI 元数据。
什么时候才允许用裸 HttpRouter
规范明确:裸 HttpRouter 只用于不契合请求/响应 HttpApi 模型的路线,典型场景是 WebSocket 升级和 catch-all 回退。server.ts 顶部的路由树注释精确对应了这一划分:
// Route tree:
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
// - eventApiRoutes: typed SSE route ...
// - ptyConnectApiRoutes: typed WebSocket upgrade route ...
// - instanceApiRoutes: remaining typed instance routes.
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
其中 uiRoute 是唯一的裸 HttpRouter.use(...) catch-all(server.ts#L194-L203),负责把未匹配路径落到内嵌 Web UI。与之形成对照的是:PTY 的 WebSocket 升级端点其实也优先收编进了 HttpApi(PtyConnectApi 声明为 typed 端点,经 ptyConnectApiRoutes 挂载),体现了"能进类型化路由树就进路由树"的取舍。
另外两条与分层相关的约束值得注意:
- 避免在请求处理器或裸路由回调里写
Effect.provide(SomeLayer)——稳定层应在应用/层边界只提供一次; HttpRouter.provideRequest(...)仅用于刻意请求级的依赖;稳定服务应走HttpRouter.use(...)。
错误边界:域错误在处理器层翻译,中间件只做兜底
这是规范中最有约束力的一部分,其目标可以概括为一句话:预期的服务错误(expected service errors)应在处理器边界映射为端点声明的公开 HTTP 错误,而不是在通用中间件里做域错误分发。
公开错误必须是显式 schema 契约
规范要求公开 JSON 错误应是"在每个端点或分组上声明的显式 schema 契约",内置 HttpApiError.* 只有在它生成的 body 恰好就是期望的公开线型(wire shape)时才可以使用。仓库中 errors.ts 就是这个契约的集中定义处,每个公开错误都用 Schema.TaggedErrorClass 声明并附带 httpApiStatus:
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()(
"SessionBusyError",
{
sessionID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 409 },
) {}
端点声明侧与之对应。以 groups/session.ts 的 shell 端点为例,错误被显式声明为 [HttpApiError.BadRequest, ApiNotFoundError, SessionBusyError]——哪些状态码可能出现,在 OpenAPI 契约里一目了然。而 get 端点则只声明 [HttpApiError.BadRequest, ApiNotFoundError]。
保留 { name, data } 线型直到明确的破坏性变更
规范特别指出:既有 { name, data } 错误体结构要一直保留,直到一次有意的破坏性 API 变更。errors.ts 中的 ApiNotFoundError 正是一个兼容案例——它用 Schema.ErrorClass 显式持有 name: "NotFoundError" 与 data: { message } 两个字段(errors.ts#L178-L193),并导出 notFound(message) 工厂函数,使处理器可以用与旧 SDK 完全一致的 body 返回 404。这直接服务于 SDK 兼容目标:客户端(TUI、桌面端、外部 SDK)解析错误体时不感知服务端内部错误类型。
处理器边界的错误映射长什么样
结合 handlers/session.ts 可以看到"一次性映射内联、重复映射提小助手"的具体执行方式:
- 存储不存在类错误统一经
SessionError.mapStorageNotFound(...)在处理器边界转成ApiNotFoundError,例如get、remove、fork等端点(见requireSession与fork实现); - 会话忙(busy)类错误经
SessionError.mapBusy(...)映射为SessionBusyError(409),用于shell、revert、unrevert、deleteMessage等会改动会话状态的端点(handlers/session.ts#L349-L355); - 权限响应端点用
Effect.catchTag("Permission.NotFoundError", ...)把域错误Permission.NotFoundError精确转成公开的PermissionNotFoundError(handlers/session.ts#L362-L378)——注意这是catchTag式的类型化捕获,而不是宽泛的mapError一把抓; - 对确实不可控的失败,
share/unshare映射为类型化的 500 而不是笼统 400,源码注释解释了原因:SessionShare的存储与网络失败不是客户端诱因(handlers/session.ts#L254-L271)。
同目录下的 specs/effect/error-boundaries-plan.md 进一步给出了分层蓝图:域/服务错误(Schema.TaggedErrorClass,不带 HTTP 状态、不带 toObject())、HTTP 公开错误(带 httpApiStatus)、CLI 渲染、会话/消息可见错误(自有 { name, data } 线型)四者各归其位,"每个缝隙(seam)把自己拥有的形状适配出去"。该计划的迁移清单也确认了 routes.md 所述规则并非空谈:Storage not found、Worktree、Provider auth、Provider model not found 等域错误已移出 HTTP 中间件的特判,而 Session.BusyError 的路由边界映射与旧 NamedError 中间件分支的删除仍在队列中。
中间件只做横切关注与最终兜底
"通用中间件不应成为域错误映射器"这条规则在 middleware/error.ts 中有清晰的落地:errorLayer 只在存在**缺陷(defect)**时介入——先过滤掉已经能自行响应的 HttpServerResponse / HttpServerError,再对真正的未预期缺陷做两件事:配置类错误(ConfigErrorV1.*)返回 400 及其原始 body,其余未知缺陷记录 Cause.pretty(cause) 日志后返回带 ref 引用的安全 500 body(middleware/error.ts#L7-L43)。文件头注释直说了边界:"typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s"。换言之,类型化错误走端点声明的路,中间件只擦最后的地板。
OpenAPI 兼容:public.ts 拥有 SDK 转换,逐条收缩
规范第三部分承认了一个现实:public.ts 目前独自承担 SDK/OpenAPI 兼容性转换,策略是"收紧源 schema 一条 workaround 一条地消灭它们"(shrink those transforms by tightening source schemas one workaround at a time)。
从源码看,这个兼容层当前处理的问题都很具体:
- 可选字段的 null 形态:Effect 的
Schema.optional在 OpenAPI 中会产出anyOf: [T, {type:"null"}],而旧 SDK 期望的是纯T,因此matchLegacyOpenApi会对所有组件 schema 做stripOptionalNull(public.ts#L92-L97); - query 参数类型对齐:Effect query schema 描述的是解码后的值(如数字),而生成 SDK 需要公开调用形态,
QueryParameterSchemas按"GET /session limit"这样的"路径 + 参数"键逐一覆写为number/integer等(public.ts#L55-L74); - 自引用组件修复、组件名归一、去重、遗留 schema 覆写、遗留错误 schema 注入等,全部集中在同一函数内顺序执行(public.ts#L82-L104)。
正因为存在这些后处理规则,规范给出了改动 OpenAPI 可见源 schema 时的三条纪律:
- 验证生成的 SDK diff 是有意为之的;
- 除非 PR 明确要改,否则保留遗留兼容;
- 优先修源 schema,而不是新增后处理规则。
配套的 /doc 文档路由也值得了解:server.ts#L183-L192 中 OpenApi.fromApi(PublicApi) 被 lazy 化,只有真正命中 /doc 才付出生成成本,且由于 HttpServerResponse.jsonUnsafe 会立即序列化,缓存的响应体让后续请求复用同一份序列化结果。/doc 本身是裸 HttpRouter.use 路由,走的是仅鉴权的 router 中间件。
路由 PR 自检清单
规范末尾的五条 checklist 是路由改动合入前的验收标准,逐条都有对应落点:
- [ ] 稳定服务在处理器层构建期被 yield(对照
handlers/session.ts开头的服务声明区); - [ ] 预期域错误在路由边界完成翻译(对照
SessionError.mapStorageNotFound/mapBusy、catchTag捕获); - [ ] 端点/分组的错误 schema 准确描述公开 body 与状态码(对照 groups/session.ts 各端点的
error:声明); - [ ] 中间件没有新增任何域特定的 name 判断(对照 middleware/error.ts 只处理 defect 与配置错误的现状);
- [ ] 裸路由仅在 HttpApi 是错误抽象时才使用(对照
server.ts路由树注释中唯一保留的uiRoute)。
关键文件速查
| 文件 | 作用 |
|---|---|
| specs/effect/routes.md | 本文的主体:路由编写规范与 PR 清单 |
| src/server/routes/instance/httpapi/api.ts | 组合 RootHttpApi / InstanceHttpApi / OpenCodeHttpApi,挂载 SchemaErrorMiddleware、Authorization |
| src/server/routes/instance/httpapi/groups/session.ts | session 端点契约声明、SessionPaths 路径表、OpenAPI 注解与分组中间件 |
| src/server/routes/instance/httpapi/handlers/session.ts | session 处理器实现:服务 yield、handleRaw、流式响应、错误边界映射 |
| src/server/routes/instance/httpapi/errors.ts | 公开 HTTP 错误 schema 契约(含 httpApiStatus 与遗留 { name, data } 形态) |
| src/server/routes/instance/httpapi/middleware/error.ts | 最终未知缺陷兜底中间件 |
| src/server/routes/instance/httpapi/public.ts | SDK/OpenAPI 兼容性后处理,逐条待收缩的 workaround 集合 |
| src/server/routes/instance/httpapi/server.ts | 路由树组装、/doc 与 catch-all UI 路由、层组装顺序 |
| specs/effect/error-boundaries-plan.md | 错误边界分层蓝图与 NamedError 移除迁移队列 |
适用前提说明:以上模式与文件路径均以当前仓库 packages/opencode 的服务进程路由实现为准;groups/ 中标题含 "Experimental HttpApi" 的分组(如 session 组的 OpenAPI 描述)表明部分实例路由仍处于向 HttpApi 迁移过程中的实验表面,具体端点状态以 groups/ 内各文件的最新声明为准。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00