cc 2 долоо хоног өмнө
parent
commit
580f58a817

+ 68 - 0
.zcode/plans/plan-sess_36fcfe53-76de-4b6d-b95b-1ed3e422aec8.md

@@ -0,0 +1,68 @@
+## 目标
+
+在 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`(两种路由都没有)维持拒绝

+ 67 - 0
.zcodeignore

@@ -0,0 +1,67 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+*.log
+*.flattened-pom.xml
+
+### NetBeans ###
+nbproject/private/
+build/
+nbbuild/
+dist/
+nbdist/
+.nb-gradle/
+
+### Mac files ###
+*.DS_Store
+/.d89af0ca/d5e03ad1/
+/.d89af0ca/lucene/
+/.d89af0ca/tmp/
+/.d89af0ca/agent/sessions/
+/logs/
+/QingJian/
+
+# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
+.git/
+.hg/
+.svn/
+node_modules/
+bower_components/
+jspm_packages/
+__pycache__/
+site-packages/
+venv/
+coverage/
+htmlcov/
+lcov-report/
+cmakefiles/
+cmake-build-*/
+bazel-*/
+pods/
+deriveddata/
+storybook-static/
+playwright-report/
+test-results/
+allure-results/
+allure-report/
+cdk.out/
+*.egg-info/
+*.dist-info/
+eggs/
+pip-wheel-metadata/
+wheels/
+# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
+# 自定义规则写在下方(本行提示可删除)

+ 129 - 0
ai-electron/electron/service/codex/chatRouter/router.itest.ts

@@ -0,0 +1,129 @@
+import { createServer, type Server } from 'node:http';
+import { mkdtemp, mkdir, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { CodexRuntime } from '../codexRuntime';
+import { chatRouter } from './routerService';
+import { applyProvider, clearProvider } from '../providerService';
+
+/**
+ * 集成冒烟(不进 npm test,跑法:npm run smoke:codex):
+ * 真 codex.exe + 内置路由层 + 只说 Chat Completions 的本地 mock。
+ * 钉住三件事:
+ * 1. applyProvider 对 chat-only 端点自动起路由,codex 的 base_url 指向本地路由;
+ * 2. codex 发出的 Responses 请求经路由翻译后,上游收到合法 Chat 请求(含鉴权透传);
+ * 3. 两轮对话第二轮的全量历史里带着第一轮提问与回复(store=false 的无状态语义经桥不丢)。
+ */
+
+const API_KEY = 'sk-router-smoke-secret-1234567890';
+
+let upstream: Server | null = null;
+let upstreamUrl = '';
+let upstreamPosts: string[] = [];
+let root = '';
+let homes = 0;
+
+beforeAll(async () => {
+  upstream = createServer((req, res) => {
+    const chunks: Buffer[] = [];
+    req.on('data', (c: Buffer) => chunks.push(c));
+    req.on('end', () => {
+      const route = req.url?.split('?')[0] ?? '';
+      if (req.method !== 'POST' || (route !== '/v1/chat/completions' && route !== '/chat/completions')) {
+        res.writeHead(404, { 'content-type': 'application/json' });
+        res.end(JSON.stringify({ error: { message: 'no route' } }));
+        return;
+      }
+      upstreamPosts.push(Buffer.concat(chunks).toString('utf8'));
+      res.writeHead(200, { 'content-type': 'text/event-stream' });
+      res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: '路由回复' } }] })}\n\n`);
+      res.write(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\n`);
+      res.write(`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 } })}\n\n`);
+      res.write('data: [DONE]\n\n');
+      res.end();
+    });
+  });
+  await new Promise<void>((resolve) => upstream?.listen(0, '127.0.0.1', resolve));
+  const address = upstream.address();
+  const port = typeof address === 'object' && address ? address.port : 0;
+  upstreamUrl = `http://127.0.0.1:${port}/v1`;
+  root = await mkdtemp(join(tmpdir(), 'zsjz-router-'));
+}, 120_000);
+
+afterAll(async () => {
+  await chatRouter.stop();
+  await new Promise<void>((resolve) => (upstream ? upstream.close(() => resolve()) : resolve()));
+  await rm(root, { recursive: true, force: true }).catch(() => undefined);
+});
+
+describe('内置路由层:codex → 路由 → Chat 上游', () => {
+  it(
+    'chat-only 端点自动桥接,两轮对话全量历史经路由不丢',
+    async () => {
+      const home = join(root, `home-${++homes}`);
+      await mkdir(home, { recursive: true });
+      const runtime = new CodexRuntime({ codexHome: home });
+      try {
+        // 走应用真实链路:探测判 chat-only → 起路由 → spec.baseUrl=路由地址
+        const applied = await applyProvider(runtime, {
+          modelId: 'chat-model',
+          name: '桥接模型',
+          baseUrl: upstreamUrl,
+          apiKey: API_KEY,
+          modelRecordId: 7,
+        });
+        expect(applied.bridged).toBe(true);
+        // 落盘的是真实上游地址,路由地址不落盘
+        expect(applied.baseUrl).toBe(upstreamUrl);
+        expect(runtime.status.state).toBe('ready');
+        expect(runtime.provider?.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1$/u);
+        expect(runtime.provider?.baseUrl).not.toBe(upstreamUrl);
+
+        const config = await runtime.readConfig();
+        const entry = (config.model_providers as Record<string, Record<string, unknown>> | undefined)?.zsjz;
+        expect(entry?.base_url).toBe(runtime.provider?.baseUrl);
+        expect(entry?.wire_api).toBe('responses');
+        expect(JSON.stringify(config)).not.toContain(API_KEY);
+
+        const threadId = await runtime.startThread({ cwd: root, approvalPolicy: 'never', sandbox: 'read-only' });
+        const first = await runtime.runTurn({ threadId, prompt: '第一句', approvalPolicy: 'never' });
+        expect(first.status).toBe('completed');
+        expect(first.text).toContain('路由回复');
+
+        const second = await runtime.runTurn({ threadId, prompt: '第二句', approvalPolicy: 'never' });
+        expect(second.status).toBe('completed');
+
+        // 应用时的探测会先直连上游打一发 /chat/completions(无 messages);
+        // 桥接转发的请求必须恰好两轮,且都是合法 Chat 请求
+        const bridgePosts = upstreamPosts.filter((body) => body.includes('"messages"'));
+        expect(bridgePosts.length).toBe(2);
+        const firstRequest = JSON.parse(bridgePosts[0] ?? '{}') as {
+          model?: string;
+          stream?: boolean;
+          messages?: Array<{ role: string; content: unknown }>;
+        };
+        expect(firstRequest.model).toBe('chat-model');
+        expect(firstRequest.stream).toBe(true);
+        expect(firstRequest.messages?.some((m) => m.role === 'developer')).toBe(true);
+        expect(JSON.stringify(firstRequest.messages)).toContain('第一句');
+
+        const secondRequest = JSON.parse(bridgePosts[1] ?? '{}') as {
+          messages?: Array<{ role: string; content: unknown }>;
+        };
+        const serialized = JSON.stringify(secondRequest.messages);
+        // store=false 的无状态语义:第二轮请求带全量历史(第一轮问、答 + 第二轮问)
+        expect(serialized).toContain('第一句');
+        expect(serialized).toContain('路由回复');
+        expect(serialized).toContain('第二句');
+        // Responses 专有字段没有被透传给 Chat 上游
+        expect(serialized).not.toContain('prompt_cache_key');
+        expect(upstreamPosts.every((body) => !body.includes('ZSJZ_CODEX_API_KEY'))).toBe(true);
+      } finally {
+        await clearProvider(runtime);
+        await runtime.dispose();
+      }
+    },
+    240_000,
+  );
+});

+ 214 - 0
ai-electron/electron/service/codex/chatRouter/routerService.test.ts

@@ -0,0 +1,214 @@
+import { createServer, type Server } from 'node:http';
+import { afterAll, beforeAll, afterEach, describe, expect, it } from 'vitest';
+import { ChatRouterService } from './routerService';
+
+let lastUpstreamBody: string | null = null;
+let lastUpstreamHeaders: Record<string, string | string[] | undefined> = {};
+let upstreamAbortCount = 0;
+/** 上游行为:'sse' 正常流 | 'slow' 慢 prefill | 'error' 500 */
+let upstreamMode = 'sse';
+
+const upstream: Server = createServer((req, res) => {
+  const chunks: Buffer[] = [];
+  req.on('data', (c: Buffer) => chunks.push(c));
+  req.on('end', () => {
+    lastUpstreamBody = Buffer.concat(chunks).toString('utf8');
+    lastUpstreamHeaders = req.headers;
+    if (upstreamMode === 'error') {
+      res.writeHead(500, { 'content-type': 'application/json' });
+      res.end(JSON.stringify({ error: { message: 'boom' } }));
+      return;
+    }
+    // 非流式请求(防御路径):回 JSON chat completion;流式回 SSE
+    const wantsStream = (() => {
+      try { return (JSON.parse(lastUpstreamBody ?? '{}') as { stream?: boolean }).stream === true; } catch { return true; }
+    })();
+    if (!wantsStream) {
+      res.writeHead(200, { 'content-type': 'application/json' });
+      res.end(JSON.stringify({
+        choices: [{ message: { content: '你好世界' }, finish_reason: 'stop' }],
+        usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
+      }));
+      return;
+    }
+    res.writeHead(200, { 'content-type': 'text/event-stream' });
+    const finish = (): void => {
+      res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: '世界' } }, { finish_reason: 'stop' }] })}\n\n`);
+      res.write(`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 } })}\n\n`);
+      res.write('data: [DONE]\n\n');
+      res.end();
+    };
+    if (upstreamMode === 'slow') {
+      // 慢 prefill:3 秒后才发首字节,路由层必须在这之前就把 SSE 头与 begin 事件发出去
+      setTimeout(() => {
+        res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: '晚到' } }] })}\n\n`);
+        finish();
+      }, 3_000);
+    } else {
+      res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: '你好' } }] })}\n\n`);
+      finish();
+    }
+  });
+  req.on('close', () => {
+    if (!res.writableEnded) upstreamAbortCount += 1;
+  });
+});
+
+function parseSse(raw: string): Array<{ event: string; data: Record<string, unknown> }> {
+  return raw.split('\n\n').filter((part) => part.includes('event:')).map((part) => {
+    const event = /^event: (.*)$/mu.exec(part)?.[1] ?? '';
+    const data = /^data: (.*)$/mu.exec(part)?.[1] ?? '{}';
+    return { event, data: JSON.parse(data) as Record<string, unknown> };
+  });
+}
+
+let upstreamUrl = '';
+const routers: ChatRouterService[] = [];
+
+beforeAll(async () => {
+  await new Promise<void>((resolve) => {
+    upstream.listen(0, '127.0.0.1', () => resolve());
+  });
+  const address = upstream.address();
+  upstreamUrl = `http://127.0.0.1:${(address as { port: number }).port}/v1`;
+});
+
+async function startRouter(mode: string): Promise<ChatRouterService> {
+  upstreamMode = mode;
+  const router = new ChatRouterService();
+  routers.push(router);
+  await router.start({
+    upstreamBaseUrl: upstreamUrl,
+    forwardHeaderNames: ['x-extra'],
+    onDiagnostic: () => {},
+  });
+  return router;
+}
+
+afterEach(async () => {
+  upstreamAbortCount = 0;
+  for (const router of routers.splice(0)) await router.stop();
+});
+
+afterAll(() => {
+  upstream.close();
+});
+
+describe('ChatRouterService', () => {
+  it('非流式:上游收到 /chat/completions 与透传头,下游拿到 Responses JSON', async () => {
+    const router = await startRouter('sse');
+    // router.url 本身以 /v1 结尾(codex 语义),所以请求路径拼 /responses
+    const res = await fetch(`${router.url}/responses`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json', authorization: 'Bearer sk-test', 'x-extra': 'v1' },
+      body: JSON.stringify({ model: 'qwen', instructions: '你是助手', input: '你好', stream: false }),
+    });
+    expect(res.status).toBe(200);
+    const payload = await res.json() as Record<string, unknown>;
+    expect(payload.object).toBe('response');
+    expect(payload.status).toBe('completed');
+    expect((payload.usage as Record<string, unknown>).total_tokens).toBe(7);
+
+    expect(lastUpstreamBody).not.toBeNull();
+    const chatRequest = JSON.parse(lastUpstreamBody!) as Record<string, unknown>;
+    expect(chatRequest.stream).toBe(false);
+    expect(chatRequest.stream_options).toBeUndefined();
+    expect(chatRequest.messages).toEqual([
+      { role: 'developer', content: '你是助手' },
+      { role: 'user', content: '你好' },
+    ]);
+    expect(lastUpstreamHeaders.authorization).toBe('Bearer sk-test');
+    expect(lastUpstreamHeaders['x-extra']).toBe('v1');
+  });
+
+  it('流式:先 created 后 completed,delta 拼接正确,上游 body 带 include_usage', async () => {
+    const router = await startRouter('sse');
+    const res = await fetch(`${router.url}/responses`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json', authorization: 'Bearer sk-stream' },
+      body: JSON.stringify({ model: 'qwen', input: '你好', stream: true }),
+    });
+    expect(res.status).toBe(200);
+    expect(res.headers.get('content-type')).toContain('text/event-stream');
+    const raw = await res.text();
+    const events = parseSse(raw);
+    expect(events[0].event).toBe('response.created');
+    expect(events.at(-1)?.event).toBe('response.completed');
+    const text = events.filter((e) => e.event === 'response.output_text.delta')
+      .map((e) => e.data.delta as string).join('');
+    expect(text).toBe('你好世界');
+    const completed = events.at(-1)!.data.response as Record<string, unknown>;
+    expect(completed.id).toMatch(/^resp_/);
+    expect(completed.usage).toMatchObject({ input_tokens: 5, output_tokens: 2, total_tokens: 7 });
+
+    expect(lastUpstreamBody).not.toBeNull();
+    const chatRequest = JSON.parse(lastUpstreamBody!) as Record<string, unknown>;
+    expect(chatRequest.stream).toBe(true);
+    expect(chatRequest.stream_options).toEqual({ include_usage: true });
+    expect(lastUpstreamHeaders.authorization).toBe('Bearer sk-stream');
+  });
+
+  it('上游 500(流式):以 response.failed 收尾,codex 走错误映射', async () => {
+    const router = await startRouter('error');
+    const res = await fetch(`${router.url}/responses`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ model: 'qwen', input: 'hi', stream: true }),
+    });
+    expect(res.status).toBe(200);
+    const events = parseSse(await res.text());
+    expect(events.at(-1)?.event).toBe('response.failed');
+    expect((events.at(-1)!.data.response as Record<string, unknown>).error).toMatchObject({ code: 'upstream_error' });
+  });
+
+  it('只接受 POST /v1/responses;stop 后端口释放', async () => {
+    const router = await startRouter('sse');
+    const getUrl = `${router.url}/models`;
+    const wrong = await fetch(getUrl);
+    expect(wrong.status).toBe(404);
+    const url = router.url;
+    await router.stop();
+    await expect(fetch(url!)).rejects.toThrow();
+  });
+
+  it('客户端提前挂断:1 秒内取消上游请求', async () => {
+    const router = await startRouter('slow');
+    const controller = new AbortController();
+    const res = await fetch(`${router.url}/responses`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ model: 'qwen', input: 'hi', stream: true }),
+      signal: controller.signal,
+    });
+    await res.arrayBuffer().catch(() => undefined);
+    controller.abort();
+    await new Promise((resolve) => setTimeout(resolve, 1_000));
+    expect(upstreamAbortCount).toBeGreaterThanOrEqual(1);
+  }, 10_000);
+
+  it('慢 prefill:上游 3 秒不响应,下游 1.5 秒内已收到 begin 事件不掐线', async () => {
+    const router = await startRouter('slow');
+    const startedAt = Date.now();
+    const res = await fetch(`${router.url}/responses`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ model: 'qwen', input: 'hi', stream: true }),
+    });
+    const reader = res.body!.getReader();
+    const decoder = new TextDecoder();
+    let firstFrameAt = -1;
+    let raw = '';
+    for (;;) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      raw += decoder.decode(value, { stream: true });
+      if (firstFrameAt < 0 && raw.includes('response.created')) {
+        firstFrameAt = Date.now() - startedAt;
+        break; // 拿到 begin 即验证完毕,不等慢 prefill
+      }
+    }
+    expect(firstFrameAt).toBeGreaterThanOrEqual(0);
+    expect(firstFrameAt).toBeLessThan(1_500);
+    reader.cancel().catch(() => undefined);
+  }, 15_000);
+});

+ 280 - 0
ai-electron/electron/service/codex/chatRouter/routerService.ts

@@ -0,0 +1,280 @@
+/**
+ * ChatRouterService:本地 HTTP 路由层外壳。
+ *
+ * codex(wire_api="responses")把 POST {url}/v1/responses 发到本服务,
+ * 这里翻译成 POST {upstream}/chat/completions 转发给真实上游,并把
+ * 上游的 Chat SSE 流式翻译回 Responses SSE。翻译本体在 translate.ts(纯函数)。
+ *
+ * 生命周期:applyProvider 探测到 chat-only 端点时 start,换模型/清除/退出时 stop。
+ * 监听 127.0.0.1 随机端口,不持有任何密钥——Authorization 与模型记录配置的自定义头
+ * 由 codex 请求带入后按白名单透传。
+ */
+import http from 'node:http';
+import type { AddressInfo } from 'node:net';
+import {
+  ChatSseTranslator,
+  chatResponseToResponses,
+  responsesToChatRequest,
+  type ResponsesCreateParams,
+} from './translate';
+
+export interface ChatRouterStartOptions {
+  /** 真实上游 API 根,形如 https://api.deepseek.com/v1(去尾斜杠后拼 /chat/completions) */
+  upstreamBaseUrl: string;
+  /** 额外透传给上游的请求头名(来自模型记录 headersJson 的键;authorization 恒透传) */
+  forwardHeaderNames?: readonly string[];
+  onDiagnostic?: (message: string) => void;
+}
+
+export interface ChatRouterInfo {
+  url: string;
+  port: number;
+  upstreamBaseUrl: string;
+}
+
+const MAX_ERROR_BODY_BYTES = 4_096;
+
+export class ChatRouterService {
+  #server: http.Server | null = null;
+  #upstreamBaseUrl: string | null = null;
+  #forwardHeaderNames = new Set<string>(['authorization']);
+  #onDiagnostic: ((message: string) => void) | null = null;
+  #diagnosticSink: ((message: string) => void) | null = null;
+  #requestSeq = 0;
+
+  /** 装配层注入的常驻诊断出口;start 传入的 onDiagnostic 优先 */
+  setDiagnosticSink(sink: ((message: string) => void) | null): void {
+    this.#diagnosticSink = sink;
+  }
+
+  get running(): boolean {
+    return this.#server !== null;
+  }
+
+  get url(): string | null {
+    return this.#upstreamBaseUrl ? this.#routerUrl() : null;
+  }
+
+  get upstreamBaseUrl(): string | null {
+    return this.#upstreamBaseUrl;
+  }
+
+  info(): { running: boolean; upstream: string } | null {
+    if (!this.#server || !this.#upstreamBaseUrl) return null;
+    return { running: true, upstream: this.#upstreamBaseUrl };
+  }
+
+  #routerUrl(): string {
+    const address = this.#server?.address() as AddressInfo | null;
+    return `http://127.0.0.1:${address?.port ?? 0}/v1`;
+  }
+
+  #diagnose(message: string): void {
+    this.#onDiagnostic?.(message);
+    this.#diagnosticSink?.(message);
+  }
+
+  /** 启动(已在跑则先停旧的再起新的,端口随机)。失败时抛错且不留半开服务 */
+  async start(options: ChatRouterStartOptions): Promise<ChatRouterInfo> {
+    await this.stop();
+
+    const upstream = options.upstreamBaseUrl.replace(/\/+$/u, '');
+    if (!/^https?:\/\//u.test(upstream)) throw new Error(`路由层上游地址无效:${options.upstreamBaseUrl}`);
+    this.#upstreamBaseUrl = upstream;
+    this.#onDiagnostic = options.onDiagnostic ?? null;
+    this.#forwardHeaderNames = new Set(['authorization']);
+    for (const name of options.forwardHeaderNames ?? []) {
+      const normalized = name.trim().toLowerCase();
+      if (normalized) this.#forwardHeaderNames.add(normalized);
+    }
+
+    const server = http.createServer((req, res) => {
+      void this.#handle(req, res);
+    });
+    this.#server = server;
+
+    await new Promise<void>((resolve, reject) => {
+      const onListening = () => {
+        server.off('error', onError);
+        resolve();
+      };
+      const onError = (error: Error) => {
+        server.off('listening', onListening);
+        this.#server = null;
+        reject(error);
+      };
+      server.once('listening', onListening);
+      server.once('error', onError);
+      server.listen(0, '127.0.0.1');
+    });
+
+    const info: ChatRouterInfo = { url: this.#routerUrl(), port: (this.#server!.address() as AddressInfo).port, upstreamBaseUrl: upstream };
+    this.#diagnose(`路由层已启动:${info.url} → ${upstream}/chat/completions`);
+    return info;
+  }
+
+  async stop(): Promise<void> {
+    const server = this.#server;
+    this.#server = null;
+    this.#upstreamBaseUrl = null;
+    if (!server) return;
+    await new Promise<void>((resolve) => {
+      server.close(() => resolve());
+    });
+    this.#diagnose('路由层已停止');
+  }
+
+  async #handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
+    const seq = ++this.#requestSeq;
+    const startedAt = Date.now();
+    const path = (req.url ?? '').replace(/\?.*$/u, '');
+    if (req.method !== 'POST' || (path !== '/v1/responses' && path !== '/responses')) {
+      res.writeHead(404, { 'content-type': 'application/json' });
+      res.end(JSON.stringify({ error: { message: `路由层只接受 POST /v1/responses,收到 ${req.method} ${path}`, type: 'router_error' } }));
+      return;
+    }
+
+    const bodyChunks: Buffer[] = [];
+    for await (const chunk of req) bodyChunks.push(chunk as Buffer);
+    let responsesRequest: ResponsesCreateParams;
+    try {
+      responsesRequest = JSON.parse(Buffer.concat(bodyChunks).toString('utf8')) as ResponsesCreateParams;
+    } catch {
+      res.writeHead(400, { 'content-type': 'application/json' });
+      res.end(JSON.stringify({ error: { message: '请求体不是合法 JSON', type: 'router_error' } }));
+      return;
+    }
+    if (!responsesRequest || typeof responsesRequest !== 'object') {
+      res.writeHead(400, { 'content-type': 'application/json' });
+      res.end(JSON.stringify({ error: { message: '请求体必须为 JSON 对象', type: 'router_error' } }));
+      return;
+    }
+
+    const chatRequest = responsesToChatRequest(responsesRequest, (message) => {
+      this.#diagnose(`路由 round #${seq}: ${message}`);
+    });
+    const upstreamUrl = `${this.#upstreamBaseUrl}/chat/completions`;
+
+    // 防单槽占死:客户端提前挂断 → 立刻取消上游
+    const controller = new AbortController();
+    let responseFinished = false;
+    let clientAborted = false;
+    res.once('close', () => {
+      if (!responseFinished) {
+        clientAborted = true;
+        controller.abort();
+      }
+    });
+
+    const forwardHeaders: Record<string, string> = {
+      'content-type': 'application/json',
+      // 防上游 gzip 把 SSE 行切碎
+      'accept-encoding': 'identity',
+      accept: chatRequest.stream ? 'text/event-stream' : 'application/json',
+    };
+    const incoming = req.headers;
+    for (const name of this.#forwardHeaderNames) {
+      const value = incoming[name];
+      if (typeof value === 'string' && value) forwardHeaders[name] = value;
+    }
+
+    const started = Date.now();
+    let upstreamBytes = 0;
+    let upstreamStatus: number | null = null;
+    let outcome = 'ok';
+    // 流式开始后出错时复用同一 translator:fail 帧的 response.id 与已发事件一致且幂等
+    let translator: ChatSseTranslator | null = null;
+    try {
+      // 流式:先把 SSE 头与 created/in_progress 发出去,再等上游(可能慢 prefill 数分钟)
+      if (chatRequest.stream) {
+        res.writeHead(200, {
+          'content-type': 'text/event-stream',
+          'cache-control': 'no-cache',
+          connection: 'keep-alive',
+          'x-accel-buffering': 'no',
+        });
+        translator = new ChatSseTranslator((message) => {
+          this.#diagnose(`路由 round #${seq}: ${message}`);
+        });
+        for (const frame of translator.begin()) res.write(frame);
+      }
+
+      const upstreamRes = await fetch(upstreamUrl, {
+        method: 'POST',
+        headers: forwardHeaders,
+        body: JSON.stringify(chatRequest),
+        signal: controller.signal,
+      });
+      upstreamStatus = upstreamRes.status;
+
+      if (!upstreamRes.ok) {
+        const errorBody = (await upstreamRes.text()).slice(0, MAX_ERROR_BODY_BYTES);
+        outcome = `upstream_${upstreamRes.status}`;
+        this.#diagnose(`路由 round #${seq}: 上游 ${upstreamRes.status} —— ${errorBody}`);
+        if (chatRequest.stream && translator) {
+          // SSE 已开:以 response.failed 收尾,codex 会走错误映射并按策略重试
+          for (const frame of translator.fail(`上游 HTTP ${upstreamRes.status}: ${errorBody || '无响应体'}`)) res.write(frame);
+          responseFinished = true;
+          res.end();
+          return;
+        }
+        res.writeHead(upstreamRes.status, { 'content-type': 'application/json' });
+        res.end(JSON.stringify({ error: { message: `上游 HTTP ${upstreamRes.status}: ${errorBody}`, type: 'upstream_error' } }));
+        return;
+      }
+
+      if (!chatRequest.stream) {
+        const payload = await upstreamRes.json() as Record<string, unknown>;
+        responseFinished = true;
+        res.writeHead(200, { 'content-type': 'application/json' });
+        res.end(JSON.stringify(chatResponseToResponses(payload, `resp_router_${seq}`)));
+        return;
+      }
+
+      const reader = upstreamRes.body?.getReader() ?? null;
+      if (!reader) {
+        for (const frame of translator!.fail('上游未返回可读响应体')) res.write(frame);
+        responseFinished = true;
+        res.end();
+        return;
+      }
+      const decoder = new TextDecoder();
+      for (;;) {
+        const { done, value } = await reader.read();
+        if (done) break;
+        upstreamBytes += value.byteLength;
+        for (const frame of translator!.push(decoder.decode(value, { stream: true }))) res.write(frame);
+      }
+      for (const frame of translator!.finish()) res.write(frame);
+      responseFinished = true;
+      res.end();
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      if (clientAborted) {
+        outcome = 'client_abort';
+        this.#diagnose(`路由 round #${seq}: 客户端提前挂断,已取消上游请求`);
+        return;
+      }
+      outcome = 'fetch_error';
+      this.#diagnose(`路由 round #${seq}: 上游请求失败 —— ${message}`);
+      if (!res.headersSent) {
+        res.writeHead(502, { 'content-type': 'application/json' });
+        res.end(JSON.stringify({ error: { message: `路由层访问上游失败:${message}`, type: 'router_error' } }));
+        return;
+      }
+      if (translator && !responseFinished) {
+        for (const frame of translator.fail(`上游请求失败:${message}`)) res.write(frame);
+        responseFinished = true;
+        res.end();
+      }
+    } finally {
+      this.#diagnose(
+        `路由 round #${seq} 结束:状态=${outcome} 上游=${upstreamStatus ?? '-'} ` +
+        `字节=${upstreamBytes} 耗时=${Date.now() - started}ms`,
+      );
+    }
+  }
+}
+
+/** 应用级单例:路由层随当前应用的 provider 启停 */
+export const chatRouter = new ChatRouterService();

+ 311 - 0
ai-electron/electron/service/codex/chatRouter/translate.test.ts

@@ -0,0 +1,311 @@
+import { describe, expect, it } from 'vitest';
+import { ChatSseTranslator, chatResponseToResponses, responsesToChatRequest } from './translate';
+
+function chatSse(payload: string): string {
+  return `data: ${payload}\n\n`;
+}
+
+function chatDelta(delta: Record<string, unknown>, finishReason?: string, usage?: unknown): string {
+  const choice: Record<string, unknown> = { index: 0, delta };
+  if (finishReason) choice.finish_reason = finishReason;
+  const chunk: Record<string, unknown> = { id: 'chatcmpl_1', choices: [choice] };
+  if (usage !== undefined) chunk.usage = usage;
+  return JSON.stringify(chunk);
+}
+
+type EventTuple = Array<{ event: string; data: Record<string, any> }>;
+
+function parseFrames(frames: string[]): EventTuple {
+  return frames.join('').split('\n\n').filter(Boolean).map((raw) => {
+    const event = /^event: (.*)$/mu.exec(raw)?.[1] ?? '';
+    const data = /^data: (.*)$/mu.exec(raw)?.[1] ?? '{}';
+    return { event, data: JSON.parse(data) as Record<string, unknown> };
+  });
+}
+
+describe('responsesToChatRequest 请求方向', () => {
+  it('instructions → developer 消息;input 字符串 → user 消息', () => {
+    const chat = responsesToChatRequest({ model: 'qwen', instructions: '你是助手', input: '你好' });
+    expect(chat.model).toBe('qwen');
+    expect(chat.messages).toEqual([
+      { role: 'developer', content: '你是助手' },
+      { role: 'user', content: '你好' },
+    ]);
+    expect(chat.stream).toBe(false);
+  });
+
+  it('message 数组 content:文本合并、单文本压字符串、图片转 image_url、未知 part 丢弃留诊断', () => {
+    const dropped: string[] = [];
+    const chat = responsesToChatRequest({
+      model: 'qwen',
+      input: [{
+        type: 'message',
+        role: 'user',
+        content: [
+          { type: 'input_text', text: '看图' },
+          { type: 'input_image', image_url: 'http://x/y.png' },
+          { type: 'input_image', image_url: { url: 'http://x/z.png' } },
+          { type: 'input_file', file_id: 'f1' },
+          { type: 'input_text', text: '回答我' },
+        ],
+      }],
+    }, (m) => dropped.push(m));
+    expect(chat.messages).toHaveLength(1);
+    const content = chat.messages[0].content as Array<Record<string, unknown>>;
+    expect(content).toEqual([
+      { type: 'text', text: '看图' },
+      { type: 'image_url', image_url: { url: 'http://x/y.png' } },
+      { type: 'image_url', image_url: { url: 'http://x/z.png' } },
+      { type: 'text', text: '回答我' },
+    ]);
+    expect(dropped.join(' ')).toContain('input_file×1');
+  });
+
+  it('function_call / function_call_output → assistant tool_calls 与 role:tool,call_id 原样对应', () => {
+    const chat = responsesToChatRequest({
+      model: 'qwen',
+      input: [
+        { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'ls' }] },
+        { type: 'function_call', id: 'fc_1', call_id: 'call_9', name: 'shell', arguments: '{"cmd":["ls"]}' },
+        { type: 'function_call_output', call_id: 'call_9', output: 'file-a\nfile-b' },
+      ],
+    });
+    expect(chat.messages[1]).toEqual({
+      role: 'assistant',
+      content: null,
+      tool_calls: [{ id: 'call_9', type: 'function', function: { name: 'shell', arguments: '{"cmd":["ls"]}' } }],
+    });
+    expect(chat.messages[2]).toEqual({ role: 'tool', tool_call_id: 'call_9', content: 'file-a\nfile-b' });
+  });
+
+  it('custom_tool_call 用 input 当 arguments;结构化 output 折叠成文本', () => {
+    const chat = responsesToChatRequest({
+      model: 'qwen',
+      input: [
+        { type: 'custom_tool_call', call_id: 'c1', name: 'patch', input: '*** Begin Patch' },
+        { type: 'custom_tool_call_output', call_id: 'c1', output: [{ type: 'input_text', text: 'ok' }] },
+      ],
+    });
+    expect(chat.messages[0].tool_calls?.[0].function.arguments).toBe('*** Begin Patch');
+    expect(chat.messages[1].content).toBe('ok');
+  });
+
+  it('reasoning / web_search_call 丢弃并留诊断', () => {
+    const dropped: string[] = [];
+    const chat = responsesToChatRequest({
+      model: 'qwen',
+      input: [
+        { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: '想' }] },
+        { type: 'message', role: 'user', content: 'hi' },
+        { type: 'web_search_call', id: 'ws_1' },
+      ],
+    }, (m) => dropped.push(m));
+    expect(chat.messages).toEqual([{ role: 'user', content: 'hi' }]);
+    expect(dropped.join(' ')).toContain('reasoning×1');
+    expect(dropped.join(' ')).toContain('web_search_call×1');
+  });
+
+  it('tools 扁平转嵌套,非 function 工具丢弃;tool_choice 各形态映射', () => {
+    const dropped: string[] = [];
+    const chat = responsesToChatRequest({
+      model: 'qwen',
+      input: 'hi',
+      tools: [
+        { type: 'function', name: 'shell', description: '跑命令', parameters: { type: 'object' }, strict: true },
+        { type: 'web_search' },
+      ],
+      tool_choice: { type: 'function', name: 'shell' },
+    }, (m) => dropped.push(m));
+    expect(chat.tools).toEqual([{ type: 'function', function: { name: 'shell', description: '跑命令', parameters: { type: 'object' } } }]);
+    expect(chat.tool_choice).toEqual({ type: 'function', function: { name: 'shell' } });
+    expect(dropped.join(' ')).toContain('tools×1');
+  });
+
+  it('Responses 专有字段绝不外泄(白名单式构建)', () => {
+    const chat = responsesToChatRequest({
+      model: 'qwen',
+      input: 'hi',
+      stream: true,
+      store: false,
+      include: ['reasoning.encrypted_content'],
+      reasoning: { effort: 'low', summary: 'auto' },
+      prompt_cache_key: 'thread-1',
+      client_metadata: { session_id: 's' },
+      metadata: { a: 1 },
+      text: { verbosity: 'low' },
+      service_tier: 'default',
+      previous_response_id: 'resp_old',
+      max_output_tokens: 123,
+      temperature: 0.5,
+      top_p: 0.9,
+      parallel_tool_calls: false,
+    });
+    const serialized = JSON.stringify(chat);
+    for (const banned of ['store', 'include', 'reasoning', 'prompt_cache_key', 'client_metadata', 'metadata', 'text', 'service_tier', 'previous_response_id']) {
+      expect(serialized).not.toContain(`"${banned}"`);
+    }
+    expect(chat.max_tokens).toBe(123);
+    expect(chat.temperature).toBe(0.5);
+    expect(chat.top_p).toBe(0.9);
+    expect(chat.parallel_tool_calls).toBe(false);
+    expect(chat.stream).toBe(true);
+    expect(chat.stream_options).toEqual({ include_usage: true });
+  });
+});
+
+describe('chatResponseToResponses 非流式响应方向', () => {
+  it('content/tool_calls/reasoning_content/usage 全量映射,length → incomplete', () => {
+    const responses = chatResponseToResponses({
+      choices: [{
+        message: {
+          content: 'hello',
+          reasoning_content: 'thinking…',
+          tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'shell', arguments: '{}' } }],
+        },
+        finish_reason: 'stop',
+      }],
+      usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
+    }, 'resp_x');
+    expect(responses.status).toBe('completed');
+    expect((responses.usage as Record<string, unknown>).total_tokens).toBe(7);
+    const output = responses.output as Array<Record<string, unknown>>;
+    expect(output.map((item) => item.type)).toEqual(['reasoning', 'message', 'function_call']);
+    expect(output[2]).toMatchObject({ call_id: 'call_1', name: 'shell', arguments: '{}' });
+  });
+
+  it('finish_reason=length → incomplete + max_output_tokens', () => {
+    const responses = chatResponseToResponses({
+      choices: [{ message: { content: ' truncated' }, finish_reason: 'length' }],
+    }, 'resp_y');
+    expect(responses.status).toBe('incomplete');
+    expect(responses.incomplete_details).toEqual({ reason: 'max_output_tokens' });
+  });
+});
+
+describe('ChatSseTranslator 流式状态机', () => {
+  it('begin() 先发 created + in_progress,response.id 一致且必填', () => {
+    const translator = new ChatSseTranslator();
+    const events = parseFrames(translator.begin());
+    expect(events.map((e) => e.event)).toEqual(['response.created', 'response.in_progress']);
+    for (const e of events) expect(e.data.response.id).toMatch(/^resp_/);
+    expect(translator.responseId).toBe(events[0].data.response.id);
+  });
+
+  it('纯文本流 golden:added → delta* → done(完整文本) → completed(usage)', () => {
+    const translator = new ChatSseTranslator();
+    translator.begin();
+    const frames = [
+      ...translator.push(
+        chatSse(chatDelta({ role: 'assistant', content: '你' })) +
+        chatSse(chatDelta({ content: '好' })) +
+        chatSse(chatDelta({}, 'stop', { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 })) +
+        chatSse('[DONE]'),
+      ),
+      ...translator.finish(),
+    ];
+    const events = parseFrames(frames);
+    expect(events.map((e) => e.event)).toEqual([
+      'response.output_item.added',
+      'response.output_text.delta',
+      'response.output_text.delta',
+      'response.output_item.done',
+      'response.completed',
+    ]);
+    const doneItem = events[3].data.item as Record<string, unknown>;
+    expect(doneItem).toMatchObject({
+      type: 'message',
+      role: 'assistant',
+      content: [{ type: 'output_text', text: '你好' }],
+    });
+    const completed = events[4].data.response as Record<string, unknown>;
+    expect(completed.id).toBe(translator.responseId);
+    expect(completed.usage).toMatchObject({ input_tokens: 5, output_tokens: 2, total_tokens: 7 });
+  });
+
+  it('data 行跨分包也能正确解析', () => {
+    const translator = new ChatSseTranslator();
+    const payload = chatDelta({ content: '分' });
+    const whole = chatSse(payload);
+    const mid = Math.floor(whole.indexOf('choices') + 3);
+    const frames = [...translator.push(whole.slice(0, mid)), ...translator.push(whole.slice(mid))];
+    const events = parseFrames(frames);
+    expect(events.some((e) => e.event === 'response.output_text.delta' && e.data.delta === '分')).toBe(true);
+  });
+
+  it('reasoning_content 先开先闭:reasoning item 先 done,再开 message item', () => {
+    const translator = new ChatSseTranslator();
+    const frames = translator.push(
+      chatSse(chatDelta({ reasoning_content: '想一步' })) +
+      chatSse(chatDelta({ reasoning_content: '想两步' })) +
+      chatSse(chatDelta({ content: '答' })) +
+      chatSse('[DONE]'),
+    );
+    translator.finish();
+    const events = parseFrames(frames);
+    const kinds = events.map((e) => e.event);
+    expect(kinds.filter((e) => e === 'response.reasoning_summary_text.delta')).toHaveLength(2);
+    expect(kinds.indexOf('response.output_item.done')).toBeGreaterThan(-1);
+    const reasoningDone = events.find((e) => e.event === 'response.output_item.done' && (e.data.item as Record<string, unknown>).type === 'reasoning');
+    const messageAdded = events.find((e) => e.event === 'response.output_item.added' && (e.data.item as Record<string, unknown>).type === 'message');
+    const reasoningDoneIndex = events.indexOf(reasoningDone!);
+    const messageAddedIndex = events.indexOf(messageAdded!);
+    expect(reasoningDoneIndex).toBeLessThan(messageAddedIndex);
+    expect(reasoningDone?.data.item).toMatchObject({ summary: [{ type: 'summary_text', text: '想一步想两步' }] });
+  });
+
+  it('并行 tool_calls 按 index 归拢:参数跨 chunk 拼接,出现新 index 先收口前一个', () => {
+    const translator = new ChatSseTranslator();
+    const frames = [
+      ...translator.push(
+        chatSse(chatDelta({ tool_calls: [{ index: 0, id: 'call_a', function: { name: 'shell', arguments: '{"cm' } }] })) +
+        chatSse(chatDelta({ tool_calls: [{ index: 0, function: { arguments: 'd":["ls"]}' } }, { index: 1, id: 'call_b', function: { name: 'read' } }] })) +
+        chatSse(chatDelta({}, 'tool_calls')) +
+        chatSse('[DONE]'),
+      ),
+      ...translator.finish(),
+    ];
+    const events = parseFrames(frames);
+    const doneCalls = events.filter((e) => e.event === 'response.output_item.done')
+      .map((e) => e.data.item as Record<string, unknown>)
+      .filter((item) => item.type === 'function_call');
+    expect(doneCalls).toHaveLength(2);
+    expect(doneCalls[0]).toMatchObject({ call_id: 'call_a', name: 'shell', arguments: '{"cmd":["ls"]}' });
+    expect(doneCalls[1]).toMatchObject({ call_id: 'call_b', name: 'read', arguments: '' });
+  });
+
+  it('finish_reason=length → completed 带 incomplete_details;无 usage 时省略 usage', () => {
+    const translator = new ChatSseTranslator();
+    const frames = [
+      ...translator.push(chatSse(chatDelta({ content: 'x' }))),
+      ...translator.push(chatSse(chatDelta({}, 'length'))),
+      ...translator.finish(),
+    ];
+    const events = parseFrames(frames);
+    const completed = events.find((e) => e.event === 'response.completed')!;
+    expect(completed.data.response.status).toBe('incomplete');
+    expect(completed.data.response.incomplete_details).toEqual({ reason: 'max_output_tokens' });
+    expect(completed.data.response.usage).toBeUndefined();
+  });
+
+  it('上游 EOF(无 [DONE])时 finish() 冲刷且幂等;坏 JSON 跳过;usage-only chunk 不产事件', () => {
+    const translator = new ChatSseTranslator();
+    const bad = translator.push('data: {not-json\n\n');
+    expect(bad).toEqual([]);
+    const usageOnly = translator.push(chatSse(JSON.stringify({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } })));
+    expect(usageOnly).toEqual([]);
+    const first = translator.finish();
+    const second = translator.finish();
+    expect(second).toEqual([]);
+    expect(first.join('')).toContain('response.completed');
+  });
+
+  it('fail() 产出 response.failed 且幂等;finish 后 fail 不再产帧', () => {
+    const translator = new ChatSseTranslator();
+    const frames = translator.fail('上游炸了');
+    expect(parseFrames(frames)[0].data.response.error).toEqual({ code: 'upstream_error', message: '上游炸了' });
+    expect(translator.fail('again')).toEqual([]);
+    const fresh = new ChatSseTranslator();
+    fresh.finish();
+    expect(fresh.fail('late')).toEqual([]);
+  });
+});

+ 637 - 0
ai-electron/electron/service/codex/chatRouter/translate.ts

@@ -0,0 +1,637 @@
+/**
+ * Responses ⇄ Chat Completions 翻译层(纯函数 + SSE 状态机,零 IO)。
+ *
+ * 规格依据:codex rust-v0.155.1 的线协议(codex-api/src/common.rs、
+ * codex-api/src/sse/responses.rs、protocol/src/models.rs):
+ * - 请求侧白名单式构建:store/include/reasoning/prompt_cache_key/text/client_metadata
+ *   等 Chat 上游不认识的字段一律不发,宁可少发不发错。
+ * - 响应侧只产 codex 实际消费的事件:output_item.done 是唯一驱动历史与工具执行的通道,
+ *   function_call 的 arguments 必须整体出现在 done(0.155.1 忽略 arguments.delta),
+ *   response.completed 的 response.id 必填,缺 usage 只影响统计不影响结束。
+ * - tool_call_id 链路:Responses 的 call_id 原样作为 Chat 的 tool_calls[].id 与
+ *   role:"tool" 消息的 tool_call_id,codex 侧不透明字符串原样回传。
+ */
+
+export type DiagnosticFn = (message: string) => void;
+
+/** codex POST /v1/responses 的请求体(只声明翻译用得到的字段,其余按白名单丢弃) */
+export interface ResponsesCreateParams {
+  model?: unknown;
+  instructions?: unknown;
+  input?: unknown;
+  tools?: unknown;
+  tool_choice?: unknown;
+  parallel_tool_calls?: unknown;
+  max_output_tokens?: unknown;
+  temperature?: unknown;
+  top_p?: unknown;
+  stream?: unknown;
+  [key: string]: unknown;
+}
+
+export interface ChatToolCall {
+  id: string;
+  type: 'function';
+  function: { name: string; arguments: string };
+}
+
+export interface ChatMessage {
+  role: 'developer' | 'system' | 'user' | 'assistant' | 'tool';
+  content?: unknown;
+  tool_calls?: ChatToolCall[];
+  tool_call_id?: string;
+  name?: string;
+}
+
+export interface ChatCompletionRequest {
+  model: string;
+  messages: ChatMessage[];
+  stream: boolean;
+  stream_options?: { include_usage: boolean };
+  tools?: Array<{ type: 'function'; function: { name: string; description?: string; parameters?: unknown } }>;
+  tool_choice?: 'auto' | 'none' | 'required' | { type: 'function'; function: { name: string } };
+  parallel_tool_calls?: boolean;
+  max_tokens?: number;
+  temperature?: number;
+  top_p?: number;
+}
+
+interface ChatToolCallDelta {
+  index?: number;
+  id?: string;
+  function?: { name?: string; arguments?: string };
+}
+
+interface ChatChunkChoice {
+  delta?: { content?: string | null; reasoning_content?: string | null; tool_calls?: ChatToolCallDelta[] };
+  finish_reason?: string | null;
+}
+
+interface ChatChunk {
+  choices?: ChatChunkChoice[];
+  usage?: {
+    prompt_tokens?: number;
+    completion_tokens?: number;
+    total_tokens?: number;
+  } | null;
+}
+
+/** 翻译过程中丢弃的非空内容计数,进诊断,绝不静默有损 */
+export interface TranslateDiagnostics {
+  droppedInputItems: Map<string, number>;
+  droppedContentParts: Map<string, number>;
+  droppedTools: number;
+}
+
+function emptyDiagnostics(): TranslateDiagnostics {
+  return { droppedInputItems: new Map(), droppedContentParts: new Map(), droppedTools: 0 };
+}
+
+function bump(map: Map<string, number>, key: string): void {
+  map.set(key, (map.get(key) ?? 0) + 1);
+}
+
+function diagnosticsMessage(diagnostics: TranslateDiagnostics): string {
+  const parts: string[] = [];
+  for (const [key, count] of diagnostics.droppedInputItems) parts.push(`input.${key}×${count}`);
+  for (const [key, count] of diagnostics.droppedContentParts) parts.push(`content.${key}×${count}`);
+  if (diagnostics.droppedTools > 0) parts.push(`tools×${diagnostics.droppedTools}`);
+  return parts.join(', ');
+}
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return value !== null && typeof value === 'object' && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+}
+
+function asString(value: unknown): string | null {
+  return typeof value === 'string' ? value : null;
+}
+
+/** Responses 的 input_image → Chat image_url */
+function imageContentPart(part: Record<string, unknown>): ChatContentPart | null {
+  const url = asString(part.image_url) ?? asString(asRecord(part.image_url)?.url);
+  return url ? { type: 'image_url', image_url: { url } } : null;
+}
+
+type ChatContentPart = { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } };
+
+/** Responses message 的 content 数组 → Chat content(文本/图片;单文本压成字符串兼容性最好) */
+function translateMessageContent(parts: unknown[], diagnostics: TranslateDiagnostics): string | ChatContentPart[] {
+  const translated: ChatContentPart[] = [];
+  for (const part of parts) {
+    const record = asRecord(part);
+    const type = asString(record?.type);
+    if (type === 'input_text' || type === 'output_text') {
+      const text = asString(record?.text) ?? '';
+      translated.push({ type: 'text', text });
+    } else if (type === 'input_image') {
+      const image = imageContentPart(record ?? {});
+      if (image) translated.push(image);
+      else bump(diagnostics.droppedContentParts, 'input_image');
+    } else if (type !== null) {
+      bump(diagnostics.droppedContentParts, type ?? 'unknown');
+    }
+  }
+  const textsOnly = translated.every((part) => part.type === 'text');
+  if (textsOnly && translated.length <= 1) return (translated[0] as { text: string } | undefined)?.text ?? '';
+  return translated;
+}
+
+/** function_call_output.output:字符串或结构化数组,折叠成 Chat tool 消息的文本 content */
+function translateOutputValue(value: unknown, diagnostics: TranslateDiagnostics): string {
+  if (typeof value === 'string') return value;
+  if (Array.isArray(value)) {
+    const texts: string[] = [];
+    for (const part of value) {
+      const record = asRecord(part);
+      const type = asString(record?.type);
+      if (type === 'input_text' || type === 'output_text' || type === 'text') {
+        texts.push(asString(record?.text) ?? '');
+      } else {
+        bump(diagnostics.droppedContentParts, type ?? 'unknown');
+      }
+    }
+    return texts.join('\n');
+  }
+  if (value === null || value === undefined) return '';
+  return String(value);
+}
+
+/**
+ * Responses 请求体 → Chat Completions 请求体。
+ * stream 透传;stream:true 时补 stream_options.include_usage(不支持的端点会忽略)。
+ */
+export function responsesToChatRequest(
+  req: ResponsesCreateParams,
+  onDiagnostic?: DiagnosticFn,
+): ChatCompletionRequest {
+  const diagnostics = emptyDiagnostics();
+  const messages: ChatMessage[] = [];
+
+  const instructions = asString(req.instructions);
+  if (instructions) messages.push({ role: 'developer', content: instructions });
+
+  const input = req.input;
+  if (typeof input === 'string') {
+    messages.push({ role: 'user', content: input });
+  } else if (Array.isArray(input)) {
+    for (const raw of input) {
+      const item = asRecord(raw);
+      if (!item) continue;
+      const type = asString(item.type);
+      if (type === null || type === 'message') {
+        const role = asString(item.role) ?? 'user';
+        const content = Array.isArray(item.content)
+          ? translateMessageContent(item.content, diagnostics)
+          : asString(item.content) ?? '';
+        messages.push({ role: role === 'developer' ? 'developer' : role as ChatMessage['role'], content });
+        continue;
+      }
+      if (type === 'function_call' || type === 'custom_tool_call') {
+        const callId = asString(item.call_id) ?? '';
+        const name = asString(item.name) ?? '';
+        const args = type === 'custom_tool_call'
+          ? asString(item.input) ?? JSON.stringify(item.input ?? null)
+          : typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? null);
+        messages.push({
+          role: 'assistant',
+          content: null,
+          tool_calls: [{ id: callId, type: 'function', function: { name, arguments: args } }],
+        });
+        continue;
+      }
+      if (type === 'function_call_output' || type === 'custom_tool_call_output') {
+        messages.push({
+          role: 'tool',
+          tool_call_id: asString(item.call_id) ?? '',
+          content: translateOutputValue(item.output, diagnostics),
+        });
+        continue;
+      }
+      // reasoning / local_shell_call / web_search_call / item_reference 等对 Chat 上游无意义
+      bump(diagnostics.droppedInputItems, type ?? 'unknown');
+    }
+  }
+
+  const chat: ChatCompletionRequest = {
+    model: asString(req.model) ?? '',
+    messages,
+    stream: req.stream === true,
+  };
+  if (chat.stream) chat.stream_options = { include_usage: true };
+
+  const tools = Array.isArray(req.tools) ? req.tools : [];
+  const chatTools: ChatCompletionRequest['tools'] = [];
+  for (const raw of tools) {
+    const tool = asRecord(raw);
+    if (asString(tool?.type) !== 'function') {
+      diagnostics.droppedTools += 1;
+      continue;
+    }
+    const description = asString(tool?.description);
+    chatTools.push({
+      type: 'function',
+      function: {
+        name: asString(tool?.name) ?? '',
+        ...(description ? { description } : {}),
+        ...(tool?.parameters !== undefined ? { parameters: tool?.parameters } : {}),
+      },
+    });
+  }
+  if (chatTools.length) chat.tools = chatTools;
+
+  const toolChoice = req.tool_choice;
+  if (toolChoice === 'auto' || toolChoice === 'none' || toolChoice === 'required') {
+    chat.tool_choice = toolChoice;
+  } else if (asString(asRecord(toolChoice)?.type) === 'function' && asRecord(toolChoice)?.name) {
+    chat.tool_choice = { type: 'function', function: { name: asString(asRecord(toolChoice)?.name) ?? '' } };
+  } else if (toolChoice !== undefined && toolChoice !== null) {
+    bump(diagnostics.droppedInputItems, `tool_choice:${typeof toolChoice}`);
+  }
+
+  if (typeof req.parallel_tool_calls === 'boolean') chat.parallel_tool_calls = req.parallel_tool_calls;
+  if (typeof req.max_output_tokens === 'number') chat.max_tokens = req.max_output_tokens;
+  if (typeof req.temperature === 'number') chat.temperature = req.temperature;
+  if (typeof req.top_p === 'number') chat.top_p = req.top_p;
+
+  const summary = diagnosticsMessage(diagnostics);
+  if (summary && onDiagnostic) onDiagnostic(`有损转换丢弃:${summary}`);
+  return chat;
+}
+
+/** Chat 非流式响应 → Responses 形状(防御路径:codex 恒为 stream:true) */
+export function chatResponseToResponses(chat: Record<string, unknown>, responseId: string): Record<string, unknown> {
+  const diagnostics = emptyDiagnostics();
+  const choices = Array.isArray(chat.choices) ? chat.choices : [];
+  const choice = asRecord(choices[0]) ?? {};
+  const message = asRecord(choice.message) ?? {};
+  const finishReason = asString(choice.finish_reason);
+
+  const output: Array<Record<string, unknown>> = [];
+  const reasoning = asString(message.reasoning_content) ?? asString(message.reasoning);
+  if (reasoning) {
+    output.push({
+      type: 'reasoning',
+      id: 'rs_1',
+      summary: [{ type: 'summary_text', text: reasoning }],
+    });
+  }
+  const content = asString(message.content);
+  if (content) {
+    output.push({
+      type: 'message',
+      id: 'msg_1',
+      role: 'assistant',
+      status: 'completed',
+      content: [{ type: 'output_text', text: content }],
+    });
+  }
+  const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
+  let callIndex = 0;
+  for (const raw of toolCalls) {
+    const call = asRecord(raw);
+    const fn = asRecord(call?.function);
+    callIndex += 1;
+    output.push({
+      type: 'function_call',
+      id: `fc_${callIndex}`,
+      call_id: asString(call?.id) ?? `call_${callIndex}`,
+      name: asString(fn?.name) ?? '',
+      arguments: asString(fn?.arguments) ?? '',
+      status: 'completed',
+    });
+  }
+  if (!reasoning && !content && toolCalls.length === 0) {
+    bump(diagnostics.droppedInputItems, 'empty_choice');
+  }
+
+  const usage = asRecord(chat.usage);
+  const promptTokens = typeof usage?.prompt_tokens === 'number' ? usage.prompt_tokens : 0;
+  const completionTokens = typeof usage?.completion_tokens === 'number' ? usage.completion_tokens : 0;
+  const totalTokens = typeof usage?.total_tokens === 'number' ? usage.total_tokens : promptTokens + completionTokens;
+
+  const incomplete = finishReason === 'length';
+  return {
+    id: responseId,
+    object: 'response',
+    status: incomplete ? 'incomplete' : 'completed',
+    ...(incomplete ? { incomplete_details: { reason: 'max_output_tokens' } } : {}),
+    output,
+    usage: {
+      input_tokens: promptTokens,
+      input_tokens_details: { cached_tokens: 0 },
+      output_tokens: completionTokens,
+      output_tokens_details: { reasoning_tokens: 0 },
+      total_tokens: totalTokens,
+    },
+    parallel_tool_calls: typeof chat.parallel_tool_calls === 'boolean' ? chat.parallel_tool_calls : undefined,
+  };
+}
+
+export type BridgeSseFrame = string;
+
+interface OpenToolCall {
+  callId: string | null;
+  name: string;
+  arguments: string;
+  itemId: string;
+}
+
+function randomId(): string {
+  return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-6);
+}
+
+function sseFrame(type: string, data: Record<string, unknown>): string {
+  return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
+}
+
+/**
+ * 上游 Chat SSE → codex Responses SSE 的流式状态机。
+ * push() 吃任意分包的文本(内部按行缓冲),finish()/fail() 收尾且幂等。
+ * 一个响应内文本/推理/工具调用的 item 顺序:按上游到达顺序开闭,工具调用按 index 归拢后
+ * 在 finish(或出现更大 index)时补发完整 done —— arguments 必须整体出现。
+ */
+export class ChatSseTranslator {
+  readonly #responseId: string;
+  readonly #onDiagnostic?: DiagnosticFn;
+  #buffer = '';
+  #itemSeq = 0;
+  #closed = false;
+  #textState: { itemId: string; text: string } | null = null;
+  #reasoningState: { itemId: string; text: string } | null = null;
+  /** index → 归拢中的工具调用;出现更大 index 时先收口前面的 */
+  #toolCalls = new Map<number, OpenToolCall>();
+  #toolCallOrder: number[] = [];
+  #toolCallsDone = false;
+  #finishReason: string | null = null;
+  #usage: { input_tokens: number; output_tokens: number; total_tokens: number } | null = null;
+  #upstreamBytes = 0;
+  #emittedEvents = 0;
+
+  constructor(onDiagnostic?: DiagnosticFn) {
+    this.#onDiagnostic = onDiagnostic;
+    this.#responseId = `resp_${randomId()}`;
+  }
+
+  get responseId(): string {
+    return this.#responseId;
+  }
+
+  get upstreamBytes(): number {
+    return this.#upstreamBytes;
+  }
+
+  get emittedEvents(): number {
+    return this.#emittedEvents;
+  }
+
+  #frame(type: string, data: Record<string, unknown>): string {
+    this.#emittedEvents += 1;
+    return sseFrame(type, data);
+  }
+
+  #nextItemId(prefix: string): string {
+    this.#itemSeq += 1;
+    return `${prefix}_${this.#itemSeq}`;
+  }
+
+  /** 先建 SSE:不等上游响应头就发 created + in_progress,防单槽端点慢 prefill 掐线 */
+  begin(): string[] {
+    if (this.#closed) return [];
+    return [
+      this.#frame('response.created', { response: { id: this.#responseId } }),
+      this.#frame('response.in_progress', { response: { id: this.#responseId } }),
+    ];
+  }
+
+  /** 关闭当前打开的文本/推理 item,产出 done 帧 */
+  #closeText(): string[] {
+    if (!this.#textState) return [];
+    const { itemId, text } = this.#textState;
+    this.#textState = null;
+    return [
+      this.#frame('response.output_item.done', {
+        item: {
+          type: 'message',
+          id: itemId,
+          role: 'assistant',
+          status: 'completed',
+          content: [{ type: 'output_text', text }],
+        },
+      }),
+    ];
+  }
+
+  #closeReasoning(): string[] {
+    if (!this.#reasoningState) return [];
+    const { itemId, text } = this.#reasoningState;
+    this.#reasoningState = null;
+    return [
+      this.#frame('response.output_item.done', {
+        item: {
+          type: 'reasoning',
+          id: itemId,
+          summary: [{ type: 'summary_text', text }],
+        },
+      }),
+    ];
+  }
+
+  /** 收口所有归拢中的工具调用(按 index 序),产出完整 function_call done 帧 */
+  #closeToolCalls(): string[] {
+    if (this.#toolCallsDone) return [];
+    this.#toolCallsDone = true;
+    const frames: string[] = [];
+    for (const index of this.#toolCallOrder) {
+      const call = this.#toolCalls.get(index);
+      if (!call) continue;
+      frames.push(
+        this.#frame('response.output_item.done', {
+          item: {
+            type: 'function_call',
+            id: call.itemId,
+            call_id: call.callId ?? `call_${index}`,
+            name: call.name,
+            arguments: call.arguments,
+            status: 'completed',
+          },
+        }),
+      );
+    }
+    return frames;
+  }
+
+  push(chunk: string): string[] {
+    if (this.#closed) return [];
+    this.#upstreamBytes += Buffer.byteLength(chunk);
+    this.#buffer += chunk;
+    const frames: string[] = [];
+    let newlineIndex = this.#buffer.indexOf('\n');
+    while (newlineIndex >= 0) {
+      const line = this.#buffer.slice(0, newlineIndex).replace(/\r$/, '');
+      this.#buffer = this.#buffer.slice(newlineIndex + 1);
+      const payload = this.#dataPayload(line);
+      if (payload) frames.push(...this.#handleData(payload));
+      newlineIndex = this.#buffer.indexOf('\n');
+    }
+    return frames;
+  }
+
+  /** 提取 SSE data: 行的载荷;[DONE]/注释行/空行返回 null */
+  #dataPayload(line: string): string | null {
+    if (!line.startsWith('data:')) return null;
+    const payload = line.slice(5).trim();
+    if (!payload || payload === '[DONE]') return null;
+    return payload;
+  }
+
+  #handleData(payload: string): string[] {
+    let chunk: ChatChunk;
+    try {
+      chunk = JSON.parse(payload) as ChatChunk;
+    } catch {
+      // 坏 JSON 跳过不杀流
+      this.#onDiagnostic?.('上游 SSE 出现无法解析的 JSON 分片,已跳过');
+      return [];
+    }
+    const frames: string[] = [];
+    if (chunk.usage && typeof chunk.usage === 'object') {
+      const promptTokens = typeof chunk.usage.prompt_tokens === 'number' ? chunk.usage.prompt_tokens : 0;
+      const completionTokens = typeof chunk.usage.completion_tokens === 'number' ? chunk.usage.completion_tokens : 0;
+      const totalTokens = typeof chunk.usage.total_tokens === 'number'
+        ? chunk.usage.total_tokens
+        : promptTokens + completionTokens;
+      this.#usage = { input_tokens: promptTokens, output_tokens: completionTokens, total_tokens: totalTokens };
+    }
+
+    const choice = chunk.choices?.[0];
+    if (!choice) return frames;
+
+    if (choice.finish_reason) this.#finishReason = choice.finish_reason;
+    const delta = choice.delta;
+
+    if (delta?.tool_calls?.length) {
+      // 工具调用开跑:先收口打开的文本/推理 item
+      frames.push(...this.#closeText(), ...this.#closeReasoning());
+      for (const callDelta of delta.tool_calls) {
+        const index = typeof callDelta.index === 'number' ? callDelta.index : 0;
+        let call = this.#toolCalls.get(index);
+        if (!call) {
+          // 出现新 index:说明更小的 index 已收口完成
+          frames.push(...this.#closeEarlierToolCalls(index));
+          call = { callId: null, name: '', arguments: '', itemId: this.#nextItemId('fc') };
+          this.#toolCalls.set(index, call);
+          this.#toolCallOrder.push(index);
+        }
+        if (callDelta.id) call.callId = callDelta.id;
+        if (callDelta.function?.name) call.name += callDelta.function.name;
+        if (callDelta.function?.arguments) call.arguments += callDelta.function.arguments;
+      }
+    }
+
+    if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
+      if (this.#textState) frames.push(...this.#closeText());
+      if (!this.#reasoningState) {
+        this.#reasoningState = { itemId: this.#nextItemId('rs'), text: '' };
+        frames.push(
+          this.#frame('response.output_item.added', {
+            item: { type: 'reasoning', id: this.#reasoningState.itemId, summary: [] },
+          }),
+        );
+      }
+      this.#reasoningState.text += delta.reasoning_content;
+      frames.push(
+        this.#frame('response.reasoning_summary_text.delta', {
+          delta: delta.reasoning_content,
+          summary_index: 0,
+          item_id: this.#reasoningState.itemId,
+        }),
+      );
+    }
+
+    if (typeof delta?.content === 'string' && delta.content.length > 0) {
+      if (this.#reasoningState) frames.push(...this.#closeReasoning());
+      if (!this.#textState) {
+        this.#textState = { itemId: this.#nextItemId('msg'), text: '' };
+        frames.push(
+          this.#frame('response.output_item.added', {
+            item: { type: 'message', id: this.#textState.itemId, role: 'assistant', content: [] },
+          }),
+        );
+      }
+      this.#textState.text += delta.content;
+      frames.push(this.#frame('response.output_text.delta', { delta: delta.content, item_id: this.#textState.itemId }));
+    }
+
+    return frames;
+  }
+
+  /** 出现 index N 时收口所有 < N 的工具调用 */
+  #closeEarlierToolCalls(index: number): string[] {
+    const frames: string[] = [];
+    for (const earlier of this.#toolCallOrder) {
+      if (earlier >= index) break;
+      const call = this.#toolCalls.get(earlier);
+      if (!call) continue;
+      frames.push(
+        this.#frame('response.output_item.done', {
+          item: {
+            type: 'function_call',
+            id: call.itemId,
+            call_id: call.callId ?? `call_${earlier}`,
+            name: call.name,
+            arguments: call.arguments,
+            status: 'completed',
+          },
+        }),
+      );
+      this.#toolCalls.delete(earlier);
+    }
+    return frames;
+  }
+
+  /** [DONE]/上游结束:收口全部 item 并补 response.completed。幂等 */
+  finish(): string[] {
+    if (this.#closed) return [];
+    this.#closed = true;
+    const frames: string[] = [
+      ...this.#closeReasoning(),
+      ...this.#closeText(),
+      ...this.#closeToolCalls(),
+    ];
+    const incomplete = this.#finishReason === 'length';
+    frames.push(
+      this.#frame('response.completed', {
+        response: {
+          id: this.#responseId,
+          status: incomplete ? 'incomplete' : 'completed',
+          ...(incomplete ? { incomplete_details: { reason: 'max_output_tokens' } } : {}),
+          ...(this.#usage ? { usage: {
+            input_tokens: this.#usage.input_tokens,
+            input_tokens_details: { cached_tokens: 0 },
+            output_tokens: this.#usage.output_tokens,
+            output_tokens_details: { reasoning_tokens: 0 },
+            total_tokens: this.#usage.total_tokens,
+          } } : {}),
+        },
+      }),
+    );
+    return frames;
+  }
+
+  /** 上游失败:补 response.failed 让 codex 走错误映射。幂等 */
+  fail(message: string): string[] {
+    if (this.#closed) return [];
+    this.#closed = true;
+    return [
+      this.#frame('response.failed', {
+        response: {
+          id: this.#responseId,
+          error: { code: 'upstream_error', message },
+        },
+      }),
+    ];
+  }
+}

+ 5 - 0
ai-electron/electron/service/codex/index.ts

@@ -4,6 +4,7 @@ import { getMainWindow } from 'ee-core/electron';
 import { logger } from 'ee-core/log';
 import { ApprovalBroker } from './approvalBroker';
 import { ensureCodexHome, getCodexDataDir, getCodexHome, getEventsDir } from './codexHome';
+import { chatRouter } from './chatRouter/routerService';
 import { CodexRuntime, type RuntimeStatus } from './codexRuntime';
 import { EventLog } from './eventLog';
 import { notificationToEvent } from './eventMapper';
@@ -69,6 +70,8 @@ export async function getCodex(): Promise<CodexContainer> {
   if (creating) return creating;
   creating = (async () => {
     await ensureCodexHome();
+    // 路由层诊断走同一环形缓冲 + ee.log + IPC 推送
+    chatRouter.setDiagnosticSink(pushDiagnostic);
     const runtime = new CodexRuntime({ codexHome: getCodexHome() });
     const broker = new ApprovalBroker(runtime);
     const eventLog = new EventLog(getEventsDir());
@@ -138,6 +141,7 @@ export function toStatusResult(status: RuntimeStatus): CodexStatusResult {
     degraded: status.degraded,
     error: status.error,
     applied: appliedCache,
+    router: chatRouter.info(),
   };
 }
 
@@ -156,6 +160,7 @@ export async function defaultWorkspaceDir(): Promise<string> {
 export async function disposeCodex(): Promise<void> {
   const current = container;
   container = null;
+  await chatRouter.stop();
   if (!current) return;
   current.broker.closeAll();
   await current.eventLog.flush().catch(() => undefined);

+ 39 - 9
ai-electron/electron/service/codex/providerService.test.ts

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
 import type { CodexProviderSpec, CodexRuntime } from './codexRuntime';
+import { chatRouter } from './chatRouter/routerService';
 import {
   applyProvider,
   baseUrlHint,
@@ -176,13 +177,13 @@ describe('probeEndpoint', () => {
     }
   });
 
-  it('没有 Responses 但有 Chat → chat-only,并说明本客户端不做桥接', async () => {
+  it('没有 Responses 但有 Chat → chat-only,说明将由内置路由层桥接对接', async () => {
     responsesStatus = 404;
     chatStatus = 400;
     const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
     expect(result.protocol).toBe('chat-only');
     expect(result.detail).toMatch(/没有 \/responses/);
-    expect(result.detail).toMatch(/不做桥接/);
+    expect(result.detail).toMatch(/路由层/);
   });
 
   it('两条路由都没有 → unsupported', async () => {
@@ -237,8 +238,7 @@ describe('applyProvider 交给运行时的 spec', () => {
       stream_max_retries: 0,
       stream_idle_timeout_ms: 1_800_000,
     });
-    expect(applied).toMatchObject({ model: 'qwen', modelRecordId: '7', baseUrl: upstream });
-    expect(applied).not.toHaveProperty('bridged');
+    expect(applied).toMatchObject({ model: 'qwen', modelRecordId: '7', baseUrl: upstream, bridged: false });
   });
 
   it('落盘 model-catalog.json 并把路径挂到 spec,目录条目带 slug 与 instructions_template', async () => {
@@ -268,14 +268,44 @@ describe('applyProvider 交给运行时的 spec', () => {
     expect(entry.model_messages.instructions_template.length).toBeGreaterThan(1000);
   });
 
-  it('端点只会 Chat 时直接报错,且不碰运行时', async () => {
+  it('端点只会 Chat 时起路由层桥接:spec 指向本地路由,落盘保留上游真实地址', async () => {
     responsesStatus = 404;
     chatStatus = 400;
     const stub = new StubRuntime();
+    const upstream = `http://127.0.0.1:${port}/v1`;
+    const applied = await applyProvider(stub as unknown as CodexRuntime, {
+      modelId: 'qwen',
+      baseUrl: upstream,
+      modelRecordId: 9,
+    });
+    const spec = stub.spec;
+    expect(spec).not.toBeNull();
+    // codex 的 base_url 是本地路由地址(127.0.0.1 随机端口 + /v1),绝不是上游地址
+    expect(spec?.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1$/u);
+    expect(spec?.baseUrl).not.toBe(upstream);
+    // 落盘快照保存真实上游地址与 bridged 标记,供页面与 modelApplied 比对
+    expect(applied.bridged).toBe(true);
+    expect(applied.baseUrl).toBe(upstream);
+    // 目录文件的 slug 仍是上游模型 id,桥接不改变模型名
+    expect(stub.spec?.modelCatalogPath).toBeTruthy();
+  });
+
+  it('路由层桥接应用失败时回滚停掉路由', async () => {
+    responsesStatus = 404;
+    chatStatus = 400;
+    class FailingRuntime {
+      calls = 0;
+      async applyProvider(): Promise<void> {
+        this.calls += 1;
+        throw new Error('runtime boom');
+      }
+    }
     await expect(
-      applyProvider(stub as unknown as CodexRuntime, { modelId: 'qwen', baseUrl: `http://127.0.0.1:${port}/v1` }),
-    ).rejects.toThrow(/没有 \/responses/);
-    expect(stub.calls).toBe(0);
-    expect(stub.spec).toBeUndefined();
+      applyProvider(new FailingRuntime() as unknown as CodexRuntime, {
+        modelId: 'qwen',
+        baseUrl: `http://127.0.0.1:${port}/v1`,
+      }),
+    ).rejects.toThrow('runtime boom');
+    expect(chatRouter.running).toBe(false);
   });
 });

+ 44 - 13
ai-electron/electron/service/codex/providerService.ts

@@ -1,7 +1,8 @@
 import { mkdir, readFile, writeFile } from 'node:fs/promises';
-import { join } from 'node:path';
+import { dirname, join, resolve } from 'node:path';
 import { getCodexDataDir } from './codexHome';
 import { DEFAULT_MODEL_INSTRUCTIONS } from './defaultModelInstructions';
+import { chatRouter } from './chatRouter/routerService';
 import { DEFAULT_API_KEY_ENV, PROVIDER_EXTRA_ALLOWLIST, type CodexProviderSpec, type CodexRuntime, type JsonValue } from './codexRuntime';
 import type { AppliedProviderInfo, ApplyProviderInput } from './types';
 
@@ -9,6 +10,7 @@ import type { AppliedProviderInfo, ApplyProviderInput } from './types';
  * 把后端「模型管理」里的一条记录翻译成 Codex 的 model provider。
  * 全程不做任何 Codex/OpenAI 账号登录:靠 wire_api="responses" + requires_openai_auth=false + env_key。
  * apiKey 只存在于内存与子进程环境变量,落盘的 AppliedProvider 不含任何密钥。
+ * 端点只会 Chat 协议时走内置路由层(chatRouter)桥接,对 codex 仍呈现为 Responses 端点。
  */
 
 export const PROVIDER_ID = 'zsjz';
@@ -17,8 +19,8 @@ export type AppliedProvider = AppliedProviderInfo;
 
 /**
  * 端点协议判定。Codex 0.155.1 起 `wire_api="chat"` 被删掉(实测 0.146/0.151/0.155 三个版本
- * 都在加载 config.toml 时就报 "wire_api = \"chat\" is no longer supported"),客户端也不做
- * Responses↔Chat 翻译 —— 只会 Chat 的端点一律拒绝应用,把原因说清楚。
+ * 都在加载 config.toml 时就报 "wire_api = \"chat\" is no longer supported"),因此对 codex
+ * 永远说 Responses;只会 Chat 的端点由内置路由层翻译后对接,应用时不再拒绝。
  */
 export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
 
@@ -234,7 +236,8 @@ export async function probeEndpoint(params: {
     });
   }
 
-  // /responses 不在:区分「只会 Chat」和「两条路由都没有」,前者的话要说得能让人去修端点
+  // /responses 不在:区分「只会 Chat」和「两条路由都没有」。前者由内置路由层桥接:
+  // codex 仍说 Responses(0.155.1 已删 wire_api="chat"),路由层翻成 Chat 转给上游
   const chat = await postProbe(chatUrl, { model }, params.apiKey);
   if (chat.status !== null && chat.status !== 404 && chat.status !== 405) {
     return probeResult({
@@ -242,9 +245,8 @@ export async function probeEndpoint(params: {
       responsesStatus: responses.status,
       chatStatus: chat.status,
       detail:
-        `端点没有 /responses(HTTP ${responses.status}),只有 /chat/completions。` +
-        'Codex 0.155.1 已删除 wire_api="chat"(实测 0.146/0.151/0.155 一致),本客户端不做桥接。' +
-        `请把端点换成会回答 POST ${responsesUrl} 的服务(自证:curl -X POST ${responsesUrl} -d '{"model":"${model}"}')`,
+        `端点没有 /responses(HTTP ${responses.status}),只有 /chat/completions(HTTP ${chat.status})。` +
+        `应用后 Codex 将经内置路由层对接:${chatUrl}`,
     });
   }
   return probeResult({
@@ -313,7 +315,9 @@ function buildModelCatalog(spec: CodexProviderSpec): Record<string, unknown> {
 }
 
 function catalogFile(): string {
-  return join(getCodexDataDir(), 'model-catalog.json');
+  // codex 的 model_catalog_json 要求是绝对路径(AbsolutePathBuf),ee-core 的数据目录
+  // 在某些环境(vitest/未初始化 app)下会给出相对路径,这里统一 resolve 掉
+  return resolve(getCodexDataDir(), 'model-catalog.json');
 }
 
 /**
@@ -322,12 +326,16 @@ function catalogFile(): string {
  */
 async function writeModelCatalog(spec: CodexProviderSpec): Promise<string> {
   const path = catalogFile();
-  await mkdir(getCodexDataDir(), { recursive: true });
+  await mkdir(dirname(path), { recursive: true });
   await writeFile(path, JSON.stringify(buildModelCatalog(spec), null, 2), 'utf8');
   return path;
 }
 
-/** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */
+/**
+ * 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启)。
+ * 端点会 Responses → 直连;只会 Chat → 起内置路由层,codex 的 base_url 指向
+ * 本地路由(真实上游地址只进路由层与落盘快照,绝不进 codex 参数)。
+ */
 export async function applyProvider(
   runtime: CodexRuntime,
   input: ApplyProviderInput & { modelRecordId?: string | number | null },
@@ -340,20 +348,42 @@ export async function applyProvider(
     modelId: spec.model,
     apiKey: spec.apiKey,
   });
-  if (probe.protocol !== 'responses') throw new Error(probe.detail);
+  if (probe.protocol !== 'responses' && probe.protocol !== 'chat-only') throw new Error(probe.detail);
+  const upstreamBaseUrl = spec.baseUrl;
+
+  if (probe.protocol === 'chat-only') {
+    const router = await chatRouter.start({
+      upstreamBaseUrl: spec.baseUrl,
+      // 自定义头(headersJson)已随 spec.httpHeaders 进 codex 参数,codex 每次请求都会带上
+      // 并到达路由层,这里只需给出白名单名单
+      forwardHeaderNames: Object.keys(spec.httpHeaders ?? {}),
+    });
+    // codex 拿到的 base_url 是本地路由;真实上游地址保留在 applied 落盘里
+    spec.baseUrl = router.url;
+  } else {
+    // 从桥接模型切回直连模型:旧路由不再需要,停掉以免状态面板报陈旧路由
+    await chatRouter.stop();
+  }
 
   spec.modelCatalogPath = await writeModelCatalog(spec);
-  await runtime.applyProvider(spec);
+  try {
+    await runtime.applyProvider(spec);
+  } catch (error) {
+    if (probe.protocol === 'chat-only') await chatRouter.stop();
+    throw error;
+  }
 
   const applied: AppliedProvider = {
     model: spec.model,
     modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null
       ? null
       : String(input.modelRecordId),
-    baseUrl: spec.baseUrl,
+    // 落盘的是真实上游地址(与模型管理记录一致),路由地址不落盘
+    baseUrl: upstreamBaseUrl,
     providerId: spec.id,
     name: spec.name ?? null,
     appliedAt: new Date().toISOString(),
+    bridged: probe.protocol === 'chat-only',
   };
   await writeAppliedProvider(applied);
   return applied;
@@ -361,5 +391,6 @@ export async function applyProvider(
 
 export async function clearProvider(runtime: CodexRuntime): Promise<void> {
   await runtime.applyProvider(null);
+  await chatRouter.stop();
   await writeAppliedProvider(null);
 }

+ 5 - 0
ai-electron/electron/service/codex/types.ts

@@ -71,15 +71,20 @@ export interface CodexStatusResult {
   error: string | null;
   /** 上次应用的模型(不含密钥);重启后需要页面重新应用才能注入 api key */
   applied: AppliedProviderInfo | null;
+  /** 本地路由层状态:端点只会 Chat 协议时启用,applied.bridged=true 时非空 */
+  router: { running: boolean; upstream: string } | null;
 }
 
 export interface AppliedProviderInfo {
   model: string;
   modelRecordId: string | null;
+  /** 真实上游地址(路由层启用时也不是路由地址,路由地址只进子进程参数) */
   baseUrl: string | null;
   providerId: string | null;
   name: string | null;
   appliedAt: string;
+  /** true = codex 经内置路由层对接(端点只会 Chat 协议) */
+  bridged?: boolean;
 }
 
 /** 主进程 → 渲染进程的事件推送 channel */

+ 18 - 8
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -159,8 +159,9 @@
       <a-tab-pane key="runtime" tab="Codex 运行时">
         <div class="ai-plugin__hint">
           本客户端<b>不做任何 Codex / OpenAI 账号登录</b>:模型来自后端「模型管理」, 选中后由主进程写入本机 Codex
-          配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 只会发 Responses 协议,
-          端点必须能回答 <b>POST {base}/responses</b>;客户端<b>不做协议桥接</b>,只会 Chat 的端点会在「应用」时被拒绝。
+          配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 只会发 Responses 协议;
+          端点支持 <b>POST {base}/responses</b> 时直连,只会 Chat Completions 的端点(如 DeepSeek)应用后
+          <b>自动经内置路由层桥接对接</b>。
         </div>
 
         <div class="ai-plugin__panel">
@@ -178,6 +179,12 @@
             <b class="ai-plugin__mono ai-plugin__ellipsis" :title="status?.codexHome">
               {{ status?.codexHome || '—' }}
             </b>
+            <template v-if="status?.router?.running">
+              <span>路由层</span>
+              <b class="ai-plugin__mono ai-plugin__ellipsis" :title="status.router.upstream">
+                桥接 → {{ status.router.upstream }}
+              </b>
+            </template>
           </div>
           <a-alert
             v-if="status?.degraded && status?.running"
@@ -509,18 +516,21 @@
 
   const selectedModel = computed(() => models.value.find((item) => item.id === selectedModelId.value) || null);
 
-  /** 探测结果:responses 可用(绿)/ 其余三种都不能对接(红) */
-  const probeAlertType = computed<'success' | 'error'>(() =>
-    probeResult.value?.protocol === 'responses' ? 'success' : 'error',
-  );
+  /** 探测结果:responses 直连(绿)/ chat-only 走内置路由层(蓝)/ 其余不能对接(红) */
+  const probeAlertType = computed<'success' | 'info' | 'error'>(() => {
+    const protocol = probeResult.value?.protocol;
+    if (protocol === 'responses') return 'success';
+    if (protocol === 'chat-only') return 'info';
+    return 'error';
+  });
   const probeAlertTitle = computed(() => {
     switch (probeResult.value?.protocol) {
       case 'responses':
         return '端点可用(Responses 协议,Codex 直连)';
       case 'chat-only':
-        return '端点只会 Chat 协议,客户端不做协议桥接,无法对接';
+        return '端点只会 Chat 协议,应用后将经内置路由层桥接对接';
       case 'unsupported':
-        return '端点没有 Responses 协议路由,无法对接';
+        return '端点没有 Responses 协议路由,也无法按 Chat 对接,无法使用';
       default:
         return '端点不可达';
     }

+ 7 - 2
ai-electron/frontend/src/codex/api/codexApi.ts

@@ -98,6 +98,8 @@ export interface AppliedProviderInfo {
   providerId: string | null;
   name: string | null;
   appliedAt: string;
+  /** true = codex 经内置路由层对接(端点只会 Chat 协议) */
+  bridged?: boolean;
 }
 
 export interface CodexStatus {
@@ -111,6 +113,8 @@ export interface CodexStatus {
   degraded: boolean;
   error: string | null;
   applied: AppliedProviderInfo | null;
+  /** 本地路由层状态:端点只会 Chat 协议时启用 */
+  router: { running: boolean; upstream: string } | null;
 }
 
 export function codexPing(): Promise<CodexPingResult> {
@@ -147,8 +151,9 @@ export interface ApplyProviderInput {
 }
 
 /**
- * 端点判定:responses → Codex 直连;chat-only → 只会 /chat/completions,本客户端不做协议桥接,拒绝应用;
- * unsupported → 两条路由都没有;unreachable → 网络不可达
+ * 端点判定:responses → Codex 直连;chat-only → 只会 /chat/completions,应用时自动经
+ * 内置路由层桥接对接(Codex 仍说 Responses);unsupported → 两条路由都没有;
+ * unreachable → 网络不可达
  */
 export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';