opencode OpenAPI 转换层瘦身:从"生成后修补"到"运行时即真源"的 SDK 兼容治理实践
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
opencode 的服务端由 EffectHttpApi声明式路由构成,OpenAPI 规格本应是路由声明的直接投影。但在长期演进中,public.ts 里积累了大量"生成后 spec 手术"(post-generation transform),其中最高风险的是InstanceQueryParameters——它在directory/workspace不存在于运行时 query schema 的情况下,也把这两个参数注入到每个 instance 路由的 OpenAPI 中,导致/doc和生成的 SDK 宣传了运行时会以400拒绝的调用。本文基于 OpenAPI Translation Cleanup Plan 展开,讲清楚这一治理计划的五条不可妥协原则、按 PR 拆分的七个阶段,以及配套的漂移测试(drift test)如何作为回归防线,最终让/doc、SDK 类型与运行时校验对每个端点达成一致。
问题本质:spec-only 行为与"真源错位"
治理计划要消除的核心失败模式只有一句话:任何出现在/doc或 SDK 中、却不被运行时HttpApi校验接受的行为,都是缺陷。
以 workspace 路由为例,WorkspaceRoutingMiddleware会从 URL 中实际读取directory与workspace两个 query 参数(见 middleware 源码 中url.searchParams.get("workspace")与defaultDirectory()的实现)。但中间件读取参数并不等于HttpApi的运行时校验接受参数——由于上游effect-smol的HttpApiMiddleware尚不能在中间件上声明 query schema,这些字段必须被显式 spread 进每个受影响路由的 query schema。源码中对这一临时方案有明确注释:
// Query fields this middleware reads from the URL. Spread into every // endpoint query schema in groups that apply WorkspaceRoutingMiddleware, // otherwise HttpApi rejects requests carrying these params with 400. // HttpApiMiddleware in effect-smol cannot declare query params today — // remove this once upstream supports middleware-declared query schemas. export const WorkspaceRoutingQueryFields = { directory: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), }这段定义位于 workspace-routing.ts。治理计划正是围绕这个"上游能力缺口"展开:短期用显式运行时 schema 兜底,长期等HttpApi支持中间件级 query 声明后再收敛。
五条不可妥协原则(Non-Negotiables)
治理计划给出了五条硬约束,它们定义了"什么样的改动可以合并":
- 不破坏已发布的 JavaScript SDK,除非有显式的版本化迁移计划;
- 运行时路由 schema 是接受参数、请求体、响应的事实真源(source of truth);
/doc、生成的 SDK 类型、运行时校验,必须对每个端点三者一致;- 优先使用端点或 schema 级注解,而不是生成后的 spec 手术;
- 一次只删除一类重写,并配聚焦的兼容性检查。
这些原则共同指向一个方向:把"翻译"从 spec 后处理层搬回路由声明层,让OpenApi.fromApi(...)的输出基本不需要再被改写。
当前"罪魁祸首":public.ts 的 transform 钩子
public.ts导出PublicApi,它对OpenCodeHttpApi挂了一个OpenApi.annotations({ transform })钩子,transform即matchLegacyOpenApi函数,为旧版 SDK 兼容性重写生成的规格。当前仓库中该 transform 仍在做大量工作,按职责可分为几类(对应 public.ts 的实际实现):
- 组件层重写:
fixSelfReferencingComponents(修复 Effect 去重器产生的自引用$ref组件,见 public.ts#L424-L458)、stripOptionalNull(剥离Schema.optional在 OpenAPI 中产生的anyOf: [T, {type:"null"}]中的 null 分支)、normalizeComponentNames、collapseDuplicateComponents、applyLegacySchemaOverrides、normalizeComponentDescriptions; - 操作层重写:删除
operation.security、responses["401"]与spec.components.securitySchemes(保持旧版 SDK 无认证表面)、normalizeLegacyErrorResponses(把内置EffectHttpApiErrorBadRequest/NotFound响应归一成BadRequestError/NotFoundError形状)、SSE 端点手工补text/event-stream响应; - 参数层重写:
QueryParameterSchemas表(按"METHOD /path param"键覆盖 query 参数的公开类型,见 public.ts#L58-L74)与normalizeParameter的统一收尾。
路由声明本身在 api.ts 中组装:OpenCodeHttpApi = HttpApi.make("opencode").addHttpApi(...),把 Config、Session、File、Pty、Workspace 等十数个路由组(groups/目录下)合并成一个HttpApi。public.ts则是这套声明之上唯一的"翻译层"。
已删除的高风险注入:InstanceQueryParameters
治理计划标记为"当前罪魁祸首"的InstanceQueryParameters(及isInstanceRoute常量)已经删除:PR 2 的工作是把"在每个 instance operation 前插入directory/workspace"的分支从 transform 中移除,改为在受影响路由的运行时 query schema 中显式声明这两个字段。计划中给出的目标代码形态是——transform 内对参数的处理只剩一行:
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)当前 public.ts#L172-L173 已是这个形态。PR 2 的验证结论记录在计划中:重新生成 SDK 后没有任何directory/workspace请求参数的丢失,SDK diff 仅剩声明顺序变化——这证明"运行时 schema 即真源"的替换是 SDK 兼容的。
另外 PR 2 还做了一处有意的表面变化:v2 的 union-query schema 被替换为普通 struct query schema,使OpenApi.fromApi能直接发出这些 query 参数。副作用是 beta/api/session的分页/过滤参数从此显式暴露在 SDK 中;cursor 互斥规则下沉到 handler 层处理,而directory/workspace允许与 cursor 同时出现(因为它们服务于路由而非查询语义)。
PR 1:漂移测试——先建防线再动刀
治理顺序的第一条就是"先只加漂移检测测试",对应 httpapi-query-schema-drift.test.ts。这个测试文件把"spec 与运行时漂移"固化为可执行的断言,包含四层防线:
1. 参数声明一致性断言。维护一份路由清单openApiDriftRoutes,覆盖session、file、experimental、instance等运行时曾出问题的路由:
const openApiDriftRoutes = [ { method: "get", path: SessionPaths.list, query: SessionListQuery }, { method: "get", path: SessionPaths.messages, query: MessagesQuery }, { method: "get", path: FilePaths.findFile, query: FindFileQuery }, // ... ] satisfies Array<{ method: Method; path: string; query: QuerySchema }>对每个条目,通过OpenApi.fromApi(PublicApi)在进程内生成公开 spec,然后断言每个 OpenAPI query 参数都必须由运行时 query schema 声明——核心辅助函数assertAdvertisedQueryParamsAreRuntimeFields会把"仅 spec 宣传"的参数集断言为空:
const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) expect(advertisedOnly, `${method} ${path} advertises query params not accepted by runtime schema`).toEqual([])2. 负向回归 fixture。专门构造一个 spec-only 参数场景来验证断言本身有效:人为传入directory/workspace两个参数但运行时 schema 为空Schema.Struct({}),断言assertAdvertisedQueryParamsAreRuntimeFields必须抛出"advertises query params not accepted by runtime schema"。这保证防线不会被"测试自身失效"而静默放行。
3. 兼容元数据快照。numericSdkQueryParams与booleanSdkQueryParams逐参数锁定了 SDK 公开的调用形状,例如GET /find/file limit必须是{ type: "integer", minimum: 1, maximum: 200 },roots/archived必须是QueryBooleanOpenApi(即{ anyOf: [{type:"boolean"},{type:"string",enum:["true","false"]}] },定义于 groups/query.ts)。pathParamPatterns则锁定 ID 类路径参数的 pattern(^ses、^msg、^prt、^per、^que、^pty、^wrk),对应 PR 4 的成果。
4. 真实 HTTP 回归。一组it.live用例启动真实 server(Server.Default().app),对/session、/find/file、/find、/file、/experimental/session、/experimental/tool、/vcs/diff等带directory&workspace参数的请求断言"不得 400"——正是"OpenAPI 宣传?directory&workspace但运行时拒绝"这一漂移类别的端到端回归。
该测试还验证了布尔 query 解码器的严格性:QueryBoolean只接受字符串"true"/"false"("1"、"yes"、"True"、空串、原生布尔都拒绝),而QueryBooleanOpenApi的anyOf形状正是为兼容旧 SDK 直接传布尔值而保留的公开形状——这正是"运行时解码严格、spec 兼容宽松"两层分离的典型样本。
验证命令(在packages/opencode下执行):
bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts bun typecheckPR 3:用路由级 schema 替换宽泛的 query 类型覆盖表
QueryParameterSchemas表的本质是一张"路由名 → 公开类型"的硬编码映射,属于按名称做宽泛假设的兼容层。PR 3 的目标是把它逐字段清空:
- 已完成的两个首批目标:
roots/archived收敛到显式的路由级共享 schema 辅助(保留QueryBooleanParameters直到路由级 schema 元数据能独立保住boolean | "true" | "false"的 SDK 调用形状);start/cursor/limit对宽泛QueryNumberParameters的依赖被替换为路由级 SDK 兼容 schema。 - 明确保留的例外:
GET /find/file limit、GET /session/{sessionID}/diff messageID、GET /session/{sessionID}/message limit的 override 保留,直到其路由 schema 能直接生成出与 SDK 完全一致的类型。 - 手法:优先在路由声明里写
Schema.NumberFromString.check(...)之类的约束,或复用 groups/session.ts 中既有的QueryBoolean布尔字符串解码器这类路由级 schema。
当前 public.ts#L58-L74 的QueryParameterSchemas已从"整类覆盖"缩小为一张明确的例外清单——每个键都是"GET /path param"形式、每个值都是可解释的 SDK 兼容形状(如minimum: 0, maximum: Number.MAX_SAFE_INTEGER),并且这张表的每一项都被 drift 测试的numericSdkQueryParams快照钉死。
PR 4:把路径参数 pattern 搬进 ID schema
旧做法是public.ts里维护PathParameterSchemas/pathParameterSchema()覆盖表,为sessionID、messageID等路径参数手工补 pattern。PR 4 的做法是把 pattern 注解直接放到品牌化(branded)ID schema 上(源头在 packages/opencode/src/session/schema.ts、packages/opencode/src/permission/schema.ts 与 pty schema 定义),让OpenApi.fromApi天然发出带 pattern 的路径参数,然后"只有当该参数的生成输出不变时才删掉对应 override"。
首批目标(sessionID、messageID、partID、permissionID、ptyID)与模糊的 workspaceid路径覆盖均已完成。结果在 drift 测试的pathParamPatterns快照里得到固化(httpapi-query-schema-drift.test.ts#L86-L95):8 个路径参数的^ses/^msg/^prt/^per/^que/^pty/^wrkpattern 现在直接来自 schema 注解而非 transform 覆盖。
PR 5:内置错误重写 → 声明式 API 错误
现状:transform 中的normalizeLegacyErrorResponses会把内置的EffectHttpApiError.BadRequest/NotFound响应(isBuiltInErrorResponse判定依据是响应描述或$ref指向EffectHttpApiError{BadRequest|NotFound})替换为手写组件BadRequestError/NotFoundError(见 public.ts#L346-L353 与 addLegacyErrorSchemas)。
PR 5 的方向是反过来的:编辑groups/下的路由组文件,把 SDK 可见的HttpApiError.BadRequest/HttpApiError.NotFound替换为 errors.ts 中的显式错误 schema(必要时在那里新增),并让 handler 在边界处直接以声明式 API 错误失败;只有当生成的 OpenAPI 保持 SDK 兼容后,才从normalizeLegacyErrorResponses中删掉对应分支。计划指定按组推进,首选小组:groups/config.ts的PATCH /config400、groups/session.ts中已经翻译了领域 not-found 错误的端点、以及groups/file.ts中任何还依赖内置错误形状的 handler。
验证要点:针对改动错误路径写断言响应体形状的聚焦 HTTP 测试,重新生成 spec 与 SDK 后比对 error union diff。
PR 6 / PR 7:认证表面与组件形状——最危险的收尾
PR 6(auth 重写)审计的是 transform 中delete operation.security、delete operation.responses?.["401"]、delete spec.components?.securitySchemes三处。从 public.ts#L146-L153 的注释看,这是一个有意的兼容决定:认证仍是 legacy 公开 OpenAPI 元数据之外的运行时中间件,因此旧版 SDK 不应暴露 auth scheme 或生成 401 错误 union。计划给出的决策路径是二选一:若必须保持 SDK 无认证表面,则保留该重写并记录为"有意兼容代码";若删除,则必须在同一 PR 里更新 SDK 生成预期与文档,且不得让 auth churn 意外改变 SDK 调用的人体工学。
PR 7(组件形状重写)要求逐个审计normalizeComponentNames、collapseDuplicateComponents、applyLegacySchemaOverrides、normalizeComponentDescriptions、stripOptionalNull、fixSelfReferencingComponents,每个重写单独开小 PR 移除或收窄;若 SDK 类型名大面积 churn,就停下来——要么保留该重写,要么先修effect-smol的生成。具体策略:normalizeComponentDescriptions(纯描述性美化,见 LegacyComponentDescriptions)若 SDK 输出无实质变化则删除;applyLegacySchemaOverrides中对应"源头已修好"的 schema 的条目收窄;stripOptionalNull因影响大量可选字段而保留,直到有显式 SDK 迁移计划。
注意stripOptionalNull与applyLegacySchemaOverrides之间存在精细的"删了又补"关系:组件级剥离 null 后,POST /experimental/workspace的branch/extra(Schema.NullOr,真可空而非仅可选)在 public.ts#L116-L129 被手工加回anyOf: [..., {type:"null"}]——这类"特例补丁"正是 transform 层复杂度的典型样本,也是逐条收窄时必须逐一理解的地方。
上游依赖:让中间件声明自己的 query
计划的长期出口写在 "Upstream Middleware Query Support" 一节:WorkspaceRoutingMiddleware应当一次性声明它读取的 query 字段,且HttpApi同时用它做运行时校验和OpenAPI 生成。对上游effect-smol的三项具体诉求:
- 扩展
HttpApiMiddleware.Service配置,增加可选 query schema 支持,或新增专门的 middleware query 注解; - 运行时请求解码纳入中间件的 query schema;
OpenApi.fromApi为使用该中间件的端点发出 middleware query 参数。
三者就绪后,路由组里的WorkspaceRoutingQueryFieldsspread 可以全部删除,directory/workspace只声明在WorkspaceRoutingMiddleware一处——public.ts里针对 workspace 路由的最后一段"字段重复"技术债随之消失。
每 PR 的标准验证清单
无论推进哪个 PR,治理计划要求同一套验证流程(命令均以 package.json 的实际脚本为准):
# 1. 改动路由的聚焦 HTTP 测试 + OpenAPI 漂移测试 bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts # packages/opencode 下 # 2. 重新生成公开 spec bun dev generate > /tmp/opencode-openapi.json # packages/opencode 下 # 3. 重新构建 SDK 并审查 diff(关注 public API churn: # request 参数是否被删、error union 是否变化、类型名是否大面积重命名) ./packages/sdk/js/script/build.ts # 仓库根目录 # 4. 类型检查 bun typecheck # packages/opencode 下,即 tsgo --noEmit建议的 PR 顺序与"一次一类"原则一一对应:漂移测试 → 删除InstanceQueryParameters注入 → query 类型覆盖转路由级辅助 → 路径参数 override 转 schema 注解 → 内置错误重写转声明式错误 → 组件命名/可空性重写(仅在 SDK 兼容快照稳定后)。
这套治理范式可复用的三点经验
- 先测试、后删除。漂移测试(PR 1)在动 transform 之前落地,且自带负向 fixture 验证断言有效性——"删除兼容代码"这类改动的风险不在于改错了,而在于测试没有覆盖到被删的行为,防线必须先于手术刀存在;
- 真源唯一、注解下沉。每个重写类别的终点都是同一个:把行为写回路由/中间件/schema 声明,让
OpenApi.fromApi的原始输出即可交付,transform 只保留"有意的、记录在案的"兼容决定; - SDK diff 是唯一兼容裁判。每步改动都要以"重新生成的 SDK diff 是否只有可解释变化"为准绳,计划中 PR 2 的验证结论("无参数删除,仅声明顺序变化")就是这套裁判机制的标准答案。
目前该计划的状态:PR 1、PR 2 及 PR 3 / PR 4 的首批目标已完成(checklist 中[x]项),组件形状与 auth 相关重写仍有明确保留条件——这正是"spec 即投影"目标下剩余的工作面。
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考