opencode OpenAPI 转换层瘦身:从“生成后修补“到“运行时即真源“的 SDK 兼容治理实践

opencode OpenAPI 转换层瘦身:从“生成后修补“到“运行时即真源“的 SDK 兼容治理实践 opencode OpenAPI 转换层瘦身从生成后修补到运行时即真源的 SDK 兼容治理实践【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencodeopencode 的服务端由 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用例启动真实 serverServer.Default().app对/session、/find/file、/find、/file、/experimental/session、/experimental/tool、/vcs/diff等带directoryworkspace参数的请求断言不得 400——正是OpenAPI 宣传?directoryworkspace但运行时拒绝这一漂移类别的端到端回归。该测试还验证了布尔 query 解码器的严格性QueryBoolean只接受字符串true/false1、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 注解直接放到品牌化brandedID 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-L958 个路径参数的^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 6auth 重写审计的是 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/extraSchema.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 schemaOpenApi.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),仅供参考