plan-sess_36fcfe53-76de-4b6d-b95b-1ed3e422aec8.md 7.1 KB

目标

在 ai-electron 应用内实现本地路由层(Responses ⇄ Chat Completions 协议转换):模型管理探测到「只会 Chat」的端点(DeepSeek、dashscope compatible-mode 等)时自动启用,codex 零改动照常工作。应用内自动桥接,不做独立服务与多上游切换 UI。

实现约束:不从 git 历史恢复任何旧桥接代码,全部重写。 唯一规格依据是 codex rust-v0.155.1 线协议契约(已从 E:\workspace\ai\codex 源码逐字段钉死),行为参照 cc-switch / LiteLLM / claude-code-router。

架构

codex app-server ──POST /v1/responses (SSE)──▶ ChatRouterService(127.0.0.1:随机端口)
   wire_api="responses"(不变)                 │ 纯函数翻译:Responses ⇄ Chat
   buildArgs 零改动                             ▼
                                     真实上游 POST {upstream}/chat/completions(流式转发)
  • 探测 responses → 直连(现状不变);探测 chat-only → 起路由层。
  • 单例路由服务,随当前应用的 provider 启停;换模型 = 重启子进程 + 路由换上游。
  • store=false、每轮全量历史、无 previous_response_id(0.155.1 HTTP 路径钉死)→ 翻译层完全无状态。

新增文件(全部重写)

1. electron/service/codex/chatRouter/translate.ts(翻译层,纯函数 + SSE 状态机,零 IO)

请求方向 responsesToChatRequest(req)(白名单式构建,未列字段一律不发):

  • instructions → {role:"developer", content};model 直传
  • input → messages:message → 对应 role(content 数组里 input_text/output_text→text,input_image→image_url,其余丢弃留诊断);function_call/custom_tool_call → assistant + tool_calls[{id: call_id, type:"function", function:{name, arguments}}];function_call_output/custom_tool_call_output → {role:"tool", tool_call_id: call_id, content: output};reasoning/其他类型 → 丢弃 + 诊断计数
  • tools:仅 type:"function",扁平 {name, description, parameters} → 嵌套 {type:"function", function:{…}};tool_choice:字符串直传,{type:"function",name} → 嵌套;max_output_tokens→max_tokens;temperature/top_p/parallel_tool_calls 直传
  • stream:true → stream:true + stream_options:{include_usage:true}(不支持的端点忽略;usage 全零兜底)
  • 剔除:store/include/reasoning/prompt_cache_key/text/client_metadata/metadata/service_tier/previous_response_id

响应方向 class ChatSseTranslator(吃上游 chat SSE 分片,吐 codex SSE 事件帧):

  • begin():不等上游首字节先发 response.created + response.in_progress——先建 SSE 头,防单槽端点慢 prefill 被 codex 空闲窗掐线重发
  • 事件产出(按 0.155.1 解析器实际消费集):response.created → response.output_item.added(message) → response.output_text.delta* → response.output_item.done(完整 message item) → response.completed;delta.reasoning_content(DeepSeek R1)→ response.reasoning_summary_text.delta(包在 reasoning item added/done 里,先开先闭);delta.tool_calls[] 按 index 归拢增量,finish_reason:"tool_calls"/stop 时逐个补发完整 output_item.done({type:"function_call", call_id, name, arguments}) —— 参数必须整体出现在 done(0.155.1 忽略 function_call_arguments.delta)
  • response.completed:response.id 必填(生成 resp_<uuid>);finish_reason:"length" → status:"incomplete" + incomplete_details;chunk 级 usage → input_tokens/output_tokens/total_tokens(+details 全零),缺省省略 usage
  • data:[DONE]、跨分包行缓冲、坏 JSON 跳过不杀流、finish() 冲刷幂等、fail(message) 补 response.failed

2. electron/service/codex/chatRouter/routerService.ts(HTTP 外壳)

  • node:http listen(0,'127.0.0.1');只接 POST /v1/responses,其余 404;非 JSON body 400
  • 头处理:Authorization 原样透传 + 模型配置 headersJson + 强制 accept-encoding: identity(防上游 gzip 切碎 SSE)
  • 流式:先写 SSE 头 + begin() → fetch(upstream + /chat/completions) → body.getReader() 边收边翻边写 → finish();客户端断连 → AbortController 取消上游(防单槽占死)
  • 非流式(stream:false,防御性保留):缓冲整包 → JSON 翻译;上游非 2xx:流式 fail()、非流式透传状态码;fetch 异常 502
  • start(upstreamBaseUrl, extraHeaders) / stop() / url;诊断钩子(序号/耗时/上游字节/事件数)接装配层 pushDiagnostic

3. 测试(契约驱动全新编写)

  • chatRouter/translate.test.ts:请求映射(items/tools/tool_choice/剔除字段/流参数)、纯文本流事件序列 golden、跨分包、并行 tool_calls 按 index 归拢、reasoning_content 先开先闭、length→incomplete、[DONE]/坏 JSON/finish 幂等
  • chatRouter/routerService.test.ts:非流式、流式(首事件 created、末事件 completed、usage)、上游 500 透传、GET 404 与端口释放、客户端挂断 1 秒内 abort 上游、慢 prefill 1.5 秒内首事件到达
  • chatRouter/router.itest.ts(纳入 smoke:codex):真二进制 → 路由 → 假 chat 上游跑两轮 turn,断言第二轮上游 messages 含第一轮提问+回复+第二轮提问(全量历史语义)

修改文件(接线)

  • providerService.ts:probeEndpoint chat-only 分支放行(detail 改「将通过内置路由层桥接对接」,probe 逻辑不动);applyProvider:responses 直连不变,chat-only 时起路由(upstream=真实地址)→ spec.baseUrl=路由 URL → writeModelCatalog(slug 不受影响)→ runtime.applyProvider → 失败回滚停路由;applied.bridged=true 落盘且 applied.baseUrl 存上游真实地址(与已修的 modelApplied 三重比对天然兼容);clearProvider 停路由
  • types.ts:AppliedProviderInfo.bridged?: boolean;CodexStatusResult.router?: { running; upstream } | null
  • index.ts:路由挂进 CodexContainer;disposeCodex 兜底 stop;toStatusResult 填 router
  • 前端 codex/api/codexApi.ts(类型与注释)、ai/views/aiPlugin/index.vue(chat-only 从红色 error 改 success/info「将通过内置路由层桥接对接」+ 运行时面板展示路由状态);codexChatStore.ts 无需改(确认)
  • 同步旧断言:providerService.test.ts(chat-only 拒绝→放行+baseUrl 断言)、codexRuntime.itest.ts、codexCtl.itest.ts(锁「拒绝」的旧用例)

实施顺序

  1. 翻译层 + 单测(契约 golden 钉死)
  2. 路由服务 + 单测
  3. 接线 providerService/types/index/前端
  4. itest + 修正旧断言
  5. 全量回归:npm test、npm run smoke:codex、tsc --noEmit、vue-tsc(src/codex 0 报错)、build-electron

已知边界

  • 上游不支持 stream_options.include_usage → usage 全零(token 统计缺失,回合正常)
  • reasoning_content → reasoning summary item(有损可见);其他 reasoning item 丢弃留诊断
  • unsupported(两种路由都没有)维持拒绝