## 目标 在 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_`);`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`(两种路由都没有)维持拒绝