Преглед изворни кода

refactor(codex): 删除内置 Responses→Chat 桥接,端点按 /responses 判定并放宽空闲窗口

照 Noobi.ai:通信方式仍是 app-server 的 stdio JSONL,客户端不做任何协议翻译。
Codex 0.146/0.151/0.155 实测都在加载 config.toml 时就拒绝 wire_api="chat",
因此端点必须自己会说 /v1/responses,只会 Chat 的端点在「应用」时被拒绝。

- 删桥接:chatBridgeService / chatBridgeTranslate 及 4 个测试,连同 bridged、
  status.bridge 等全链路字段与页面文案一并清掉
- probeEndpoint 改判 /responses,四态 responses|chat-only|unsupported|unreachable,
  报错带地址、状态码与 curl 自证命令
- provider 参数按源码语义修正:stream_idle_timeout_ms 由 300_000 放宽到 1_800_000
  (300_000 恰等于 Codex 默认值,属空操作),删掉同为 serde 默认的 supports_websockets=false;
  providerService 与 codexRuntime 两处重复白名单合并成同一导出常量,补真实键 websocket_connect_timeout_ms
- 诊断行加 layer=rpc|turn 前缀;映射 thread/tokenUsage/updated 成「上下文用量」一行

根因(codexIdle.itest.ts 三条实测钉住):流建立后 Codex 每次轮询都套
timeout(stream_idle_timeout_ms, stream.next()),默认 300 秒,到期抛可重试的
CodexErr::Stream —— 单槽端点几分钟的 prefill 必撞这把刀。代码里原写的
「Codex 默认每 15 秒掐一次流并重试 5 次」是误判:15 秒只是 websocket_connect_timeout_ms,
且用 -c 声明的 provider 其 supports_websockets 恒为 false,根本不走 WebSocket。

验证:vitest 95 passed;真二进制 smoke 31 passed(含 codexIdle 三条对照:不发头静默 30 秒不判死、
先发头再静默 5 秒窗口判死且上游只被问 1 次、窗口 120 秒则正常完成);tsc --noEmit 0 报错;
vue-tsc 93 条与基线一致、src/codex 与 src/ai 命中 0;npm run smoke:ipc 28/28。
未跑打包;未在真实端点验证(10.66.66.66:8080 当前不可达,是否支持 /responses 待端点升级后自证)。
cc пре 1 недеља
родитељ
комит
70ddb463bc

+ 0 - 114
ai-electron/electron/service/codex/chatBridge.itest.ts

@@ -1,114 +0,0 @@
-import { createServer, type Server } from 'node:http';
-import { mkdtemp, rm } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-import { chatBridge } from './chatBridgeService';
-import { ensureCodexHome } from './codexHome';
-import { CodexRuntime } from './codexRuntime';
-import { applyProvider, readAppliedProvider } from './providerService';
-
-/**
- * 桥接集成冒烟:mock Chat 上游(/chat/completions 返回 SSE 固定文本,其余路由 404)
- * → 内置桥接 → 真实 codex app-server 跑完整 turn。
- * 同时验证「Codex 无状态调用」假设:第二轮 turn 的 messages 里必须携带第一轮内容。
- * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。
- */
-
-let upstream: Server;
-let upstreamPort = 0;
-let lastChatBody: Record<string, unknown> | null = null;
-let tmpRoot: string;
-
-const REPLY = '桥接冒烟固定回复';
-
-const SSE_BODY = [
-  `data: ${JSON.stringify({ choices: [{ delta: { role: 'assistant', content: '' }, finish_reason: null }] })}`,
-  '',
-  `data: ${JSON.stringify({ choices: [{ delta: { content: REPLY }, finish_reason: null }] })}`,
-  '',
-  `data: ${JSON.stringify({
-    choices: [{ delta: {}, finish_reason: 'stop' }],
-    usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
-  })}`,
-  '',
-  'data: [DONE]',
-  '',
-  '',
-].join('\n');
-
-beforeAll(async () => {
-  upstream = createServer((req, res) => {
-    if (req.url?.endsWith('/chat/completions')) {
-      const chunks: Buffer[] = [];
-      req.on('data', (chunk: Buffer) => chunks.push(chunk));
-      req.on('end', () => {
-        lastChatBody = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null');
-        res.writeHead(200, { 'content-type': 'text/event-stream' });
-        res.end(SSE_BODY);
-      });
-      return;
-    }
-    // 除 Chat 外的路由一律 404(探活的 /models 也算活着,不影响判定)
-    req.resume();
-    res.writeHead(404, { 'content-type': 'application/json' });
-    res.end('{}');
-  });
-  await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
-  const address = upstream.address();
-  upstreamPort = typeof address === 'object' && address ? address.port : 0;
-  tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-codex-bridge-'));
-});
-
-afterAll(async () => {
-  await chatBridge.stop();
-  await new Promise<void>((resolve) => upstream?.close(() => resolve()));
-  await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
-});
-
-describe('内置桥接:Chat 端点接入', () => {
-  it('桥接应用成功,真实 Codex 经桥接跑通两轮 turn(验证无状态全量历史)', async () => {
-    const codexHome = await ensureCodexHome();
-    const runtime = new CodexRuntime({ codexHome });
-    try {
-      const applied = await applyProvider(runtime, {
-        modelId: 'bridge-smoke-model',
-        name: '桥接冒烟',
-        baseUrl: `http://127.0.0.1:${upstreamPort}/v1`,
-      });
-      expect(applied.bridged).toBe(true);
-      // 落盘存上游真实地址;桥接的本地 URL 不落盘
-      expect(applied.baseUrl).toBe(`http://127.0.0.1:${upstreamPort}/v1`);
-      expect(chatBridge.info).not.toBeNull();
-      expect(JSON.stringify(await readAppliedProvider())).not.toContain(chatBridge.info!.url);
-
-      // 生效配置:必须是自定义 zsjz provider 指向桥接地址(不能残留内置 provider 绕过桥接)
-      const config = await runtime.readConfig();
-      expect(config.model_provider).toBe('zsjz');
-      const entry = (config.model_providers as Record<string, Record<string, unknown>> | undefined)?.zsjz;
-      expect(entry?.base_url).toBe(chatBridge.info!.url);
-      expect(entry?.wire_api).toBe('responses');
-
-      // 第一轮:Codex → 桥接 → mock 上游 SSE → 翻译回 Responses 流
-      const threadId = await runtime.startThread({ cwd: tmpRoot });
-      const first = await runtime.runTurn({ threadId, prompt: '第一句话', timeoutMs: 120_000 });
-      expect(first.status).toBe('completed');
-      expect(first.text).toContain(REPLY);
-      expect(lastChatBody?.model).toBe('bridge-smoke-model');
-      expect(lastChatBody?.stream).toBe(true);
-      expect(JSON.stringify(lastChatBody?.messages)).toContain('第一句话');
-
-      // 第二轮:上游没有状态(没有 previous_response_id 可用),
-      // Codex 必须把第一轮内容一并放进 messages —— 这是 Ollama 非状态化兼容的根基
-      const second = await runtime.runTurn({ threadId, prompt: '第二句话', timeoutMs: 120_000 });
-      expect(second.status).toBe('completed');
-      const history = JSON.stringify(lastChatBody?.messages);
-      expect(history).toContain('第一句话');
-      expect(history).toContain(REPLY); // 第一轮 assistant 回复也被回放
-      expect(history).toContain('第二句话');
-    } finally {
-      await runtime.dispose();
-      await chatBridge.stop();
-    }
-  }, 300_000);
-});

+ 0 - 96
ai-electron/electron/service/codex/chatBridge.live.itest.ts

@@ -1,96 +0,0 @@
-import { mkdtemp, rm } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-import { chatBridge } from './chatBridgeService';
-import { CodexRuntime, type CodexProviderSpec } from './codexRuntime';
-
-/**
- * 真机复现(集成冒烟,不进 npm test):生产同一条链路打真实自部署端点。
- *   chatBridge(Responses→Chat 翻译) → 真实 codex app-server → 局域网 llama.cpp
- *
- * 存在的理由:客户端日志里每 ~15 秒一条 `request timed out` 重试、5 次后回落 HTTP,
- * 而桥接/超时/WebSocket/排队四个假设都用受控 mock 否掉了 —— 剩下的只能在这台真端点上验。
- * 端点不可达时整组跳过,不让 smoke 变成网络探测。
- *
- * 断言的是「健康的一轮应当是什么样」:上游只被问一次、Codex 一条重试都不产生、能拿到回复。
- *
- * 跑法:npm run smoke:codex -- electron/service/codex/chatBridge.live.itest.ts
- */
-
-const UPSTREAM_BASE = process.env.ZSJZ_LIVE_UPSTREAM ?? 'http://10.66.66.66:8080/v1';
-const MODEL = process.env.ZSJZ_LIVE_MODEL ?? '/home/rdd/llama/model/Ornith-1.5-35B-Q5_K_M.gguf';
-const HEALTH_URL = UPSTREAM_BASE.replace(/\/v\d+$/u, '') + '/health';
-
-let home = '';
-let cwd = '';
-const bridgeLines: string[] = [];
-const codexLines: string[] = [];
-
-async function probe(url: string, init?: RequestInit): Promise<{ ok: boolean; ms: number; status: number | null; error: string }> {
-  const startedAt = Date.now();
-  try {
-    const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5_000) });
-    return { ok: res.ok, ms: Date.now() - startedAt, status: res.status, error: '' };
-  } catch (error) {
-    return { ok: false, ms: Date.now() - startedAt, status: null, error: error instanceof Error ? error.message : String(error) };
-  }
-}
-
-/**
- * 可达性必须在模块顶层测:describe.skipIf 在收集阶段就求值,
- * 放进 beforeAll 里会永远是初始值、整组被静默跳过。
- */
-const health = await probe(HEALTH_URL);
-const reachable = health.ok;
-
-beforeAll(async () => {
-  home = await mkdtemp(join(tmpdir(), 'zsjz-live-home-'));
-  cwd = await mkdtemp(join(tmpdir(), 'zsjz-live-cwd-'));
-  chatBridge.setDiagnostic((line) => bridgeLines.push(line));
-});
-
-afterAll(async () => {
-  await chatBridge.stop().catch(() => undefined);
-  await rm(home, { recursive: true, force: true }).catch(() => undefined);
-  await rm(cwd, { recursive: true, force: true }).catch(() => undefined);
-});
-
-const retryLines = (): string[] => codexLines.filter((line) => /retrying sampling request/u.test(line));
-const disconnectLines = (): string[] => bridgeLines.filter((line) => /提前断开|转发完成|上游 HTTP|不可达/u.test(line));
-
-describe.skipIf(!reachable)('真机:自部署 Chat 端点跑一整轮', () => {
-  it('上游只被问一次、Codex 不重试、拿到回复', async () => {
-    const bridge = await chatBridge.start({ upstreamBaseUrl: UPSTREAM_BASE, headers: null });
-    const spec: CodexProviderSpec = { id: 'zsjz', name: '真机冒烟', baseUrl: bridge.url, model: MODEL };
-    const runtime = new CodexRuntime({ codexHome: home });
-    runtime.on('diagnostic', (line: string) => codexLines.push(line));
-
-    const startedAt = Date.now();
-    let text = '';
-    let failure = '';
-    try {
-      await runtime.applyProvider(spec);
-      const threadId = await runtime.startThread({ cwd });
-      const result = await runtime.runTurn({ threadId, prompt: '用一句话说明什么是资金流向分析', cwd, timeoutMs: 420_000 });
-      text = result.text;
-    } catch (error) {
-      failure = error instanceof Error ? error.message : String(error);
-    } finally {
-      await runtime.dispose();
-    }
-    const elapsed = (Date.now() - startedAt) / 1000;
-
-    console.log(`[真机] 耗时=${elapsed.toFixed(1)}s 失败=${failure || '无'}`);
-    console.log(`[真机] 回复=${text.slice(0, 80)}`);
-    console.log(`[真机] Codex 重试行=${retryLines().length}`);
-    console.log('[真机] 桥接:\n  ' + bridgeLines.join('\n  '));
-    console.log('[真机] Codex 关键诊断:\n  ' + disconnectLines().join('\n  '));
-
-    expect(failure).toBe('');
-    expect(text.length).toBeGreaterThan(0);
-    expect(retryLines().length).toBe(0);
-    // 一次正常的轮次不该让桥接把同一份请求重发给上游
-    expect(bridgeLines.filter((line) => /起 round/u.test(line)).length).toBe(1);
-  }, 480_000);
-});

+ 0 - 284
ai-electron/electron/service/codex/chatBridgeService.test.ts

@@ -1,284 +0,0 @@
-import { createServer, type Server } from 'node:http';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { ChatBridgeService } from './chatBridgeService';
-
-/** mock 上游:记录最近一次 /chat/completions 请求,按配置返回非流式或 SSE 流式 */
-let upstream: Server;
-let upstreamPort = 0;
-let lastUpstream: { url: string | null; auth: string | null; extra: string | null; body: Record<string, unknown> | null };
-let upstreamMode: 'json' | 'sse' | 'error' = 'json';
-
-const SSE_BODY = [
-  `data: ${JSON.stringify({ choices: [{ delta: { content: '你好' }, finish_reason: null }] })}`,
-  '',
-  `data: ${JSON.stringify({ choices: [{ delta: { content: '世界' }, finish_reason: null }] })}`,
-  '',
-  `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 } })}`,
-  '',
-  'data: [DONE]',
-  '',
-  '',
-].join('\n');
-
-beforeEach(async () => {
-  upstreamMode = 'json';
-  lastUpstream = { url: null, auth: null, extra: null, body: null };
-  upstream = createServer((req, res) => {
-    const chunks: Buffer[] = [];
-    req.on('data', (chunk: Buffer) => chunks.push(chunk));
-    req.on('end', () => {
-      lastUpstream = {
-        url: req.url ?? null,
-        auth: req.headers.authorization ?? null,
-        extra: (req.headers['x-extra'] as string) ?? null,
-        body: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null'),
-      };
-      if (upstreamMode === 'error') {
-        res.writeHead(500, { 'content-type': 'application/json' });
-        res.end(JSON.stringify({ error: 'boom' }));
-        return;
-      }
-      if (upstreamMode === 'sse') {
-        res.writeHead(200, { 'content-type': 'text/event-stream' });
-        res.end(SSE_BODY);
-        return;
-      }
-      res.writeHead(200, { 'content-type': 'application/json' });
-      res.end(
-        JSON.stringify({
-          id: 'chatcmpl-1',
-          created: 1700000000,
-          model: 'qwen',
-          choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: '非流式回复' } }],
-          usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
-        }),
-      );
-    });
-  });
-  await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
-  const address = upstream.address();
-  upstreamPort = typeof address === 'object' && address ? address.port : 0;
-});
-
-afterEach(async () => {
-  await new Promise<void>((resolve) => upstream.close(() => resolve()));
-});
-
-function makeBridge(): Promise<ChatBridgeService> {
-  return Promise.resolve(new ChatBridgeService());
-}
-
-describe('ChatBridgeService', () => {
-  it('非流式:请求翻译后转发上游,响应翻译回 Responses 形状,头原样透传', async () => {
-    const bridge = await makeBridge();
-    const info = await bridge.start({
-      upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}/v1`,
-      headers: { 'X-Extra': 'yes' },
-    });
-    try {
-      const response = await fetch(`${info.url}/responses`, {
-        method: 'POST',
-        headers: { 'content-type': 'application/json', authorization: 'Bearer sk-live' },
-        body: JSON.stringify({
-          model: 'qwen',
-          instructions: '你是助手',
-          input: '你好',
-          max_output_tokens: 64,
-        }),
-      });
-      expect(response.status).toBe(200);
-      // 上游收到的是 chat 形状
-      expect(lastUpstream.url).toBe('/v1/chat/completions');
-      expect(lastUpstream.auth).toBe('Bearer sk-live');
-      expect(lastUpstream.extra).toBe('yes');
-      expect(lastUpstream.body).toMatchObject({
-        model: 'qwen',
-        max_tokens: 64,
-        messages: [
-          { role: 'developer', content: '你是助手' },
-          { role: 'user', content: '你好' },
-        ],
-      });
-      // 下游拿到的是 responses 形状
-      const body = (await response.json()) as Record<string, unknown>;
-      expect(body).toMatchObject({
-        object: 'response',
-        status: 'completed',
-        usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 },
-      });
-      const output = body.output as Array<Record<string, unknown>>;
-      expect((output[0].content as Array<{ text: string }>)[0].text).toBe('非流式回复');
-    } finally {
-      await bridge.stop();
-    }
-  });
-
-  it('流式:上游 SSE 逐事件翻译下发,completed 带 usage', async () => {
-    upstreamMode = 'sse';
-    const bridge = await makeBridge();
-    const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}/v1` });
-    try {
-      const response = await fetch(`${info.url}/responses`, {
-        method: 'POST',
-        headers: { 'content-type': 'application/json' },
-        body: JSON.stringify({ model: 'qwen', input: '你好', stream: true }),
-      });
-      expect(response.status).toBe(200);
-      expect(response.headers.get('content-type')).toContain('text/event-stream');
-      const text = await response.text();
-      const events = text
-        .split('\n\n')
-        .filter((block) => block.trim())
-        .map((block) => {
-          const eventMatch = block.match(/^event: (.+)$/mu);
-          const dataMatch = block.match(/^data: (.+)$/mu);
-          return { event: eventMatch?.[1], data: JSON.parse(dataMatch?.[1] ?? '{}') as Record<string, unknown> };
-        });
-      const types = events.map((item) => item.event);
-      expect(types[0]).toBe('response.created');
-      expect(types.at(-1)).toBe('response.completed');
-      // 请求侧补了 include_usage
-      expect(lastUpstream.body?.stream_options).toEqual({ include_usage: true });
-      const deltas = events
-        .filter((item) => item.event === 'response.output_text.delta')
-        .map((item) => item.data.delta);
-      expect(deltas.join('')).toBe('你好世界');
-      const completed = events.at(-1)?.data.response as Record<string, unknown>;
-      expect(completed.usage).toEqual({ input_tokens: 5, output_tokens: 2, total_tokens: 7 });
-    } finally {
-      await bridge.stop();
-    }
-  });
-
-  it('上游非 2xx:状态码与错误体透回', async () => {
-    upstreamMode = 'error';
-    const bridge = await makeBridge();
-    const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}/v1` });
-    try {
-      const response = await fetch(`${info.url}/responses`, {
-        method: 'POST',
-        headers: { 'content-type': 'application/json' },
-        body: JSON.stringify({ model: 'qwen', input: 'x' }),
-      });
-      expect(response.status).toBe(500);
-      const body = (await response.json()) as { error: { message: string } };
-      expect(body.error.message).toContain('500');
-    } finally {
-      await bridge.stop();
-    }
-  });
-
-  it('只接 POST /v1/responses;stop 后端口释放', async () => {
-    const bridge = await makeBridge();
-    const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}/v1` });
-    const wrong = await fetch(`${info.url}/models`);
-    expect(wrong.status).toBe(404);
-    await bridge.stop();
-    await expect(fetch(`${info.url}/responses`, { method: 'POST' })).rejects.toThrow();
-  });
-});
-
-/**
- * Codex 等不到就会挂断重来,而自部署的多是单槽推理:
- * 桥接不取消上游,被放弃的那次生成就继续占着槽,下一次请求排在它后面 —— 越重试越慢。
- */
-describe('Codex 提前挂断时取消上游', () => {
-  it('上游请求在挂断后立即中止', async () => {
-    let diedAt = 0;
-    const neverEnds = createServer((req, res) => {
-      req.resume();
-      req.on('end', () => {
-        res.writeHead(200, { 'content-type': 'text/event-stream' });
-        res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: '第一段' }, finish_reason: null }] })}\n\n`);
-        const timer = setInterval(() => res.write(': tick\n\n'), 50);
-        const stop = (): void => {
-          clearInterval(timer);
-          if (!diedAt) diedAt = Date.now();
-        };
-        res.on('close', stop);
-        req.on('aborted', stop);
-      });
-    });
-    await new Promise<void>((resolve) => neverEnds.listen(0, '127.0.0.1', resolve));
-    const port = (neverEnds.address() as { port: number }).port;
-    const bridge = new ChatBridgeService();
-    try {
-      const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${port}/v1` });
-      const controller = new AbortController();
-      const response = await fetch(`${info.url}/responses`, {
-        method: 'POST',
-        headers: { 'content-type': 'application/json' },
-        body: JSON.stringify({ model: 'm', input: '讲个长故事', stream: true }),
-        signal: controller.signal,
-      });
-      // 收到第一个事件再挂断,确保桥接确实已经在上游吞吐数据
-      await response.body?.getReader().read();
-      const abortAt = Date.now();
-      controller.abort();
-
-      const deadline = abortAt + 2_000;
-      while (!diedAt && Date.now() < deadline) {
-        await new Promise((resolve) => setTimeout(resolve, 25));
-      }
-      expect(diedAt).toBeGreaterThan(0);
-      expect(diedAt - abortAt).toBeLessThan(1_000);
-    } finally {
-      await bridge.stop();
-      await new Promise<void>((resolve) => neverEnds.close(() => resolve()));
-    }
-  }, 20_000);
-});
-
-/**
- * 单槽自部署服务光 prefill 就要几分钟才吐首个字节。桥接要是等上游响应头到手才给 Codex 写东西,
- * Codex 面对的就是一条一个字节都没有的死连接 —— 它会掐了重发,于是越重试越慢。
- * 流式请求一到就先把 SSE 建起来,上游慢不影响客户端侧有字节可收。
- */
-describe('上游首字节很慢时先把流建起来', () => {
-  it('立刻回 response.created,不等上游', async () => {
-    const slowUpstream = createServer((req, res) => {
-      req.resume();
-      req.on('end', () => {
-        setTimeout(() => {
-          res.writeHead(200, { 'content-type': 'text/event-stream' });
-          res.end(SSE_BODY);
-        }, 6_000);
-      });
-    });
-    await new Promise<void>((resolve) => slowUpstream.listen(0, '127.0.0.1', resolve));
-    const port = (slowUpstream.address() as { port: number }).port;
-    const bridge = new ChatBridgeService();
-    const controller = new AbortController();
-    try {
-      const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${port}/v1` });
-      const startedAt = Date.now();
-      const response = await fetch(`${info.url}/responses`, {
-        method: 'POST',
-        headers: { 'content-type': 'application/json' },
-        body: JSON.stringify({ model: 'm', input: '讲个故事', stream: true }),
-        signal: controller.signal,
-      });
-      const reader = response.body?.getReader();
-      if (!reader) throw new Error('没有响应体');
-      const first = await reader.read();
-      const firstAt = Date.now() - startedAt;
-      const firstText = new TextDecoder().decode(first.value ?? new Uint8Array());
-      expect(firstText).toContain('event: response.created');
-      expect(firstAt).toBeLessThan(1_500);
-
-      // 流没断:上游 6 秒后才回,最终回复照样要翻给客户端
-      let all = firstText;
-      for (;;) {
-        const { done, value } = await reader.read();
-        if (done) break;
-        all += new TextDecoder().decode(value);
-      }
-      expect(all).toContain('你好');
-      expect(all).toContain('event: response.completed');
-    } finally {
-      controller.abort();
-      await bridge.stop();
-      await new Promise<void>((resolve) => slowUpstream.close(() => resolve()));
-    }
-  }, 30_000);
-});

+ 0 - 265
ai-electron/electron/service/codex/chatBridgeService.ts

@@ -1,265 +0,0 @@
-import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
-import {
-  chatResponseToResponses,
-  responsesRequestToChat,
-  ResponsesSseTranslator,
-  type BridgeSseEvent,
-  type DiagnosticFn,
-  type ResponsesCreateParams,
-} from './chatBridgeTranslate';
-
-/**
- * 内置 Responses→Chat 桥接:给只会 /chat/completions 的端点(老 Ollama / vLLM / Xinference)
- * 套一层本地翻译代理。Codex 连这里的 /v1/responses,桥接翻译后转发上游。
- *
- * 安全边界:只绑 127.0.0.1 随机端口;不持有任何密钥(incoming Authorization 原样透传,
- * key 由 Codex 侧 env_key 注入);只接受 POST /v1/responses,其余一律 404。
- * 消息格式转换全部在 chatBridgeTranslate.ts,这里只管 HTTP 与生命周期。
- */
-
-export interface ChatBridgeOptions {
-  /** 上游 chat 端点 API 根(形如 http://host:11434/v1) */
-  upstreamBaseUrl: string;
-  /** 额外转发头(来自模型配置的 headersJson) */
-  headers?: Record<string, string> | null;
-  onDiagnostic?: DiagnosticFn;
-}
-
-export interface ChatBridgeInfo {
-  url: string;
-  port: number;
-  upstreamBaseUrl: string;
-}
-
-function readBody(req: IncomingMessage): Promise<string> {
-  return new Promise((resolve, reject) => {
-    const chunks: Buffer[] = [];
-    req.on('data', (chunk: Buffer) => chunks.push(chunk));
-    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
-    req.on('error', reject);
-  });
-}
-
-function writeSseEvents(res: ServerResponse, events: BridgeSseEvent[]): void {
-  for (const item of events) {
-    res.write(`event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`);
-  }
-}
-
-export class ChatBridgeService {
-  #server: Server | null = null;
-
-  #info: ChatBridgeInfo | null = null;
-
-  #options: ChatBridgeOptions | null = null;
-
-  /** 诊断里区分同一轮里的多次上游请求 */
-  #seq = 0;
-
-  /** 装配层(index.ts)注入的诊断出口;start 时的 onDiagnostic 优先 */
-  #diagnostic: DiagnosticFn | null = null;
-
-  get info(): ChatBridgeInfo | null {
-    return this.#info;
-  }
-
-  setDiagnostic(fn: DiagnosticFn | null): void {
-    this.#diagnostic = fn;
-  }
-
-  #diag(line: string): void {
-    (this.#options?.onDiagnostic ?? this.#diagnostic)?.(line);
-  }
-
-  async start(options: ChatBridgeOptions): Promise<ChatBridgeInfo> {
-    await this.stop();
-    this.#options = options;
-    const server = createServer((req, res) => {
-      void this.#handle(req, res);
-    });
-    this.#server = server;
-    await new Promise<void>((resolve, reject) => {
-      server.once('error', reject);
-      server.listen(0, '127.0.0.1', () => resolve());
-    });
-    const address = server.address();
-    const port = typeof address === 'object' && address ? address.port : 0;
-    this.#info = { url: `http://127.0.0.1:${port}/v1`, port, upstreamBaseUrl: options.upstreamBaseUrl };
-    this.#diag(`chat-bridge: 已启动 ${this.#info.url} → ${options.upstreamBaseUrl}`);
-    return this.#info;
-  }
-
-  async stop(): Promise<void> {
-    const server = this.#server;
-    this.#server = null;
-    this.#info = null;
-    this.#options = null;
-    if (!server) return;
-    await new Promise<void>((resolve) => server.close(() => resolve()));
-  }
-
-  #forwardHeaders(req: IncomingMessage): Record<string, string> {
-    const headers: Record<string, string> = {
-      'content-type': 'application/json',
-      // 上游 gzip 会把 SSE 行切碎,显式要求不压缩
-      'accept-encoding': 'identity',
-    };
-    const authorization = req.headers.authorization;
-    if (authorization) headers.authorization = authorization;
-    for (const [key, value] of Object.entries(this.#options?.headers ?? {})) {
-      if (value) headers[key.toLowerCase()] = value;
-    }
-    return headers;
-  }
-
-  #failJson(res: ServerResponse, status: number, message: string): void {
-    if (res.headersSent) {
-      res.end();
-      return;
-    }
-    res.writeHead(status, { 'content-type': 'application/json' });
-    res.end(JSON.stringify({ error: { message, type: 'bridge_error' } }));
-  }
-
-  async #handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
-    if (req.method !== 'POST' || req.url !== '/v1/responses') {
-      this.#failJson(res, 404, 'not found');
-      return;
-    }
-
-    let responsesReq: ResponsesCreateParams;
-    try {
-      responsesReq = JSON.parse(await readBody(req)) as ResponsesCreateParams;
-    } catch {
-      this.#failJson(res, 400, '请求体不是合法 JSON');
-      return;
-    }
-
-    const options = this.#options;
-    if (!options) {
-      this.#failJson(res, 503, '桥接未配置上游');
-      return;
-    }
-
-    const chatReq = responsesRequestToChat(responsesReq, (line) => this.#diag(line));
-    const upstream = `${options.upstreamBaseUrl.replace(/\/+$/u, '')}/chat/completions`;
-    const id = ++this.#seq;
-    const t0 = Date.now();
-    const secs = (at: number = Date.now()): string => `${((at - t0) / 1000).toFixed(1)}s`;
-    const inputText = typeof responsesReq.input === 'string' ? responsesReq.input : JSON.stringify(responsesReq.input ?? '');
-    const messages = Array.isArray(chatReq.messages) ? chatReq.messages : [];
-    this.#diag(
-      `chat-bridge: #${id} 起 round model=${responsesReq.model ?? '?'} stream=${chatReq.stream ? 'Y' : 'N'} ` +
-        `tools=${Array.isArray(chatReq.tools) ? chatReq.tools.length : 0} messages=${messages.length} 输入≈${inputText.length}字 → ${upstream}`,
-    );
-
-    /**
-     * Codex 拿不到响应就会挂断重来,而自部署的多是单槽推理:
-     * 不取消上游的话,被放弃的那次生成会继续占着槽,下一次请求排在它后面 —— 越重试越慢。
-     */
-    const controller = new AbortController();
-    let clientGone = false;
-    res.once('close', () => {
-      if (res.writableEnded) return;
-      clientGone = true;
-      controller.abort();
-      this.#diag(`chat-bridge: #${id} Codex 提前断开(${secs()}),已取消上游请求`);
-    });
-
-    /**
-     * 流式请求先把 SSE 开起来再等上游:单槽自部署服务光 prefill 就要几分钟,
-     * 那期间 Codex 一个字节都收不到就会掐断重发(重发又把同一个 prompt 重新排一遍队)。
-     */
-    const translator = chatReq.stream ? new ResponsesSseTranslator(responsesReq, (line) => this.#diag(line)) : null;
-    let events = 0;
-    const write = (items: BridgeSseEvent[]): void => {
-      events += items.length;
-      writeSseEvents(res, items);
-    };
-    if (translator) {
-      res.writeHead(200, {
-        'content-type': 'text/event-stream',
-        'cache-control': 'no-cache',
-        connection: 'keep-alive',
-      });
-      write(translator.begin());
-    }
-
-    let upstreamRes: Response;
-    try {
-      upstreamRes = await fetch(upstream, {
-        method: 'POST',
-        headers: this.#forwardHeaders(req),
-        body: JSON.stringify(chatReq),
-        signal: controller.signal,
-      });
-    } catch (error) {
-      if (clientGone) return;
-      const message = error instanceof Error ? error.message : String(error);
-      this.#diag(`chat-bridge: #${id} 上游不可达(${secs()}):${message}`);
-      if (translator) {
-        write(translator.fail(`桥接上游不可达:${message}`));
-        res.end();
-        return;
-      }
-      this.#failJson(res, 502, `桥接上游不可达:${message}`);
-      return;
-    }
-
-    if (!upstreamRes.ok) {
-      const text = await upstreamRes.text().catch(() => '');
-      if (clientGone) return;
-      const detail = `桥接上游返回 HTTP ${upstreamRes.status}:${text.slice(0, 500)}`;
-      this.#diag(`chat-bridge: #${id} 上游 HTTP ${upstreamRes.status}(${secs()}):${text.slice(0, 300)}`);
-      if (translator) {
-        write(translator.fail(detail));
-        res.end();
-        return;
-      }
-      this.#failJson(res, upstreamRes.status, detail);
-      return;
-    }
-
-    if (!translator) {
-      try {
-        const chatJson = (await upstreamRes.json()) as Parameters<typeof chatResponseToResponses>[0];
-        if (clientGone) return;
-        res.writeHead(200, { 'content-type': 'application/json' });
-        res.end(JSON.stringify(chatResponseToResponses(chatJson, responsesReq)));
-        this.#diag(`chat-bridge: #${id} 非流式完成(${secs()})`);
-      } catch (error) {
-        if (clientGone) return;
-        this.#diag(`chat-bridge: #${id} 非流式解析失败(${secs()})`);
-        this.#failJson(res, 502, `桥接解析上游响应失败:${error instanceof Error ? error.message : String(error)}`);
-      }
-      return;
-    }
-
-    // 流式:边收边翻边发(SSE 已在请求到达时就开好,translator 此时必定存在)
-    let bytes = 0;
-    try {
-      if (!upstreamRes.body) throw new Error('上游响应没有 body');
-      const reader = upstreamRes.body.getReader();
-      const decoder = new TextDecoder();
-      for (;;) {
-        const { done, value } = await reader.read();
-        if (done) break;
-        bytes += value?.byteLength ?? 0;
-        write(translator.push(decoder.decode(value, { stream: true })));
-      }
-      write(translator.push(decoder.decode()));
-      write(translator.finish());
-      this.#diag(`chat-bridge: #${id} 转发完成(${secs()},上游 ${bytes}B → ${events} 个事件)`);
-    } catch (error) {
-      // 客户端已走 = 我们自己主动 abort,不该再当成上游故障去报告警
-      if (clientGone) return;
-      const message = error instanceof Error ? error.message : String(error);
-      this.#diag(`chat-bridge: #${id} 流式转发中断(${secs()}):${message}`);
-      write(translator.fail(message));
-    }
-    res.end();
-  }
-}
-
-/** 模块级单例:providerService 应用/清除模型时启停,装配层退出时兜底 stop */
-export const chatBridge = new ChatBridgeService();

+ 0 - 380
ai-electron/electron/service/codex/chatBridgeTranslate.test.ts

@@ -1,380 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import {
-  chatResponseToResponses,
-  responsesRequestToChat,
-  ResponsesSseTranslator,
-  type BridgeSseEvent,
-  type ResponsesCreateParams,
-} from './chatBridgeTranslate';
-
-function sseLines(events: Array<Record<string, unknown>>): string {
-  return events.map((data) => `data: ${JSON.stringify(data)}\n\n`).join('');
-}
-
-function types(events: BridgeSseEvent[]): string[] {
-  return events.map((item) => item.event);
-}
-
-describe('responsesRequestToChat', () => {
-  it('instructions 转 developer message,input 字符串转 user message', () => {
-    const chat = responsesRequestToChat({ model: 'qwen', instructions: '你是助手', input: '你好' });
-    expect(chat.model).toBe('qwen');
-    expect(chat.messages).toEqual([
-      { role: 'developer', content: '你是助手' },
-      { role: 'user', content: '你好' },
-    ]);
-  });
-
-  it('message item:字符串 content 直传;easy-input-message(无 type)按 message 处理', () => {
-    const chat = responsesRequestToChat({
-      input: [
-        { type: 'message', role: 'user', content: '直接字符串' },
-        { role: 'assistant', content: '没有 type 字段' },
-      ],
-    });
-    expect(chat.messages).toEqual([
-      { role: 'user', content: '直接字符串' },
-      { role: 'assistant', content: '没有 type 字段' },
-    ]);
-  });
-
-  it('content parts:input_text 合并;单文本压成 string;input_file 丢弃并留诊断', () => {
-    const dropped: string[] = [];
-    const chat = responsesRequestToChat(
-      {
-        input: [
-          {
-            type: 'message',
-            role: 'user',
-            content: [
-              { type: 'input_text', text: '第一段' },
-              { type: 'input_file', file_url: 'file:///x.pdf' },
-              { type: 'input_text', text: '第二段' },
-            ],
-          },
-          {
-            type: 'message',
-            role: 'user',
-            content: [{ type: 'input_text', text: '唯一文本' }],
-          },
-        ],
-      },
-      (line) => dropped.push(line),
-    );
-    expect(chat.messages[0]).toEqual({
-      role: 'user',
-      content: [
-        { type: 'text', text: '第一段' },
-        { type: 'text', text: '第二段' },
-      ],
-    });
-    expect(chat.messages[1]).toEqual({ role: 'user', content: '唯一文本' });
-    expect(dropped.some((line) => line.includes('input_file'))).toBe(true);
-  });
-
-  it('function_call / function_call_output / reasoning 的映射与丢弃', () => {
-    const dropped: string[] = [];
-    const chat = responsesRequestToChat(
-      {
-        input: [
-          { type: 'reasoning', summary: [] },
-          { type: 'function_call', call_id: 'call_1', name: 'exec', arguments: '{"cmd":"ls"}' },
-          { type: 'function_call_output', call_id: 'call_1', output: 'ok' },
-        ],
-      },
-      (line) => dropped.push(line),
-    );
-    expect(chat.messages).toEqual([
-      {
-        role: 'assistant',
-        content: null,
-        tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'exec', arguments: '{"cmd":"ls"}' } }],
-      },
-      { role: 'tool', tool_call_id: 'call_1', content: 'ok' },
-    ]);
-    expect(dropped.some((line) => line.includes('reasoning'))).toBe(true);
-  });
-
-  it('tools 扁平转嵌套;tool_choice 各形态;max_output_tokens 转 max_tokens', () => {
-    const chat = responsesRequestToChat({
-      input: 'x',
-      tools: [
-        { type: 'function', name: 'exec', description: '执行', parameters: { type: 'object' } },
-        { type: 'web_search' },
-      ],
-      tool_choice: { type: 'function', name: 'exec' },
-      max_output_tokens: 1024,
-      temperature: 0.3,
-      top_p: 0.9,
-      parallel_tool_calls: false,
-    });
-    expect(chat.tools).toEqual([
-      { type: 'function', function: { name: 'exec', description: '执行', parameters: { type: 'object' } } },
-    ]);
-    expect(chat.tool_choice).toEqual({ type: 'function', function: { name: 'exec' } });
-    expect(chat.max_tokens).toBe(1024);
-    expect(chat.temperature).toBe(0.3);
-    expect(chat.top_p).toBe(0.9);
-    expect(chat.parallel_tool_calls).toBe(false);
-  });
-
-  it('tool_choice 字符串直传;无法识别的形态丢弃', () => {
-    expect(responsesRequestToChat({ input: 'x', tool_choice: 'required' }).tool_choice).toBe('required');
-    expect(
-      responsesRequestToChat({ input: 'x', tool_choice: { type: 'allowed_tools', tools: [] } }).tool_choice,
-    ).toBeUndefined();
-  });
-
-  it('stream 时补 stream_options.include_usage;responses 专有字段不外泄', () => {
-    const chat = responsesRequestToChat({
-      input: 'x',
-      stream: true,
-      previous_response_id: 'resp_x',
-      store: false,
-      metadata: { a: 1 },
-      reasoning: { effort: 'high' },
-      include: ['reasoning.encrypted_content'],
-      truncation: 'auto',
-    });
-    expect(chat.stream).toBe(true);
-    expect(chat.stream_options).toEqual({ include_usage: true });
-    const raw = JSON.stringify(chat);
-    // 注意要用带冒号的键名匹配:裸 'include' 会误伤 stream_options 的 include_usage
-    for (const key of ['previous_response_id', 'store', 'metadata', 'reasoning', 'include', 'truncation']) {
-      expect(raw).not.toContain(`"${key}":`);
-    }
-  });
-});
-
-describe('chatResponseToResponses', () => {
-  const req: ResponsesCreateParams = { model: 'qwen', input: 'x' };
-
-  it('content / tool_calls / reasoning_content / usage 全量映射', () => {
-    const response = chatResponseToResponses(
-      {
-        id: 'chatcmpl-1',
-        created: 1700000000,
-        model: 'qwen',
-        choices: [
-          {
-            finish_reason: 'tool_calls',
-            message: {
-              reasoning_content: '想一想',
-              content: '好的',
-              tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'exec', arguments: '{}' } }],
-            },
-          },
-        ],
-        usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
-      },
-      req,
-    );
-    expect(response).toMatchObject({
-      id: 'chatcmpl-1',
-      object: 'response',
-      created_at: 1700000000,
-      model: 'qwen',
-      status: 'completed',
-      usage: { input_tokens: 10, output_tokens: 20, total_tokens: 30 },
-    });
-    const output = response.output as Array<Record<string, unknown>>;
-    expect(output.map((item) => item.type)).toEqual(['reasoning', 'message', 'function_call']);
-    expect(output[1]).toMatchObject({
-      role: 'assistant',
-      status: 'completed',
-      content: [{ type: 'output_text', text: '好的' }],
-    });
-    expect(output[2]).toMatchObject({ call_id: 'call_1', name: 'exec', arguments: '{}' });
-  });
-
-  it('finish_reason=length 判 incomplete;无 usage 填全零', () => {
-    const response = chatResponseToResponses(
-      { choices: [{ finish_reason: 'length', message: { content: '截断' } }] },
-      req,
-    );
-    expect(response.status).toBe('incomplete');
-    expect(response.incomplete_details).toEqual({ reason: 'max_output_tokens' });
-    expect(response.usage).toEqual({ input_tokens: 0, output_tokens: 0, total_tokens: 0 });
-  });
-});
-
-describe('ResponsesSseTranslator', () => {
-  const req: ResponsesCreateParams = { model: 'qwen', input: 'x', stream: true };
-
-  it('纯文本流:完整 golden 事件序列', () => {
-    const translator = new ResponsesSseTranslator(req);
-    const events = translator.push(
-      sseLines([
-        { choices: [{ delta: { role: 'assistant', content: '' }, finish_reason: null }] },
-        { choices: [{ delta: { content: '你好' }, finish_reason: null }] },
-        { choices: [{ delta: { content: ',世界' }, finish_reason: null }] },
-        { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } },
-      ]) + 'data: [DONE]\n\n',
-    );
-    expect(types(events)).toEqual([
-      'response.created',
-      'response.in_progress',
-      'response.output_item.added',
-      'response.content_part.added',
-      'response.output_text.delta',
-      'response.output_text.delta',
-      'response.output_text.done',
-      'response.content_part.done',
-      'response.output_item.done',
-      'response.completed',
-    ]);
-    const completed = events.at(-1)?.data.response as Record<string, unknown>;
-    expect(completed.status).toBe('completed');
-    expect(completed.usage).toEqual({ input_tokens: 3, output_tokens: 2, total_tokens: 5 });
-    const output = completed.output as Array<Record<string, unknown>>;
-    expect(output).toHaveLength(1);
-    expect((output[0].content as Array<{ text: string }>)[0].text).toBe('你好,世界');
-  });
-
-  it('data: 行跨分包拼接', () => {
-    const translator = new ResponsesSseTranslator(req);
-    const chunk = `data: ${JSON.stringify({ choices: [{ delta: { content: '整段' } }] })}`;
-    const half = Math.floor(chunk.length / 2);
-    const first = translator.push(chunk.slice(0, half));
-    const second = translator.push(chunk.slice(half) + '\n\ndata: [DONE]\n\n');
-    expect(types([...first, ...second])).toEqual([
-      'response.created',
-      'response.in_progress',
-      'response.output_item.added',
-      'response.content_part.added',
-      'response.output_text.delta',
-      'response.output_text.done',
-      'response.content_part.done',
-      'response.output_item.done',
-      'response.completed',
-    ]);
-  });
-
-  it('两个并行 tool_calls 按 index 交错也能分别归拢', () => {
-    const translator = new ResponsesSseTranslator(req);
-    const events = translator.push(
-      sseLines([
-        {
-          choices: [
-            {
-              delta: {
-                tool_calls: [
-                  { index: 0, id: 'call_a', function: { name: 'exec', arguments: '{"a":' } },
-                  { index: 1, id: 'call_b', function: { name: 'read', arguments: '{"b":' } },
-                ],
-              },
-            },
-          ],
-        },
-        {
-          choices: [
-            {
-              delta: {
-                tool_calls: [
-                  { index: 1, function: { arguments: '1}' } },
-                  { index: 0, function: { arguments: '2}' } },
-                ],
-              },
-              finish_reason: 'tool_calls',
-            },
-          ],
-        },
-      ]) + 'data: [DONE]\n\n',
-    );
-    expect(types(events)).toEqual([
-      'response.created',
-      'response.in_progress',
-      'response.output_item.added',
-      'response.function_call_arguments.delta',
-      'response.output_item.added',
-      'response.function_call_arguments.delta',
-      'response.function_call_arguments.delta',
-      'response.function_call_arguments.delta',
-      'response.function_call_arguments.done',
-      'response.output_item.done',
-      'response.function_call_arguments.done',
-      'response.output_item.done',
-      'response.completed',
-    ]);
-    const completed = events.at(-1)?.data.response as Record<string, unknown>;
-    const output = completed.output as Array<Record<string, unknown>>;
-    expect(output.map((item) => [item.call_id, item.name, item.arguments])).toEqual([
-      ['call_a', 'exec', '{"a":2}'],
-      ['call_b', 'read', '{"b":1}'],
-    ]);
-  });
-
-  it('reasoning_content 流转 reasoning item,正文开始前闭合', () => {
-    const translator = new ResponsesSseTranslator(req);
-    const events = translator.push(
-      sseLines([
-        { choices: [{ delta: { reasoning_content: '先想' } }] },
-        { choices: [{ delta: { reasoning_content: '一下' } }] },
-        { choices: [{ delta: { content: '结论' } }] },
-        { choices: [{ delta: {}, finish_reason: 'stop' }] },
-      ]) + 'data: [DONE]\n\n',
-    );
-    expect(types(events)).toEqual([
-      'response.created',
-      'response.in_progress',
-      'response.output_item.added',
-      'response.reasoning_summary_part.added',
-      'response.reasoning_summary_text.delta',
-      'response.reasoning_summary_text.delta',
-      'response.reasoning_summary_text.done',
-      'response.reasoning_summary_part.done',
-      'response.output_item.done',
-      'response.output_item.added',
-      'response.content_part.added',
-      'response.output_text.delta',
-      'response.output_text.done',
-      'response.content_part.done',
-      'response.output_item.done',
-      'response.completed',
-    ]);
-    const completed = events.at(-1)?.data.response as Record<string, unknown>;
-    const output = completed.output as Array<Record<string, unknown>>;
-    expect(output[0]).toMatchObject({ type: 'reasoning', summary: [{ type: 'summary_text', text: '先想一下' }] });
-  });
-
-  it('上游没发 [DONE]:finish() 冲刷闭合;无 usage 填全零', () => {
-    const translator = new ResponsesSseTranslator(req);
-    translator.push(sseLines([{ choices: [{ delta: { content: '半截' } }] }]));
-    const events = translator.finish();
-    expect(types(events)).toEqual([
-      'response.output_text.done',
-      'response.content_part.done',
-      'response.output_item.done',
-      'response.completed',
-    ]);
-    const completed = events.at(-1)?.data.response as Record<string, unknown>;
-    expect(completed.usage).toEqual({ input_tokens: 0, output_tokens: 0, total_tokens: 0 });
-    // 再次 finish / push 不再重复发 completed
-    expect(translator.finish()).toEqual([]);
-  });
-
-  it('坏 JSON 行跳过不杀流;usage-only chunk 不产事件', () => {
-    const dropped: string[] = [];
-    const translator = new ResponsesSseTranslator(req, (line) => dropped.push(line));
-    const events = translator.push(
-      'data: {bad json\n\n' +
-        sseLines([{ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }]) +
-        sseLines([{ choices: [{ delta: { content: '好' }, finish_reason: 'stop' }] }]) +
-        'data: [DONE]\n\n',
-    );
-    expect(dropped.some((line) => line.includes('无法解析'))).toBe(true);
-    const completed = events.at(-1)?.data.response as Record<string, unknown>;
-    expect(completed.usage).toEqual({ input_tokens: 1, output_tokens: 1, total_tokens: 2 });
-  });
-
-  it('fail() 补 response.failed 并带错误信息', () => {
-    const translator = new ResponsesSseTranslator(req);
-    translator.push(sseLines([{ choices: [{ delta: { content: '写了一半' } }] }]));
-    const events = translator.fail('上游连接中断');
-    expect(types(events)).toEqual(['response.failed']);
-    const response = events[0].data.response as Record<string, unknown>;
-    expect(response.status).toBe('failed');
-    expect(response.error).toMatchObject({ code: 'upstream_error', message: '上游连接中断' });
-    expect(translator.finish()).toEqual([]);
-  });
-});

+ 0 - 722
ai-electron/electron/service/codex/chatBridgeTranslate.ts

@@ -1,722 +0,0 @@
-/**
- * Responses API ↔ Chat Completions 的翻译层(纯函数 + SSE 状态机,零 IO)。
- *
- * Codex 0.155+ 只会发 Responses API;chat-only 端点(老 Ollama / vLLM / Xinference)
- * 由 chatBridgeService 在本地起代理,请求与响应的格式转换全部集中在这里。
- * 转换是有损的,丢弃项一律走 onDiagnostic 留痕,绝不静默。
- */
-
-// ── 结构类型(只声明用到的字段,其余原样忽略) ─────────────────────────────
-
-export interface ResponsesInputItem {
-  type?: string;
-  role?: string;
-  content?: unknown;
-  call_id?: string;
-  name?: string;
-  arguments?: string;
-  output?: unknown;
-  [key: string]: unknown;
-}
-
-export interface ResponsesTool {
-  type?: string;
-  name?: string;
-  description?: string;
-  parameters?: unknown;
-  [key: string]: unknown;
-}
-
-export interface ResponsesCreateParams {
-  model?: string;
-  instructions?: string | null;
-  input?: string | ResponsesInputItem[];
-  tools?: ResponsesTool[];
-  tool_choice?: unknown;
-  max_output_tokens?: number | null;
-  temperature?: number | null;
-  top_p?: number | null;
-  parallel_tool_calls?: boolean;
-  stream?: boolean;
-  [key: string]: unknown;
-}
-
-export interface ChatContentPart {
-  type: string;
-  text?: string;
-  image_url?: { url: string };
-}
-
-export interface ChatToolCall {
-  id: string;
-  type: 'function';
-  function: { name: string; arguments: string };
-}
-
-export interface ChatMessage {
-  role: string;
-  content?: string | ChatContentPart[] | null;
-  tool_calls?: ChatToolCall[];
-  tool_call_id?: string;
-}
-
-export interface ChatCompletionRequest {
-  model?: string;
-  messages: ChatMessage[];
-  tools?: Array<{ type: 'function'; function: { name?: string; description?: string; parameters?: unknown } }>;
-  tool_choice?: unknown;
-  max_tokens?: number | null;
-  temperature?: number | null;
-  top_p?: number | null;
-  parallel_tool_calls?: boolean;
-  stream?: boolean;
-  stream_options?: { include_usage?: boolean };
-}
-
-export interface ChatUsage {
-  prompt_tokens?: number;
-  completion_tokens?: number;
-  total_tokens?: number;
-}
-
-export interface ResponsesUsage {
-  input_tokens: number;
-  output_tokens: number;
-  total_tokens: number;
-}
-
-export interface ChatCompletionLike {
-  id?: string;
-  created?: number;
-  model?: string;
-  choices?: Array<{
-    finish_reason?: string | null;
-    message?: {
-      content?: string | null;
-      tool_calls?: ChatToolCall[];
-      reasoning_content?: string;
-    };
-  }>;
-  usage?: ChatUsage;
-}
-
-export interface BridgeSseEvent {
-  event: string;
-  data: Record<string, unknown>;
-}
-
-export type DiagnosticFn = (line: string) => void;
-
-// ── 小工具 ────────────────────────────────────────────────────────────────
-
-let idCounter = 0;
-
-function newId(prefix: string): string {
-  idCounter += 1;
-  return `${prefix}_${Date.now().toString(36)}${idCounter.toString(36)}${Math.random().toString(36).slice(2, 8)}`;
-}
-
-function mapUsage(usage: ChatUsage | null | undefined): ResponsesUsage {
-  return {
-    input_tokens: usage?.prompt_tokens ?? 0,
-    output_tokens: usage?.completion_tokens ?? 0,
-    total_tokens: usage?.total_tokens ?? 0,
-  };
-}
-
-// ── 请求方向:Responses → Chat ─────────────────────────────────────────────
-
-function convertMessageItem(item: ResponsesInputItem, onDiagnostic?: DiagnosticFn): ChatMessage | null {
-  const role = typeof item.role === 'string' && item.role ? item.role : 'user';
-  const content = item.content;
-  if (typeof content === 'string') return { role, content };
-  if (Array.isArray(content)) {
-    const parts: ChatContentPart[] = [];
-    for (const part of content) {
-      if (!part || typeof part !== 'object') continue;
-      const record = part as { type?: string; text?: unknown; image_url?: unknown };
-      if ((record.type === 'input_text' || record.type === 'output_text') && typeof record.text === 'string') {
-        parts.push({ type: 'text', text: record.text });
-      } else if (record.type === 'input_image' && typeof record.image_url === 'string') {
-        parts.push({ type: 'image_url', image_url: { url: record.image_url } });
-      } else {
-        onDiagnostic?.(`chat-bridge: 丢弃不支持的 content part(${record.type ?? '未知类型'})`);
-      }
-    }
-    if (!parts.length) return null;
-    // 单文本压成 string,兼容性最好
-    if (parts.length === 1 && parts[0].type === 'text') return { role, content: parts[0].text ?? '' };
-    return { role, content: parts };
-  }
-  return null;
-}
-
-function convertToolChoice(choice: unknown): unknown {
-  if (choice === 'auto' || choice === 'none' || choice === 'required') return choice;
-  if (choice && typeof choice === 'object') {
-    const record = choice as { type?: string; name?: unknown };
-    if (record.type === 'function' && typeof record.name === 'string') {
-      return { type: 'function', function: { name: record.name } };
-    }
-  }
-  return undefined;
-}
-
-export function responsesRequestToChat(req: ResponsesCreateParams, onDiagnostic?: DiagnosticFn): ChatCompletionRequest {
-  const messages: ChatMessage[] = [];
-
-  if (typeof req.instructions === 'string' && req.instructions) {
-    messages.push({ role: 'developer', content: req.instructions });
-  }
-
-  const input = req.input;
-  if (typeof input === 'string') {
-    if (input) messages.push({ role: 'user', content: input });
-  } else if (Array.isArray(input)) {
-    for (const item of input) {
-      if (!item || typeof item !== 'object') continue;
-      // easy-input-message 形式({role, content} 无 type 字段)按 message 处理
-      const type = typeof item.type === 'string' ? item.type : item.role ? 'message' : null;
-      if (type === 'message') {
-        const converted = convertMessageItem(item, onDiagnostic);
-        if (converted) messages.push(converted);
-      } else if (type === 'function_call' || type === 'custom_tool_call') {
-        messages.push({
-          role: 'assistant',
-          content: null,
-          tool_calls: [
-            {
-              id: item.call_id ?? '',
-              type: 'function',
-              function: {
-                name: item.name ?? '',
-                arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? ''),
-              },
-            },
-          ],
-        });
-      } else if (type === 'function_call_output' || type === 'custom_tool_call_output') {
-        messages.push({
-          role: 'tool',
-          tool_call_id: item.call_id ?? '',
-          content: typeof item.output === 'string' ? item.output : JSON.stringify(item.output ?? ''),
-        });
-      } else {
-        // reasoning / item_reference / web_search_call 等:chat 协议没有对应物
-        onDiagnostic?.(`chat-bridge: 丢弃不支持的 input item(${type ?? '未知类型'})`);
-      }
-    }
-  }
-
-  const request: ChatCompletionRequest = { messages };
-  if (req.model) request.model = req.model;
-
-  if (Array.isArray(req.tools)) {
-    const tools = req.tools
-      .filter((tool) => tool && tool.type === 'function')
-      .map((tool) => ({
-        type: 'function' as const,
-        function: { name: tool.name, description: tool.description, parameters: tool.parameters },
-      }));
-    if (tools.length) request.tools = tools;
-  }
-
-  const toolChoice = convertToolChoice(req.tool_choice);
-  if (toolChoice !== undefined) request.tool_choice = toolChoice;
-
-  if (typeof req.max_output_tokens === 'number') request.max_tokens = req.max_output_tokens;
-  if (typeof req.temperature === 'number') request.temperature = req.temperature;
-  if (typeof req.top_p === 'number') request.top_p = req.top_p;
-  if (typeof req.parallel_tool_calls === 'boolean') request.parallel_tool_calls = req.parallel_tool_calls;
-
-  if (req.stream) {
-    request.stream = true;
-    // 让上游在最后一个 chunk 带 usage;不支持的端点会忽略,下游有全零兜底
-    request.stream_options = { include_usage: true };
-  }
-  return request;
-}
-
-// ── 非流式响应方向:Chat → Responses ───────────────────────────────────────
-
-export function chatResponseToResponses(chat: ChatCompletionLike, req: ResponsesCreateParams): Record<string, unknown> {
-  const choice = chat.choices?.[0];
-  const message = choice?.message ?? {};
-  const output: Array<Record<string, unknown>> = [];
-
-  if (typeof message.reasoning_content === 'string' && message.reasoning_content) {
-    output.push({
-      id: newId('rs'),
-      type: 'reasoning',
-      summary: [{ type: 'summary_text', text: message.reasoning_content }],
-    });
-  }
-  if (typeof message.content === 'string' && message.content) {
-    output.push({
-      id: newId('msg'),
-      type: 'message',
-      role: 'assistant',
-      status: 'completed',
-      content: [{ type: 'output_text', text: message.content, annotations: [] }],
-    });
-  }
-  if (Array.isArray(message.tool_calls)) {
-    for (const call of message.tool_calls) {
-      output.push({
-        id: newId('fc'),
-        type: 'function_call',
-        call_id: call.id ?? '',
-        name: call.function?.name ?? '',
-        arguments: call.function?.arguments ?? '',
-        status: 'completed',
-      });
-    }
-  }
-
-  const incomplete = choice?.finish_reason === 'length';
-  return {
-    id: chat.id || newId('resp'),
-    object: 'response',
-    created_at: chat.created ?? Math.floor(Date.now() / 1000),
-    model: chat.model ?? req.model ?? '',
-    status: incomplete ? 'incomplete' : 'completed',
-    ...(incomplete ? { incomplete_details: { reason: 'max_output_tokens' } } : {}),
-    output,
-    parallel_tool_calls: req.parallel_tool_calls ?? true,
-    tool_choice: req.tool_choice ?? 'auto',
-    tools: req.tools ?? [],
-    usage: mapUsage(chat.usage),
-  };
-}
-
-// ── SSE 流式状态机:chat.completion.chunk → Responses 语义事件 ─────────────
-
-interface TextItemState {
-  itemId: string;
-  outputIndex: number;
-  open: boolean;
-  text: string;
-}
-
-interface ReasoningItemState {
-  itemId: string;
-  outputIndex: number;
-  open: boolean;
-  text: string;
-}
-
-interface ToolCallState {
-  itemId: string;
-  outputIndex: number;
-  callId: string;
-  name: string;
-  argsBuffer: string;
-  open: boolean;
-}
-
-export class ResponsesSseTranslator {
-  private readonly model: string;
-
-  private readonly onDiagnostic?: DiagnosticFn;
-
-  private readonly responseId = newId('resp');
-
-  private readonly createdAt = Math.floor(Date.now() / 1000);
-
-  private lineBuffer = '';
-
-  private started = false;
-
-  private completed = false;
-
-  private nextOutputIndex = 0;
-
-  private textItem: TextItemState | null = null;
-
-  private reasoningItem: ReasoningItemState | null = null;
-
-  private readonly toolCalls = new Map<number, ToolCallState>();
-
-  private finishReason: string | null = null;
-
-  private usage: ResponsesUsage | null = null;
-
-  constructor(req: ResponsesCreateParams, onDiagnostic?: DiagnosticFn) {
-    this.model = req.model ?? '';
-    this.onDiagnostic = onDiagnostic;
-  }
-
-  /** 喂入上游 SSE 文本片段,吐出 0..n 个待发送的 Responses SSE 事件 */
-  push(chunk: string): BridgeSseEvent[] {
-    const events: BridgeSseEvent[] = [];
-    this.lineBuffer += chunk;
-    const lines = this.lineBuffer.split(/\r?\n/u);
-    this.lineBuffer = lines.pop() ?? '';
-    for (const line of lines) {
-      const trimmed = line.trim();
-      if (!trimmed || !trimmed.startsWith('data:')) continue; // event:/id:/注释行忽略
-      const payload = trimmed.slice(5).trim();
-      if (payload === '[DONE]') {
-        events.push(...this.finish());
-        continue;
-      }
-      let parsed: unknown;
-      try {
-        parsed = JSON.parse(payload);
-      } catch {
-        this.onDiagnostic?.('chat-bridge: 跳过无法解析的上游 SSE 行');
-        continue;
-      }
-      events.push(...this.processChunk(parsed as Record<string, unknown>));
-    }
-    return events;
-  }
-
-  /** 上游流结束(可能没收到 [DONE]):闭合未闭 item 并补 response.completed */
-  finish(): BridgeSseEvent[] {
-    if (this.completed) return [];
-    this.completed = true;
-    const events: BridgeSseEvent[] = [];
-    if (!this.started) {
-      this.started = true;
-      events.push(...this.openResponse());
-    }
-    events.push(...this.closeAllItems());
-    const incomplete = this.finishReason === 'length';
-    const response: Record<string, unknown> = {
-      ...this.responseSkeleton(incomplete ? 'incomplete' : 'completed'),
-      output: this.buildOutput(),
-      usage: this.usage ?? mapUsage(null),
-    };
-    if (incomplete) response.incomplete_details = { reason: 'max_output_tokens' };
-    events.push(this.event('response.completed', { response }));
-    return events;
-  }
-
-  /** 流中途出错:补一个 response.failed 再交给 server 关闭 */
-  fail(message: string): BridgeSseEvent[] {
-    if (this.completed) return [];
-    this.completed = true;
-    const events: BridgeSseEvent[] = [];
-    if (!this.started) {
-      this.started = true;
-      events.push(...this.openResponse());
-    }
-    const response: Record<string, unknown> = {
-      ...this.responseSkeleton('failed'),
-      output: this.buildOutput(),
-      error: { code: 'upstream_error', message },
-      usage: this.usage ?? mapUsage(null),
-    };
-    events.push(this.event('response.failed', { response }));
-    return events;
-  }
-
-  // ── 内部 ──
-
-  private event(type: string, data: Record<string, unknown>): BridgeSseEvent {
-    return { event: type, data: { type, ...data } };
-  }
-
-  private responseSkeleton(status: string): Record<string, unknown> {
-    return {
-      id: this.responseId,
-      object: 'response',
-      created_at: this.createdAt,
-      model: this.model,
-      status,
-      output: [],
-      parallel_tool_calls: true,
-      tool_choice: 'auto',
-      tools: [],
-      usage: null,
-    };
-  }
-
-  private openResponse(): BridgeSseEvent[] {
-    const response = this.responseSkeleton('in_progress');
-    return [this.event('response.created', { response }), this.event('response.in_progress', { response })];
-  }
-
-  /**
-   * 不等上游、立刻把 Responses 流开起来:单槽自部署服务光 prefill 就要几分钟,
-   * 客户端在那期间一个字节都收不到就会掐断重发。开了之后 push() 不会重复发这两帧。
-   */
-  begin(): BridgeSseEvent[] {
-    return this.ensureStarted();
-  }
-
-  private ensureStarted(): BridgeSseEvent[] {
-    if (this.started) return [];
-    this.started = true;
-    return this.openResponse();
-  }
-
-  private processChunk(chunk: Record<string, unknown>): BridgeSseEvent[] {
-    const events: BridgeSseEvent[] = [];
-    const usage = chunk.usage as ChatUsage | undefined;
-    if (usage) this.usage = mapUsage(usage);
-
-    const choices = chunk.choices as Array<Record<string, unknown>> | undefined;
-    const choice = choices?.[0];
-    if (!choice) return events; // usage-only chunk
-
-    events.push(...this.ensureStarted());
-    const delta = (choice.delta ?? {}) as {
-      content?: unknown;
-      reasoning_content?: unknown;
-      tool_calls?: Array<{ index?: number; id?: string; function?: { name?: string; arguments?: string } }>;
-    };
-
-    if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
-      events.push(...this.pushReasoningDelta(delta.reasoning_content));
-    }
-    if (typeof delta.content === 'string' && delta.content) {
-      events.push(...this.pushTextDelta(delta.content));
-    }
-    if (Array.isArray(delta.tool_calls)) {
-      for (const call of delta.tool_calls) {
-        events.push(...this.pushToolCallDelta(call));
-      }
-    }
-    if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
-      this.finishReason = choice.finish_reason;
-      events.push(...this.closeAllItems());
-    }
-    return events;
-  }
-
-  private closeReasoningIfOpen(exceptOpen = false): BridgeSseEvent[] {
-    const state = this.reasoningItem;
-    if (!state || !state.open || exceptOpen) return [];
-    state.open = false;
-    return [
-      this.event('response.reasoning_summary_text.done', {
-        item_id: state.itemId,
-        output_index: state.outputIndex,
-        summary_index: 0,
-        text: state.text,
-      }),
-      this.event('response.reasoning_summary_part.done', {
-        item_id: state.itemId,
-        output_index: state.outputIndex,
-        summary_index: 0,
-        part: { type: 'summary_text', text: state.text },
-      }),
-      this.event('response.output_item.done', {
-        output_index: state.outputIndex,
-        item: {
-          id: state.itemId,
-          type: 'reasoning',
-          summary: state.text ? [{ type: 'summary_text', text: state.text }] : [],
-        },
-      }),
-    ];
-  }
-
-  private closeTextIfOpen(): BridgeSseEvent[] {
-    const state = this.textItem;
-    if (!state || !state.open) return [];
-    state.open = false;
-    const part = { type: 'output_text', text: state.text, annotations: [] as unknown[] };
-    return [
-      this.event('response.output_text.done', {
-        item_id: state.itemId,
-        output_index: state.outputIndex,
-        content_index: 0,
-        text: state.text,
-      }),
-      this.event('response.content_part.done', {
-        item_id: state.itemId,
-        output_index: state.outputIndex,
-        content_index: 0,
-        part,
-      }),
-      this.event('response.output_item.done', {
-        output_index: state.outputIndex,
-        item: { id: state.itemId, type: 'message', role: 'assistant', status: 'completed', content: [part] },
-      }),
-    ];
-  }
-
-  private closeToolCallIfOpen(state: ToolCallState): BridgeSseEvent[] {
-    if (!state.open) return [];
-    state.open = false;
-    return [
-      this.event('response.function_call_arguments.done', {
-        item_id: state.itemId,
-        output_index: state.outputIndex,
-        arguments: state.argsBuffer,
-      }),
-      this.event('response.output_item.done', {
-        output_index: state.outputIndex,
-        item: {
-          id: state.itemId,
-          type: 'function_call',
-          call_id: state.callId,
-          name: state.name,
-          arguments: state.argsBuffer,
-          status: 'completed',
-        },
-      }),
-    ];
-  }
-
-  private closeAllItems(): BridgeSseEvent[] {
-    const events: BridgeSseEvent[] = [];
-    events.push(...this.closeReasoningIfOpen());
-    events.push(...this.closeTextIfOpen());
-    for (const state of this.toolCalls.values()) {
-      events.push(...this.closeToolCallIfOpen(state));
-    }
-    return events;
-  }
-
-  private pushReasoningDelta(text: string): BridgeSseEvent[] {
-    const events: BridgeSseEvent[] = [];
-    if (!this.reasoningItem) {
-      this.reasoningItem = { itemId: newId('rs'), outputIndex: this.nextOutputIndex, open: true, text: '' };
-      this.nextOutputIndex += 1;
-      events.push(
-        this.event('response.output_item.added', {
-          output_index: this.reasoningItem.outputIndex,
-          item: { id: this.reasoningItem.itemId, type: 'reasoning', summary: [] },
-        }),
-        this.event('response.reasoning_summary_part.added', {
-          item_id: this.reasoningItem.itemId,
-          output_index: this.reasoningItem.outputIndex,
-          summary_index: 0,
-          part: { type: 'summary_text', text: '' },
-        }),
-      );
-    }
-    const state = this.reasoningItem;
-    state.text += text;
-    events.push(
-      this.event('response.reasoning_summary_text.delta', {
-        item_id: state.itemId,
-        output_index: state.outputIndex,
-        summary_index: 0,
-        delta: text,
-      }),
-    );
-    return events;
-  }
-
-  private pushTextDelta(text: string): BridgeSseEvent[] {
-    const events: BridgeSseEvent[] = [];
-    events.push(...this.closeReasoningIfOpen());
-    if (!this.textItem) {
-      this.textItem = { itemId: newId('msg'), outputIndex: this.nextOutputIndex, open: true, text: '' };
-      this.nextOutputIndex += 1;
-      events.push(
-        this.event('response.output_item.added', {
-          output_index: this.textItem.outputIndex,
-          item: { id: this.textItem.itemId, type: 'message', role: 'assistant', status: 'in_progress', content: [] },
-        }),
-        this.event('response.content_part.added', {
-          item_id: this.textItem.itemId,
-          output_index: this.textItem.outputIndex,
-          content_index: 0,
-          part: { type: 'output_text', text: '', annotations: [] },
-        }),
-      );
-    }
-    const state = this.textItem;
-    state.text += text;
-    events.push(
-      this.event('response.output_text.delta', {
-        item_id: state.itemId,
-        output_index: state.outputIndex,
-        content_index: 0,
-        delta: text,
-      }),
-    );
-    return events;
-  }
-
-  private pushToolCallDelta(call: { index?: number; id?: string; function?: { name?: string; arguments?: string } }): BridgeSseEvent[] {
-    const events: BridgeSseEvent[] = [];
-    const index = typeof call.index === 'number' ? call.index : 0;
-    let state = this.toolCalls.get(index);
-    if (!state) {
-      // chat 流里 content 总在 tool_calls 之前;保守起见仍先闭合文本与 reasoning
-      events.push(...this.closeReasoningIfOpen());
-      events.push(...this.closeTextIfOpen());
-      state = {
-        itemId: newId('fc'),
-        outputIndex: this.nextOutputIndex,
-        callId: call.id ?? '',
-        name: call.function?.name ?? '',
-        argsBuffer: '',
-        open: true,
-      };
-      this.nextOutputIndex += 1;
-      this.toolCalls.set(index, state);
-      events.push(
-        this.event('response.output_item.added', {
-          output_index: state.outputIndex,
-          item: { id: state.itemId, type: 'function_call', call_id: state.callId, name: state.name, arguments: '', status: 'in_progress' },
-        }),
-      );
-    } else {
-      // 后续 chunk 可能补齐 id / name(多数上游只在首帧带)
-      if (call.id && !state.callId) state.callId = call.id;
-      if (call.function?.name && !state.name) state.name = call.function.name;
-    }
-    const argsDelta = call.function?.arguments;
-    if (typeof argsDelta === 'string' && argsDelta) {
-      state.argsBuffer += argsDelta;
-      events.push(
-        this.event('response.function_call_arguments.delta', {
-          item_id: state.itemId,
-          output_index: state.outputIndex,
-          delta: argsDelta,
-        }),
-      );
-    }
-    return events;
-  }
-
-  /** 按 output_index 顺序组装最终 output(response.completed / failed 用) */
-  private buildOutput(): Array<Record<string, unknown>> {
-    const items: Array<{ outputIndex: number; item: Record<string, unknown> }> = [];
-    if (this.reasoningItem) {
-      items.push({
-        outputIndex: this.reasoningItem.outputIndex,
-        item: {
-          id: this.reasoningItem.itemId,
-          type: 'reasoning',
-          summary: this.reasoningItem.text ? [{ type: 'summary_text', text: this.reasoningItem.text }] : [],
-        },
-      });
-    }
-    if (this.textItem) {
-      items.push({
-        outputIndex: this.textItem.outputIndex,
-        item: {
-          id: this.textItem.itemId,
-          type: 'message',
-          role: 'assistant',
-          status: 'completed',
-          content: [{ type: 'output_text', text: this.textItem.text, annotations: [] }],
-        },
-      });
-    }
-    for (const state of this.toolCalls.values()) {
-      items.push({
-        outputIndex: state.outputIndex,
-        item: {
-          id: state.itemId,
-          type: 'function_call',
-          call_id: state.callId,
-          name: state.name,
-          arguments: state.argsBuffer,
-          status: 'completed',
-        },
-      });
-    }
-    items.sort((a, b) => a.outputIndex - b.outputIndex);
-    return items.map((entry) => entry.item);
-  }
-}

+ 4 - 4
ai-electron/electron/service/codex/codexCtl.itest.ts

@@ -143,10 +143,10 @@ describe('CodexCtl Skill', () => {
 });
 
 describe('CodexCtl 模型 provider', () => {
-  it('端点没有 Chat 路由时给出明确原因', async () => {
+  it('端点没有 Responses 路由时给出明确原因', async () => {
     expectFailure(
       await ctl.applyProvider({ modelId: 'x', baseUrl: notFoundUrl, apiKey: 'sk-nope' }),
-      /没有 \/chat\/completions/,
+      /没有 \/responses/,
     );
   }, 120_000);
 
@@ -154,12 +154,12 @@ describe('CodexCtl 模型 provider', () => {
     const result = await ctl.probeProvider({ baseUrl: notFoundUrl });
     const data = unwrap(result) as {
       protocol: string;
-      chatStatus: number | null;
+      responsesStatus: number | null;
       detail: string;
       hint: string | null;
     };
     expect(data.protocol).toBe('unsupported');
-    expect(data.chatStatus).toBe(404);
+    expect(data.responsesStatus).toBe(404);
     expect(data.hint).toBeNull();
   }, 120_000);
 

+ 218 - 0
ai-electron/electron/service/codex/codexIdle.itest.ts

@@ -0,0 +1,218 @@
+import { createServer, type Server } from 'node:http';
+import { mkdir, mkdtemp, 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, type CodexProviderSpec } from './codexRuntime';
+
+/**
+ * 集成冒烟(不进 npm test,跑法:npm run smoke:codex):
+ * 真 codex.exe + 只说 Responses 的本地 mock,钉住"Codex 在长时间静默后会把回合判死"这条机制。
+ *
+ * 病根(源码 rust-v0.155.1):codex-api/src/sse/responses.rs:580-606 在流建立之后,每次轮询都
+ * `timeout(stream_idle_timeout_ms, stream.next())`,到期给出可重试的 CodexErr::Stream
+ * (protocol/src/error.rs:372-413),默认窗口 300 秒(model-provider-info/src/lib.rs:29)。
+ * 端点若在响应头之后长时间静默(自部署单槽推理的 prefill 就是这样),回合就会被判死并重发整轮。
+ * 客户端不做协议翻译,所以这条窗口只能靠 provider 参数放宽来治 —— 见 providerService.PROVIDER_DEFAULTS。
+ *
+ * 这里把静默与空闲窗口都压缩到几十秒内,让同一条机制能在 CI 里被观测,而不是靠推理。
+ */
+
+const SILENCE_MS = 30_000;
+
+let server: Server | null = null;
+let baseUrl = '';
+let root = '';
+/** 上游被真正问了几次:一次回合只该付一次 prefill,重试就是放大器 */
+let posts = 0;
+let homes = 0;
+let headers: 'late' | 'eager' = 'late';
+
+/**
+ * 静默 SILENCE_MS 后正常吐完一轮 Responses SSE。两种形态的差别是本测试的全部意义:
+ * - late:连响应头都不发(流还没建立);
+ * - eager:先发响应头 + response.created,再静默(流已建立,长 prefill 的真实形态)。
+ * Codex 的 `timeout(stream_idle_timeout_ms, stream.next())` 只在流已建立后武装,
+ * 所以只有 eager 形态会在 prefill 静默中被判死 —— 见 rust-v0.155.1
+ * codex-api/src/sse/responses.rs:580-606。
+ */
+async function listen(): Promise<void> {
+  server = createServer((req, res) => {
+    let body = '';
+    req.on('data', (chunk) => (body += chunk));
+    req.on('end', () => {
+      const route = req.url?.split('?')[0] ?? '';
+      if (route !== '/v1/responses' || req.method !== 'POST') {
+        res.writeHead(404, { 'content-type': 'application/json' });
+        res.end(JSON.stringify({ error: { message: 'no route' } }));
+        return;
+      }
+      posts += 1;
+      const model = (() => {
+        try {
+          return String(JSON.parse(body).model ?? '');
+        } catch {
+          return '';
+        }
+      })();
+      const send = (type: string, extra: Record<string, unknown>) =>
+        res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...extra })}\n\n`);
+      const created = () =>
+        send('response.created', {
+          response: { id: 'resp_1', object: 'response', model, status: 'in_progress', output: [] },
+        });
+      const finish = () => {
+        send('response.output_item.added', {
+          output_index: 0,
+          item: { type: 'message', id: 'msg_1', role: 'assistant', status: 'in_progress', content: [] },
+        });
+        send('response.output_text.delta', { item_id: 'msg_1', output_index: 0, content_index: 0, delta: '久等了' });
+        send('response.output_text.done', { item_id: 'msg_1', output_index: 0, content_index: 0, text: '久等了' });
+        send('response.output_item.done', {
+          output_index: 0,
+          item: {
+            type: 'message',
+            id: 'msg_1',
+            role: 'assistant',
+            status: 'completed',
+            content: [{ type: 'output_text', text: '久等了' }],
+          },
+        });
+        send('response.completed', {
+          response: {
+            id: 'resp_1',
+            object: 'response',
+            model,
+            status: 'completed',
+            output: [
+              {
+                type: 'message',
+                id: 'msg_1',
+                role: 'assistant',
+                status: 'completed',
+                content: [{ type: 'output_text', text: '久等了' }],
+              },
+            ],
+            usage: { input_tokens: 1000, output_tokens: 3, total_tokens: 1003 },
+          },
+        });
+        res.end();
+      };
+      const head = () => res.writeHead(200, { 'content-type': 'text/event-stream' });
+      if (headers === 'eager') {
+        head();
+        created();
+        setTimeout(finish, SILENCE_MS);
+        return;
+      }
+      setTimeout(() => {
+        head();
+        created();
+        finish();
+      }, SILENCE_MS);
+    });
+  });
+  await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve));
+  const address = server.address();
+  const port = typeof address === 'object' && address ? address.port : 0;
+  baseUrl = `http://127.0.0.1:${port}/v1`;
+}
+
+function spec(extra: Record<string, number>): CodexProviderSpec {
+  return {
+    id: 'idletest',
+    name: '静默端点',
+    baseUrl,
+    model: 'mock-responses',
+    apiKey: 'sk-idletest-secret-0123456789',
+    extra,
+  };
+}
+
+async function runOneTurn(mode: 'late' | 'eager', extra: Record<string, number>) {
+  headers = mode;
+  const home = join(root, `home-${++homes}`);
+  await mkdir(home, { recursive: true });
+  const runtime = new CodexRuntime({ codexHome: home, provider: spec(extra) });
+  const events: Array<{ method: string; params?: unknown }> = [];
+  runtime.on('notification', (n: { method: string; params?: unknown }) => events.push(n));
+  try {
+    await runtime.start();
+    const threadId = await runtime.startThread({ cwd: root, approvalPolicy: 'never', sandbox: 'read-only' });
+    const postsBefore = posts;
+    const result = await runtime.runTurn({ threadId, prompt: '说句话', approvalPolicy: 'never' });
+    return { result, postsInTurn: posts - postsBefore, events };
+  } finally {
+    await runtime.dispose();
+  }
+}
+
+beforeAll(async () => {
+  root = await mkdtemp(join(tmpdir(), 'zsjz-idle-'));
+  await listen();
+}, 120_000);
+
+afterAll(async () => {
+  await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
+  await rm(root, { recursive: true, force: true }).catch(() => undefined);
+});
+
+/**
+ * 把错误文案取回来:失败的回合由 turn/completed{status:'failed',error} 收尾,
+ * 但 'error' 通知先到,而 runTurn 要等带 turnId 的 turn/completed —— 按协议形状从通知里取。
+ */
+function firstErrorOf(events: Array<{ method: string; params?: unknown }>): string {
+  for (const n of events) {
+    if (n.method !== 'error') continue;
+    const params = (n.params ?? {}) as { message?: string; error?: { message?: string } };
+    const message = params.message ?? params.error?.message ?? '';
+    if (message) return message;
+  }
+  return '';
+}
+
+describe('Codex 的 SSE 空闲窗口决定长回合的生死', () => {
+  it(
+    '迟发响应头(流没建立)→ 空闲窗口再小也不会判死:计时器只在流建立后武装',
+    async () => {
+      const { result, postsInTurn } = await runOneTurn('late', {
+        stream_idle_timeout_ms: 5_000,
+        request_max_retries: 0,
+        stream_max_retries: 0,
+      });
+      expect(result.status).toBe('completed');
+      expect(postsInTurn).toBe(1);
+    },
+    120_000,
+  );
+
+  it(
+    '先建流再静默(长 prefill 的真实形态)→ 空闲窗口到点就把回合判死,且 stream_max_retries=0 时上游只被问一次',
+    async () => {
+      const { result, postsInTurn, events } = await runOneTurn('eager', {
+        stream_idle_timeout_ms: 5_000,
+        request_max_retries: 0,
+        stream_max_retries: 0,
+      });
+      expect(result.status).toBe('failed');
+      expect(firstErrorOf(events)).toMatch(/idle timeout waiting for SSE/iu);
+      expect(postsInTurn).toBe(1);
+    },
+    120_000,
+  );
+
+  it(
+    '同样的静默,把空闲窗口放宽到大于 prefill → 回合正常完成,仍然只问一次',
+    async () => {
+      const { result, postsInTurn } = await runOneTurn('eager', {
+        stream_idle_timeout_ms: 120_000,
+        request_max_retries: 0,
+        stream_max_retries: 0,
+      });
+      expect(result.status).toBe('completed');
+      expect(result.text).toContain('久等了');
+      expect(postsInTurn).toBe(1);
+    },
+    120_000,
+  );
+});

+ 9 - 8
ai-electron/electron/service/codex/codexRuntime.itest.ts

@@ -209,28 +209,29 @@ describe('切换 provider 会重启子进程', () => {
     expect(JSON.stringify(persisted)).not.toContain(API_KEY);
   });
 
-  it('应用一律经内置桥接,落盘存上游真实地址', async () => {
+  it('应用直连上游真实地址,不做协议转换', async () => {
     endpointStatus = 200;
     const applied = await applyProvider(runtime, {
-      modelId: 'smoke-chat-model',
+      modelId: 'smoke-responses-model',
       baseUrl,
       apiKey: API_KEY,
     });
-    expect(applied.bridged).toBe(true);
-    // 落盘存上游真实地址,不是桥接的本地 URL
     expect(applied.baseUrl).toBe(baseUrl);
     const config = await runtime.readConfig();
     expect(config.model_provider).toBe('zsjz');
     const entry = (config.model_providers as Record<string, Record<string, unknown>> | undefined)?.zsjz;
-    expect(entry?.base_url).not.toBe(baseUrl);
-    expect(entry?.base_url).toMatch(/127\.0\.0\.1/);
+    expect(entry?.base_url).toBe(baseUrl);
     expect(entry?.wire_api).toBe('responses');
+    // 空闲窗口必须显著大于 Codex 默认的 300 秒,否则长 prefill 会被判成流故障
+    expect(entry?.stream_idle_timeout_ms).toBe(1_800_000);
+    expect(entry?.stream_max_retries).toBe(0);
+    expect(entry?.request_max_retries).toBe(0);
   });
 
-  it('没有 Chat 路由的端点拒绝应用', async () => {
+  it('没有 Responses 路由的端点拒绝应用', async () => {
     endpointStatus = 404;
     await expect(applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY })).rejects.toThrow(
-      /没有 \/chat\/completions/,
+      /没有 \/responses/,
     );
   });
 

+ 8 - 0
ai-electron/electron/service/codex/codexRuntime.test.ts

@@ -77,6 +77,14 @@ describe('buildArgs', () => {
     expect(joined).toContain('model_providers.zsjz.supports_websockets=false');
   });
 
+  it('extra 里没有的键绝不写进 -c(supports_websockets 是 serde 默认值,属于空操作)', () => {
+    const joined = buildArgs({ ...custom, extra: { request_max_retries: 0 } }).join(' ');
+    expect(joined).not.toContain('supports_websockets');
+    // websocket_connect_timeout_ms 是真实键(15 秒那个),允许显式覆盖
+    const withWsTimeout = buildArgs({ ...custom, extra: { websocket_connect_timeout_ms: 5_000 } }).join(' ');
+    expect(withWsTimeout).toContain('model_providers.zsjz.websocket_connect_timeout_ms=5000');
+  });
+
   it('值里的引号与反斜杠被 TOML 安全转义', () => {
     const joined = buildArgs({
       ...custom,

+ 50 - 6
ai-electron/electron/service/codex/codexRuntime.ts

@@ -71,7 +71,7 @@ export interface CodexProviderSpec {
   envKey?: string;
   httpHeaders?: Record<string, string> | null;
   envHttpHeaders?: Record<string, string> | null;
-  /** 仅放行 request_max_retries / stream_max_retries / stream_idle_timeout_ms / supports_websockets / query_params */
+  /** 仅放行 PROVIDER_EXTRA_ALLOWLIST 里的键,其余在 providerService 就被丢弃 */
   extra?: Record<string, JsonValue> | null;
 }
 
@@ -180,17 +180,26 @@ interface TurnWaiter {
 }
 
 const TURN_TIMEOUT_MS = 20 * 60 * 1_000;
+/** 单条 RPC 超过这个耗时才值得进诊断(jsonRpcPeer 的预算是 30 秒) */
+const SLOW_RPC_MS = 3_000;
 /** 退出事件可能比 stderr 落地慢半拍,等一下再拼错误信息 */
 const EXIT_GRACE_MS = 300;
 const STDERR_TAIL_LINES = 40;
 export const DEFAULT_API_KEY_ENV = 'ZSJZ_CODEX_API_KEY';
-const PROVIDER_EXTRA_ALLOWLIST = new Set([
+/**
+ * 模型管理 config 里允许透传给 Codex 的 provider 字段(对应 rust-v0.155.1
+ * model-provider-info/src/lib.rs 的 ModelProviderInfo)。providerService 用它做白名单,
+ * 这里做 -c 落参,两处必须同源,否则页面提示"已丢弃"而实际写进去、或反之。
+ */
+export const PROVIDER_EXTRA_ALLOWLIST: readonly string[] = Object.freeze([
   'request_max_retries',
   'stream_max_retries',
   'stream_idle_timeout_ms',
+  'websocket_connect_timeout_ms',
   'supports_websockets',
   'query_params',
 ]);
+const PROVIDER_EXTRA_ALLOWED = new Set<string>(PROVIDER_EXTRA_ALLOWLIST);
 
 export class CodexRuntime extends EventEmitter {
   readonly #codexHome: string;
@@ -451,11 +460,19 @@ export class CodexRuntime extends EventEmitter {
     }
 
     // turn/completed 可能早于 turn/start 的响应回来,先存起来
+    const startedAt = Date.now();
+    const noteTurn = (result: TurnResult): TurnResult => {
+      this.emit(
+        'diagnostic',
+        `layer=turn turn=${result.turnId} status=${result.status} 用时 ${((Date.now() - startedAt) / 1000).toFixed(1)}s`,
+      );
+      return result;
+    };
     const early = this.#earlyTurnStates.get(turnId);
     this.#earlyTurnStates.delete(turnId);
     if (early?.completed) {
       this.#releaseRunTurnStart();
-      return early.completed;
+      return noteTurn(early.completed);
     }
 
     return new Promise<TurnResult>((resolve, reject) => {
@@ -466,10 +483,19 @@ export class CodexRuntime extends EventEmitter {
       const timer = setTimeout(() => {
         this.#turnWaiters.delete(turnId);
         void this.interruptTurn(options.threadId, turnId).catch(() => undefined);
+        this.emit(
+          'diagnostic',
+          `layer=turn turn=${turnId} 宿主侧期限已到(${Math.round(timeoutMs / 1000)} 秒),已发 turn/interrupt`,
+        );
         reject(new Error(`Codex turn ${turnId} 超时`));
       }, timeoutMs);
       timer.unref();
-      this.#turnWaiters.set(turnId, { text: early?.text ?? '', resolve, reject, timer });
+      this.#turnWaiters.set(turnId, {
+        text: early?.text ?? '',
+        resolve: (result) => resolve(noteTurn(result)),
+        reject,
+        timer,
+      });
       this.#releaseRunTurnStart();
     });
   }
@@ -608,7 +634,25 @@ export class CodexRuntime extends EventEmitter {
 
   #request<T>(method: string, params?: unknown): Promise<T> {
     if (!this.#peer) throw new Error('Codex App Server 未运行');
-    return this.#peer.request<T>(method, params);
+    const startedAt = Date.now();
+    // 只报慢的和失败的:正常一轮有几十条 RPC,全打等于没打
+    return this.#peer.request<T>(method, params).then(
+      (value) => {
+        const ms = Date.now() - startedAt;
+        if (ms > SLOW_RPC_MS) {
+          this.emit('diagnostic', `layer=rpc ${method} 用了 ${(ms / 1000).toFixed(1)} 秒`);
+        }
+        return value;
+      },
+      (error: unknown) => {
+        const ms = Date.now() - startedAt;
+        this.emit(
+          'diagnostic',
+          `layer=rpc ${method} 失败(${(ms / 1000).toFixed(1)} 秒):${sanitizeDiagnostic(error instanceof Error ? error.message : String(error))}`,
+        );
+        throw error;
+      },
+    );
   }
 
   #handleNotification(notification: { method: string; params?: unknown }): void {
@@ -713,7 +757,7 @@ export function buildArgs(provider: CodexProviderSpec | null): string[] {
     args.push('-c', `${prefix}.env_http_headers=${tomlInlineTable(provider.envHttpHeaders)}`);
   }
   for (const [key, value] of Object.entries(provider.extra ?? {})) {
-    if (!PROVIDER_EXTRA_ALLOWLIST.has(key) || value === null || value === undefined) continue;
+    if (!PROVIDER_EXTRA_ALLOWED.has(key) || value === null || value === undefined) continue;
     args.push('-c', `${prefix}.${key}=${tomlValue(value)}`);
   }
   return args;

+ 33 - 2
ai-electron/electron/service/codex/eventMapper.ts

@@ -27,7 +27,7 @@ export function notificationToEvent(notification: {
   const turn = asRecord(params.turn);
   const threadId = threadIdOf(notification);
   const turnId = readString(params.turnId) ?? readString(turn?.id) ?? 'turn';
-  const itemId = readString(params.itemId) ?? readString(item?.id) ?? undefined;
+  let itemId = readString(params.itemId) ?? readString(item?.id) ?? undefined;
   const method = notification.method;
   let kind: AgentEventKind = 'lifecycle';
   let title = 'Codex';
@@ -75,8 +75,28 @@ export function notificationToEvent(notification: {
     }
     case 'turn/started':
       title = '回合开始';
-      message = 'Codex 已开始处理当前任务';
+      // 自部署端点在首字之前整段静默是正常现象,先把预期讲清楚,免得被当成卡死
+      message = 'Codex 已开始处理当前任务;端点要把完整上下文重新 prefill,首字之前不会有任何输出';
       break;
+    case 'thread/tokenUsage/updated': {
+      // 形状取自 rust-v0.155.1 ServerNotification.json:
+      // params.tokenUsage = { last, total, modelContextWindow:int64 },last/total 都是 TokenUsageBreakdown
+      const usage = asRecord(params.tokenUsage);
+      const last = asRecord(usage?.last) ?? asRecord(usage?.total);
+      const input = readNumber(last?.inputTokens);
+      const cached = readNumber(last?.cachedInputTokens);
+      const output = readNumber(last?.outputTokens);
+      const window = readNumber(usage?.modelContextWindow);
+      kind = 'lifecycle';
+      title = '上下文用量';
+      itemId = 'tokenUsage';
+      message =
+        `本轮请求:输入 ${formatTokens(input)} tokens` +
+        (cached ? `(命中缓存 ${formatTokens(cached)})` : '') +
+        `|输出 ${formatTokens(output)} tokens` +
+        (window ? `|上下文窗口 ${formatTokens(window)} tokens` : '');
+      break;
+    }
     case 'turn/completed':
       title = '回合结束';
       message = `状态:${readString(turn?.status) ?? 'completed'}`;
@@ -180,3 +200,14 @@ function asRecord(value: unknown): Record<string, unknown> | null {
 function readString(value: unknown): string | null {
   return typeof value === 'string' ? value : null;
 }
+
+function readNumber(value: unknown): number | null {
+  return typeof value === 'number' && Number.isFinite(value) ? value : null;
+}
+
+/** 46122 → "4.6 万":给页面读的,不是给机器算的 */
+function formatTokens(value: number | null): string {
+  if (value === null) return '未知';
+  if (value >= 10_000) return `${(value / 10_000).toFixed(1)} 万`;
+  return String(value);
+}

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

@@ -3,7 +3,6 @@ import { mkdir } from 'node:fs/promises';
 import { getMainWindow } from 'ee-core/electron';
 import { logger } from 'ee-core/log';
 import { ApprovalBroker } from './approvalBroker';
-import { chatBridge } from './chatBridgeService';
 import { ensureCodexHome, getCodexDataDir, getCodexHome, getEventsDir } from './codexHome';
 import { CodexRuntime, type RuntimeStatus } from './codexRuntime';
 import { EventLog } from './eventLog';
@@ -70,8 +69,6 @@ export async function getCodex(): Promise<CodexContainer> {
   if (creating) return creating;
   creating = (async () => {
     await ensureCodexHome();
-    // 桥接的诊断出口统一进环形缓冲(providerService 启动桥接时不再单独传)
-    chatBridge.setDiagnostic(pushDiagnostic);
     const runtime = new CodexRuntime({ codexHome: getCodexHome() });
     const broker = new ApprovalBroker(runtime);
     const eventLog = new EventLog(getEventsDir());
@@ -130,7 +127,6 @@ export function setAppliedProvider(value: AppliedProviderInfo | null): void {
 }
 
 export function toStatusResult(status: RuntimeStatus): CodexStatusResult {
-  const bridgeInfo = chatBridge.info;
   return {
     state: status.state,
     running: status.state === 'ready',
@@ -142,7 +138,6 @@ export function toStatusResult(status: RuntimeStatus): CodexStatusResult {
     degraded: status.degraded,
     error: status.error,
     applied: appliedCache,
-    bridge: bridgeInfo ? { running: true, upstream: bridgeInfo.upstreamBaseUrl } : null,
   };
 }
 
@@ -159,8 +154,6 @@ export async function defaultWorkspaceDir(): Promise<string> {
 }
 
 export async function disposeCodex(): Promise<void> {
-  // 桥接不挂在 container 上,有没有 container 都要兜底停掉
-  await chatBridge.stop().catch(() => undefined);
   const current = container;
   container = null;
   if (!current) return;

+ 56 - 44
ai-electron/electron/service/codex/providerService.test.ts

@@ -3,7 +3,6 @@ import { mkdtempSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
-import { chatBridge } from './chatBridgeService';
 import type { CodexProviderSpec, CodexRuntime } from './codexRuntime';
 import {
   applyProvider,
@@ -25,6 +24,7 @@ vi.mock('./codexHome', async (importOriginal) => {
 
 let server: Server;
 let port = 0;
+let responsesStatus = 404;
 let chatStatus = 404;
 let lastRequest: { url: string | null; auth: string | null; body: string | null } = { url: null, auth: null, body: null };
 
@@ -39,7 +39,11 @@ beforeAll(async () => {
         body: Buffer.concat(chunks).toString('utf8') || null,
       };
       res.setHeader('content-type', 'application/json');
-      const status = req.url?.endsWith('/chat/completions') ? chatStatus : 404;
+      const status = req.url?.endsWith('/responses')
+        ? responsesStatus
+        : req.url?.endsWith('/chat/completions')
+          ? chatStatus
+          : 404;
       res.writeHead(status).end(JSON.stringify({ status }));
     });
   });
@@ -98,15 +102,18 @@ describe('toProviderSpec', () => {
     expect(spec.apiKey ?? null).toBeNull();
   });
 
-  it('自部署端点默认关掉重试放大、放宽空闲超时、不走 websocket', () => {
+  it('空闲窗口必须显著大于 Codex 默认的 300 秒,且不写等于默认值的无效项', () => {
     const spec = toProviderSpec({ modelId: 'm', baseUrl: 'http://10.66.66.66:8080/v1' });
-    // 单槽推理上一次 prefill 要几分钟,Codex 默认 5 次重试 = 同样几分钟的活儿重复五遍
     expect(spec.extra).toMatchObject({
       request_max_retries: 0,
       stream_max_retries: 0,
-      stream_idle_timeout_ms: 300_000,
-      supports_websockets: false,
+      // Codex 默认 300_000(model-provider-info/src/lib.rs:29);写 300_000 等于没写
+      stream_idle_timeout_ms: 1_800_000,
     });
+    expect(spec.extra).toBeTypeOf('object');
+    // supports_websockets 的 serde 默认就是 false,用 -c 声明的 provider 根本不走 WS,
+    // 写进默认值只会让下一位误以为这里防住了什么(15 秒那条说法来自 websocket 握手超时)
+    expect(spec.extra).not.toHaveProperty('supports_websockets');
   });
 
   it('模型管理 config 里显式写的值优先于默认', () => {
@@ -121,6 +128,7 @@ describe('toProviderSpec', () => {
       request_max_retries: 0,
     });
   });
+
   it('缺 modelId 或缺 baseUrl 直接报错,不做任何地址兜底', () => {
     expect(() => toProviderSpec({ modelId: '  ' })).toThrow(/modelId/);
     expect(() => toProviderSpec({ modelId: 'gpt' })).toThrow(/base_url/);
@@ -144,48 +152,57 @@ describe('toProviderSpec', () => {
 describe('probeEndpoint', () => {
   const base = () => `http://127.0.0.1:${port}/v1`;
 
-  it('只探 Chat 路由存在性,请求体不带 messages(不能触发推理)', async () => {
-    chatStatus = 400;
+  it('只探 Responses 路由存在性,请求体不带 input(不能触发推理)', async () => {
+    responsesStatus = 400;
     const result = await probeEndpoint({
       baseUrl: `${base()}/`,
       modelId: 'qwen3-max',
       apiKey: 'sk-test-key',
     });
-    expect(result.protocol).toBe('chat');
-    expect(lastRequest.url).toBe('/v1/chat/completions');
+    expect(result.protocol).toBe('responses');
+    expect(lastRequest.url).toBe('/v1/responses');
     expect(lastRequest.auth).toBe('Bearer sk-test-key');
     const body = JSON.parse(lastRequest.body ?? '{}') as Record<string, unknown>;
     expect(body.model).toBe('qwen3-max');
-    // 一旦带上 messages,单槽本地服务就会真的开始生成,实测一次要 14 秒
-    expect(body.messages).toBeUndefined();
+    // 一旦带上 input,单槽本地服务就会真的开始生成,实测一次要 14 秒
+    expect(body.input).toBeUndefined();
   });
 
-  it('2xx 与 4xx 只要有路由就判 chat 可用', async () => {
+  it('2xx 与 4xx 只要有路由就判 responses 可用', async () => {
     for (const status of [200, 400, 401]) {
-      chatStatus = status;
+      responsesStatus = status;
       const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
-      expect(result).toMatchObject({ protocol: 'chat', chatStatus: status });
+      expect(result).toMatchObject({ protocol: 'responses', responsesStatus: status });
     }
   });
 
-  it('没有 Chat 路由判 unsupported', 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(/不做桥接/);
+  });
+
+  it('两条路由都没有 → unsupported', async () => {
+    responsesStatus = 404;
     chatStatus = 404;
     const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
     expect(result.protocol).toBe('unsupported');
-    expect(result.detail).toMatch(/没有 \/chat\/completions/);
   });
 
   it('端点不可达时报错带上是哪个地址', async () => {
     const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
     expect(result.protocol).toBe('unreachable');
-    expect(result.chatStatus).toBeNull();
+    expect(result.responsesStatus).toBeNull();
     expect(result.detail).toMatch(/不可达/);
-    expect(result.detail).toContain('http://127.0.0.1:1/v1/chat/completions');
+    expect(result.detail).toContain('http://127.0.0.1:1/v1/responses');
   });
 });
 
 /**
- * 客户端真正走的是 applyProvider:探测 → 起桥接 → 把 spec 交给运行时重启子进程。
+ * 客户端真正走的是 applyProvider:探端点 → 把 spec 交给运行时重启子进程。
  * 这里用假运行时把「交给 Codex 的最终参数」钉死 —— 真机 smoke 用的是手搓 spec,覆盖不到这一段。
  */
 describe('applyProvider 交给运行时的 spec', () => {
@@ -199,8 +216,8 @@ describe('applyProvider 交给运行时的 spec', () => {
     }
   }
 
-  it('一律自定义 provider + 桥接地址,并带上单槽端点该有的重试/超时开关', async () => {
-    chatStatus = 200;
+  it('Codex 直连上游真实地址,并带上有效的重试/空闲窗口参数', async () => {
+    responsesStatus = 400;
     const stub = new StubRuntime();
     const upstream = `http://127.0.0.1:${port}/v1`;
     const applied = await applyProvider(stub as unknown as CodexRuntime, {
@@ -209,33 +226,28 @@ describe('applyProvider 交给运行时的 spec', () => {
       baseUrl: upstream,
       modelRecordId: 7,
     });
-    try {
-      const spec = stub.spec;
-      expect(spec).not.toBeNull();
-      expect(spec?.id).toBe('zsjz');
-      expect(spec?.model).toBe('qwen');
-      // Codex 连的是本地桥接,不是上游
-      expect(spec?.baseUrl).toBe(chatBridge.info?.url);
-      expect(spec?.baseUrl).not.toBe(upstream);
-      expect(spec?.extra).toMatchObject({
-        request_max_retries: 0,
-        stream_max_retries: 0,
-        stream_idle_timeout_ms: 300_000,
-        supports_websockets: false,
-      });
-      // 落盘的是上游真实地址,便于页面展示
-      expect(applied).toMatchObject({ model: 'qwen', modelRecordId: '7', baseUrl: upstream, bridged: true });
-    } finally {
-      await chatBridge.stop();
-    }
+    const spec = stub.spec;
+    expect(spec).not.toBeNull();
+    expect(spec?.id).toBe('zsjz');
+    expect(spec?.model).toBe('qwen');
+    // 不做任何翻译:Codex 连的就是模型记录里那个地址
+    expect(spec?.baseUrl).toBe(upstream);
+    expect(spec?.extra).toMatchObject({
+      request_max_retries: 0,
+      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');
   });
 
-  it('上游没有 Chat 路由时直接报错,且不碰运行时', async () => {
-    chatStatus = 404;
+  it('端点只会 Chat 时直接报错,且不碰运行时', async () => {
+    responsesStatus = 404;
+    chatStatus = 400;
     const stub = new StubRuntime();
     await expect(
       applyProvider(stub as unknown as CodexRuntime, { modelId: 'qwen', baseUrl: `http://127.0.0.1:${port}/v1` }),
-    ).rejects.toThrow(/没有 \/chat\/completions/);
+    ).rejects.toThrow(/没有 \/responses/);
     expect(stub.calls).toBe(0);
     expect(stub.spec).toBeUndefined();
   });

+ 57 - 51
ai-electron/electron/service/codex/providerService.ts

@@ -1,8 +1,7 @@
 import { mkdir, readFile, writeFile } from 'node:fs/promises';
 import { join } from 'node:path';
-import { chatBridge } from './chatBridgeService';
 import { getCodexDataDir } from './codexHome';
-import { DEFAULT_API_KEY_ENV, type CodexProviderSpec, type CodexRuntime, type JsonValue } from './codexRuntime';
+import { DEFAULT_API_KEY_ENV, PROVIDER_EXTRA_ALLOWLIST, type CodexProviderSpec, type CodexRuntime, type JsonValue } from './codexRuntime';
 import type { AppliedProviderInfo, ApplyProviderInput } from './types';
 
 /**
@@ -16,38 +15,40 @@ export const PROVIDER_ID = 'zsjz';
 export type AppliedProvider = AppliedProviderInfo;
 
 /**
- * 端点判定。后端「模型管理」里的记录一律是 Chat 协议(没有 /responses),
- * 所以这里只关心「能不能用 Chat」:chat → 走内置桥接;其余两种拒绝。
+ * 端点协议判定。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 的端点一律拒绝应用,把原因说清楚。
  */
-export type EndpointProtocol = 'chat' | 'unsupported' | 'unreachable';
+export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
 
 export interface EndpointProbeResult {
   protocol: EndpointProtocol;
-  /** /chat/completions 的 HTTP 状态;未探或网络失败为 null */
+  /** /responses 的 HTTP 状态;未探或网络失败为 null */
+  responsesStatus: number | null;
+  /** /chat/completions 的 HTTP 状态;只在需要区分 chat-only 时才探,未探为 null */
   chatStatus: number | null;
   detail: string;
 }
 
-const EXTRA_ALLOWLIST: readonly string[] = [
-  'request_max_retries',
-  'stream_max_retries',
-  'stream_idle_timeout_ms',
-  'supports_websockets',
-  'query_params',
-];
+const EXTRA_ALLOWLIST: readonly string[] = PROVIDER_EXTRA_ALLOWLIST;
 
 /**
- * 自部署端点多是单槽推理:实测一轮 Codex 请求的 prompt 有 4.6 万 token,
- * 而这台机器 prefill 只有 ~29 token/s —— Codex 默认每 15 秒掐一次流并重试 5 次,
- * 等于把同样几分钟的活儿重复五遍,还会把前缀缓存挤掉。所以默认不重试、空闲超时放宽,
- * 并声明不走 websocket(Codex 对本地 provider 本来也没发 upgrade,这里显式关掉免得哪天再试)。
+ * 自部署端点多是单槽推理:实测一轮 prompt 4.6 万 token、prefill 只有 ~29 token/s,
+ * 首字之前可以静默十几分钟。Codex 在流建立后每次轮询都套一个
+ * `timeout(stream_idle_timeout_ms, stream.next())`(rust-v0.155.1
+ * codex-api/src/sse/responses.rs:580-606),默认 300_000(model-provider-info/src/lib.rs:29),
+ * 到期给出可重试的 CodexErr::Stream(protocol/src/error.rs:372-413),按 stream_max_retries
+ * (默认 5)重发整轮 —— 把几分钟的活儿重复五遍,还挤掉前缀缓存。
+ *
+ * 所以这里:空闲窗口放宽到远大于最差 prefill;两个重试都关到 0。
+ * 注意不要再写 `supports_websockets=false`:那是 serde 默认值,用 -c 声明的 provider 从来不走
+ * WebSocket,那个 15 秒是 websocket_connect_timeout_ms,与此无关(此前一条错误注释把六次修改带偏)。
  * 模型管理 config 里显式写的值优先。
  */
 const PROVIDER_DEFAULTS: Readonly<Record<string, JsonValue>> = Object.freeze({
   request_max_retries: 0,
   stream_max_retries: 0,
-  stream_idle_timeout_ms: 300_000,
-  supports_websockets: false,
+  stream_idle_timeout_ms: 1_800_000,
 });
 
 function providerFile(): string {
@@ -65,7 +66,7 @@ export function baseUrlHint(baseUrl: string | null): string | null {
   if (!baseUrl) return '未配置 base_url';
   if (!/^https?:\/\//u.test(baseUrl)) return 'base_url 必须以 http:// 或 https:// 开头';
   if (!/\/v\d+$/u.test(baseUrl)) {
-    return 'base_url 通常应写到版本段(如 .../v1),否则拼出的 /chat/completions 可能 404';
+    return 'base_url 通常应写到版本段(如 .../v1),否则拼出的 /responses 可能 404';
   }
   return null;
 }
@@ -191,16 +192,19 @@ function unreachableDetail(url: string, attempt: ProbeAttempt): string {
   return `端点不可达:${url} —— ${attempt.error ?? '网络错误'}`;
 }
 
-function probeResult(partial: Partial<EndpointProbeResult> & { protocol: EndpointProtocol; detail: string }): EndpointProbeResult {
-  return { chatStatus: null, ...partial };
+function probeResult(
+  partial: Partial<Omit<EndpointProbeResult, 'protocol' | 'detail'>> & {
+    protocol: EndpointProtocol;
+    detail: string;
+  },
+): EndpointProbeResult {
+  return { responsesStatus: null, chatStatus: null, ...partial };
 }
 
 /**
- * 判定端点能不能用 Chat 协议——后端「模型管理」里的记录都是 Chat 端点,
- * 有的就交给内置桥接。
- *
- * 只看 /chat/completions 这条路由在不在:请求体故意不带 messages,
- * 端点会在校验阶段就回 400(实测 15ms),不会真的开始生成。
+ * 判定端点会不会说 Responses。只问「路由在不在」,绝不让端点真去生成:本地服务多是单槽推理,
+ * 一次生成能占住整个 HTTP 服务几十秒(实测 max_tokens:1 也要 14 秒,期间连 /models 都不应答)。
+ * 请求体故意不带 input,端点会在校验阶段就回 400(实测 15–27ms)。
  * 代价是模型名写错要到真正提问时才暴露。
  */
 export async function probeEndpoint(params: {
@@ -209,24 +213,40 @@ export async function probeEndpoint(params: {
   apiKey?: string | null;
 }): Promise<EndpointProbeResult> {
   const baseUrl = params.baseUrl.replace(/\/+$/u, '');
+  const responsesUrl = `${baseUrl}/responses`;
   const chatUrl = `${baseUrl}/chat/completions`;
   const model = params.modelId?.trim() || 'probe';
 
-  const chat = await postProbe(chatUrl, { model }, params.apiKey);
-  if (chat.status === null) {
-    return probeResult({ protocol: 'unreachable', detail: unreachableDetail(chatUrl, chat) });
+  const responses = await postProbe(responsesUrl, { model }, params.apiKey);
+  if (responses.status === null) {
+    return probeResult({ protocol: 'unreachable', detail: unreachableDetail(responsesUrl, responses) });
+  }
+  if (responses.status !== 404 && responses.status !== 405) {
+    return probeResult({
+      protocol: 'responses',
+      responsesStatus: responses.status,
+      detail: `端点支持 Responses 协议(HTTP ${responses.status}),Codex 直连 ${responsesUrl}`,
+    });
   }
-  if (chat.status !== 404 && chat.status !== 405) {
+
+  // /responses 不在:区分「只会 Chat」和「两条路由都没有」,前者的话要说得能让人去修端点
+  const chat = await postProbe(chatUrl, { model }, params.apiKey);
+  if (chat.status !== null && chat.status !== 404 && chat.status !== 405) {
     return probeResult({
-      protocol: 'chat',
+      protocol: 'chat-only',
+      responsesStatus: responses.status,
       chatStatus: chat.status,
-      detail: `端点支持 Chat 协议(HTTP ${chat.status}),应用时经内置桥接转换为 Responses`,
+      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}"}')`,
     });
   }
   return probeResult({
     protocol: 'unsupported',
+    responsesStatus: responses.status,
     chatStatus: chat.status,
-    detail: `端点没有 /chat/completions 路由(HTTP ${chat.status}),本客户端只对接 Chat 协议端点`,
+    detail: `端点没有 /responses 路由(HTTP ${responses.status}),Codex 只能按 Responses 协议对接 ${responsesUrl}`,
   });
 }
 
@@ -250,8 +270,6 @@ export async function applyProvider(
   input: ApplyProviderInput & { modelRecordId?: string | number | null },
 ): Promise<AppliedProvider> {
   const spec = toProviderSpec(input);
-  /** 落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */
-  const displayBaseUrl = spec.baseUrl;
 
   // 一定要先探端点:应用成功后才发现连不上,比这里直接报错更难排查
   const probe = await probeEndpoint({
@@ -259,37 +277,25 @@ export async function applyProvider(
     modelId: spec.model,
     apiKey: spec.apiKey,
   });
-  if (probe.protocol !== 'chat') throw new Error(probe.detail);
+  if (probe.protocol !== 'responses') throw new Error(probe.detail);
 
-  // Codex 只会发 Responses,所以 Chat 端点一律经内置桥接:Codex 改连本地代理的 /v1/responses
-  const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null });
-  spec.baseUrl = bridge.url;
-
-  try {
-    await runtime.applyProvider(spec);
-  } catch (error) {
-    // 应用失败回滚桥接,避免留下一个指着旧上游的孤儿代理
-    await chatBridge.stop();
-    throw error;
-  }
+  await runtime.applyProvider(spec);
 
   const applied: AppliedProvider = {
     model: spec.model,
     modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null
       ? null
       : String(input.modelRecordId),
-    baseUrl: displayBaseUrl,
+    baseUrl: spec.baseUrl,
     providerId: spec.id,
     name: spec.name ?? null,
     appliedAt: new Date().toISOString(),
-    bridged: true,
   };
   await writeAppliedProvider(applied);
   return applied;
 }
 
 export async function clearProvider(runtime: CodexRuntime): Promise<void> {
-  await chatBridge.stop();
   await runtime.applyProvider(null);
   await writeAppliedProvider(null);
 }

+ 1 - 9
ai-electron/electron/service/codex/types.ts

@@ -39,7 +39,7 @@ export interface BackendModelRecord {
 
 /**
  * 应用到 Codex 运行时的入参,字段直接取自后端「模型管理」的 ModelVO。
- * 后端记录一律是 Chat 协议端点,不需要厂商协议类型:判定与转换由探测 + 内置桥接负责。
+ * 协议由探测判定(providerService.probeEndpoint),客户端不做任何协议翻译。
  */
 export interface ApplyProviderInput {
   modelId: string;
@@ -71,8 +71,6 @@ export interface CodexStatusResult {
   error: string | null;
   /** 上次应用的模型(不含密钥);重启后需要页面重新应用才能注入 api key */
   applied: AppliedProviderInfo | null;
-  /** 内置 Responses→Chat 桥接运行状态;未桥接为 null */
-  bridge?: { running: boolean; upstream: string | null } | null;
 }
 
 export interface AppliedProviderInfo {
@@ -82,12 +80,6 @@ export interface AppliedProviderInfo {
   providerId: string | null;
   name: string | null;
   appliedAt: string;
-  /**
-   * 恒为 true:后端记录都是 Chat 端点,一律经内置桥接接入。
-   * 此时 baseUrl 存的是上游真实地址,桥接的本地 URL 每次应用临时分配、不落盘,
-   * 重启后由页面重新「应用」重建。
-   */
-  bridged?: boolean;
 }
 
 /** 主进程 → 渲染进程的事件推送 channel */

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

@@ -160,7 +160,7 @@
         <div class="ai-plugin__hint">
           本客户端<b>不做任何 Codex / OpenAI 账号登录</b>:模型来自后端「模型管理」, 选中后由主进程写入本机 Codex
           配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 只会发 Responses 协议,
-          而这里配置的模型都是 <b>/chat/completions</b> 端点,所以一律经内置桥接转换后再交给 Codex。
+          端点必须能回答 <b>POST {base}/responses</b>;客户端<b>不做协议桥接</b>,只会 Chat 的端点会在「应用」时被拒绝。
         </div>
 
         <div class="ai-plugin__panel">
@@ -174,12 +174,6 @@
             <b class="ai-plugin__mono">{{ status?.defaultModel || '未配置' }}</b>
             <span>Provider</span>
             <b class="ai-plugin__mono">{{ status?.providerId || '—' }}</b>
-            <template v-if="status?.bridge?.running">
-              <span>桥接</span>
-              <b class="ai-plugin__mono ai-plugin__ellipsis" :title="status.bridge.upstream || ''">
-                {{ status.bridge.upstream || '运行中' }}
-              </b>
-            </template>
             <span>CODEX_HOME</span>
             <b class="ai-plugin__mono ai-plugin__ellipsis" :title="status?.codexHome">
               {{ status?.codexHome || '—' }}
@@ -515,16 +509,18 @@
 
   const selectedModel = computed(() => models.value.find((item) => item.id === selectedModelId.value) || null);
 
-  /** 探测结果:chat 可用(绿)/ 没有 Chat 路由或连不上(红) */
+  /** 探测结果:responses 可用(绿)/ 其余三种都不能对接(红) */
   const probeAlertType = computed<'success' | 'error'>(() =>
-    probeResult.value?.protocol === 'chat' ? 'success' : 'error',
+    probeResult.value?.protocol === 'responses' ? 'success' : 'error',
   );
   const probeAlertTitle = computed(() => {
     switch (probeResult.value?.protocol) {
-      case 'chat':
-        return '端点可用(Chat 协议,应用时经内置桥接转换)';
+      case 'responses':
+        return '端点可用(Responses 协议,Codex 直连)';
+      case 'chat-only':
+        return '端点只会 Chat 协议,客户端不做协议桥接,无法对接';
       case 'unsupported':
-        return '端点没有 Chat 协议路由,无法对接';
+        return '端点没有 Responses 协议路由,无法对接';
       default:
         return '端点不可达';
     }

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

@@ -98,8 +98,6 @@ export interface AppliedProviderInfo {
   providerId: string | null;
   name: string | null;
   appliedAt: string;
-  /** 恒为 true:模型都是 Chat 端点,经内置桥接接入;此时 baseUrl 是上游真实地址 */
-  bridged?: boolean;
 }
 
 export interface CodexStatus {
@@ -113,8 +111,6 @@ export interface CodexStatus {
   degraded: boolean;
   error: string | null;
   applied: AppliedProviderInfo | null;
-  /** 内置 Responses→Chat 桥接运行状态;未桥接为 null */
-  bridge?: { running: boolean; upstream: string | null } | null;
 }
 
 export function codexPing(): Promise<CodexPingResult> {
@@ -150,11 +146,15 @@ export interface ApplyProviderInput {
   modelRecordId?: string | number | null;
 }
 
-/** 端点判定:chat → 应用时走内置桥接;unsupported 没有 Chat 路由;unreachable 网络不可达 */
-export type EndpointProtocol = 'chat' | 'unsupported' | 'unreachable';
+/**
+ * 端点判定:responses → Codex 直连;chat-only → 只会 /chat/completions,本客户端不做协议桥接,拒绝应用;
+ * unsupported → 两条路由都没有;unreachable → 网络不可达
+ */
+export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
 
 export interface ProviderProbeResult {
   protocol: EndpointProtocol;
+  responsesStatus: number | null;
   chatStatus: number | null;
   detail: string;
   hint: string | null;

+ 1 - 1
ai-electron/scripts/codex-ipc-probe.html

@@ -113,7 +113,7 @@
             : { success: true, data: { cleaned: true } };
         });
 
-        await check('probeProvider 不可达端点', () => call('probeProvider', { baseUrl: 'http://127.0.0.1:9/v1' }), expectData((d) => d.compatible === false && d.status === null));
+        await check('probeProvider 不可达端点', () => call('probeProvider', { baseUrl: 'http://127.0.0.1:9/v1' }), expectData((d) => d.protocol === 'unreachable' && d.responsesStatus === null && String(d.detail || '').includes('http://127.0.0.1:9/v1/responses')));
 
         // 用本地假端点跑一次真实 apply / clear(不触碰任何真实模型密钥)
         const http = require('node:http');

+ 166 - 0
docs/CODEX.md

@@ -0,0 +1,166 @@
+# Codex 桌面端集成(ai-electron)
+
+这份文档是 Codex 链路的**唯一权威说明**:走什么协议、一次消息经历了什么、系统里一共有哪几个计时器、
+以及端点必须满足什么条件。改这块代码前先读完第 3、4 节 —— 前六个提交之所以没收口,
+就是因为按一份写错的超时说明在调参。
+
+代码位置:主进程 `ai-electron/electron/service/codex/`,控制器 `ai-electron/electron/controller/codexCtl.ts`,
+渲染进程 `ai-electron/frontend/src/codex/`。参照实现:`E:\workspace\ai\Noobi.ai\src\main\`(本目录多数文件由它移植)。
+
+---
+
+## 1. 通信方式:stdio 上的换行分隔 JSON-RPC
+
+```
+渲染进程 ──IPC(controller/codexCtl/<方法>)──> 主进程
+                                              │  spawn(codex.exe, ['app-server','--listen','stdio://', ...])
+                                              │  stdin  ← 一行一个 JSON 请求
+                                              │  stdout → 一行一个 JSON 响应 / 通知 / 服务端请求
+                                              ▼
+                                        codex app-server 子进程
+                                              │  HTTPS,Responses 协议(Codex 自己管,我们不参与)
+                                              ▼
+                                        模型端点(模型管理里那条记录的 base_url)
+```
+
+- **只有这一条通道。没有 WebSocket,没有 HTTP 服务,没有轮询。**
+  客户端 ↔ Codex 走 stdio JSONL;`ws://` 只是 `codex app-server --listen` 可选的**服务端监听形态**,我们不用。
+- Codex 内部确实有一条 Responses-over-WebSocket 通道,那是**它连模型端点**时的一种传输选择,
+  由 `model_providers.<id>.supports_websockets` 决定。用 `-c` 声明的 provider 该字段是 serde 默认值 `false`,
+  所以我们这条路上永远不走 WS(见第 3 节,那句"15 秒"的误判就出在这里)。
+- 子进程只有一个,被所有会话共用;退出即置 `error`,不自动重启(下一次操作懒启动)。
+
+与 Noobi.ai 的文件级对照(同名即同实现,超时/重试策略也照它):
+
+| 我们 | Noobi.ai | 职责 |
+| --- | --- | --- |
+| `jsonRpcPeer.ts` | `src/main/jsonRpcPeer.ts` | JSONL 通道、30 秒单请求预算、16/48MiB 行上限 |
+| `codexRuntime.ts` | `src/main/codexAppServer.ts` | 子进程生命周期、RPC 封装、回合等待 |
+| `eventMapper.ts` | `src/main/eventMapper.ts` | 通知 → 统一的 AgentEvent |
+| `approvalBroker.ts` | `src/main/approvalBroker.ts` | 服务端请求(审批)转发与回包 |
+| `eventLog.ts` | `src/main/eventLog.ts` | 事件 JSONL 落盘,供 UI 回放 |
+| `codexLocator.ts` | `src/main/codexLocator.ts` | 找 vendor 里的 codex 二进制 |
+
+## 2. 一条消息的完整时序
+
+1. 渲染进程 `store.send(text)`;首次发送前 `ensureModelApplied()` → 主进程 `applyProvider`:
+   探端点(第 4 节)→ 重启子进程并注入 `-c`(api key 走子进程环境变量,不落盘)。
+2. `thread/start`(带 cwd/sandbox/approvalPolicy/model)拿 `threadId`,会话与线程 1:1。
+3. `turn/start` **立即返回** `turn{id, status:"inProgress"}`(实测 0.3 秒),不阻塞等生成。
+4. 生成期间是**通知流**:`item/started`、`item/agentMessage/delta`、`item/reasoning/summaryTextDelta`、
+   `item/commandExecution/*`、`item/fileChange/patchUpdated`、`turn/plan/updated`、
+   `thread/tokenUsage/updated`(我们把它显示成"上下文用量"一行)。
+5. 需要授权时 Codex 反过来发**服务端请求**(`item/commandExecution/requestApproval` 等),
+   `approvalBroker` 转给页面,页面回 `accept/decline/...`;2 分钟无答复按拒绝收尾。
+6. `turn/completed{status}` 收尾;失败也一定会有这一条(`status:"failed"` + `turn.error`)。
+   所以"回合卡住"只会是端点在慢慢 prefill,不会是结局没送到。
+7. 子进程重启/换模型后旧 `threadId` 失效:`turn/start` 报 thread not found 时先 `thread/resume`
+   再发**一次**(这次 RPC 失败时上游还没开始生成,不会重复付 prefill);resume 也失败就提示新建会话。
+
+## 3. 超时与重试:一张表说了算
+
+### 我们这一侧(ai-electron)
+
+| 常量 | 值 | 位置 | 管什么 |
+| --- | --- | --- | --- |
+| 单条 RPC 预算 | 30 秒 | `jsonRpcPeer.ts` `request(..., timeoutMs = 30_000)` | 请求→响应。**不包回合时长**:`turn/start` 是异步的,秒回 |
+| 回合宿主期限 | 20 分钟 | `codexRuntime.ts` `TURN_TIMEOUT_MS` | 到点发 `turn/interrupt` 并判失败(对齐 Noobi 同值) |
+| 端点探测 | 15 秒 | `providerService.ts` `PROBE_TIMEOUT_MS` | 只问路由在不在,绝不触发推理 |
+| 审批等待 | 2 分钟 | `approvalBroker.ts` `APPROVAL_TIMEOUT_MS` | 无人应答按拒绝收尾 |
+| 慢 RPC 诊断 | 3 秒 | `codexRuntime.ts` `SLOW_RPC_MS` | 超过才记一行 `layer=rpc`,不刷屏 |
+
+**我们不做任何重试。** 一次回合 = 上游一次生成。这是硬规则:自部署端点多是单槽推理,
+重试不是容错,是把同一份几分钟的活儿再排一遍队。
+
+### Codex 那一侧(源码 `rust-v0.155.1`,我们用 `-c` 改的就是这些)
+
+| 参数 | Codex 默认 | 我们注入 | 源码 |
+| --- | --- | --- | --- |
+| `stream_idle_timeout_ms` | **300_000** | **1_800_000** | `model-provider-info/src/lib.rs:29`;计时器在 `codex-api/src/sse/responses.rs:580-606` |
+| `stream_max_retries` | 5 | 0 | `lib.rs:30`;重试判定 `core/src/responses_retry.rs:108`、可重试集合 `protocol/src/error.rs:372-413` |
+| `request_max_retries` | 4 | 0 | `lib.rs:31` |
+| `websocket_connect_timeout_ms` | **15_000** | 不写(走不到) | `lib.rs:34`;只包 WS 握手 `core/src/client.rs:1081-1099` |
+| `supports_websockets` | serde 默认 `false` | 不写 | `lib.rs:149-151` |
+
+**这段是以前那次误判的正面记录**,别再走一遍:
+
+- 客户端日志里那句 `request timed out` + `retrying sampling request (n/5)` + `Falling back from WebSockets to HTTPS transport`
+  只可能出自 **WebSocket 连接路径**(15 秒那个)。而我们声明的 provider `supports_websockets=false`,
+  这条路一次都不会走 —— 所以"每 15 秒掐一次流"从来不存在。
+- 真正会咬人的是 `stream_idle_timeout_ms`:流建立之后,Codex 每次轮询都套 300 秒的表;
+  长时间没有**新事件**就抛可重试的 `CodexErr::Stream("idle timeout waiting for SSE")`。
+  单槽端点 prefill 几分钟到二十几分钟,正好撞在这把刀上。
+- 之前提交里"修复"的四项参数有两项是空操作:`stream_idle_timeout_ms=300000` 恰等于默认值、
+  `supports_websockets=false` 也恰等于默认值。写它们等于什么都没改。
+- 实测(真 codex.exe + 受控 mock,`electron/service/codex/codexIdle.itest.ts`,三条都过):
+  1. 连响应头都不发、静默 30 秒,`stream_idle_timeout_ms=5000` → **不判死**(计时器只在流建立后武装);
+  2. 先发响应头 + `response.created` 再静默 30 秒,同样 5 秒窗口 → **判死**,文案
+     `idle timeout waiting for SSE`,且 `stream_max_retries=0` 时上游只被问 **1 次**;
+  3. 同样的静默,窗口放宽到 120 秒 → **正常完成**,仍是 1 次请求。
+     这条就是我们把默认窗口抬到 30 分钟的依据。
+
+## 4. 端点必须会答 `/v1/responses`
+
+Codex 0.155.1 起 `wire_api = "chat"` 被删掉,且**加载 config.toml 时就报错**,不是运行时降级:
+
+```
+Error loading config.toml: `wire_api = "chat"` is no longer supported.
+How to fix: set `wire_api = "responses"` in your provider config.
+```
+
+实测 0.146.0 / 0.151.0 / 0.155.1 三个版本行为一致。曾经为此内置过一个 Responses↔Chat 桥接,
+现在**整个删掉**:不做协议翻译,端点不会说 Responses 就拒绝应用模型,并把地址、状态码、自证命令一起报出来。
+
+自证(4xx = 路由存在,可用;404/405 = 该构建不支持,需要换端点):
+
+```bash
+curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
+  -X POST "<base_url>/responses" -H 'content-type: application/json' \
+  -d '{"model":"<模型名>"}'
+```
+
+请求体故意不带 `input`:单槽服务一旦真开始生成就把整个 HTTP 服务占住(实测 `max_tokens:1` 也要 14 秒,
+期间连 `/models` 都不应答),探测必须只问路由。代价是**模型名写错要到真正提问时才暴露**。
+
+探测结果四态(`providerService.probeEndpoint`):`responses` / `chat-only` / `unsupported` / `unreachable`,
+只有 `responses` 允许应用。
+
+## 5. 一次回合有多慢是物理决定的
+
+用真实 codex.exe 抓下来的请求体(空目录、只发一句 "hi"):
+
+```
+总 41,223 字节 = instructions 17,733 + tools 18,371(9 个工具)+ input 3,787
+              + client_metadata 1,056 + prompt_cache_key 38 + 其余若干
+```
+
+这 ~4 万字节是 **Codex 自带的 agent 开场**(系统指令 + 工具定义 + 环境上下文),
+不是我们拼的,删不掉。真实会话再往上叠历史,实测能到 4.6 万 token。
+按自部署端点 ~29 token/s 的 prefill 算:46,000 / 29 ≈ **26 分钟**才有第一个字。
+
+所以页面上是这两行,而不是进度条:
+
+- 「回合开始」:Codex 已开始处理当前任务;端点要把完整上下文重新 prefill,**首字之前不会有任何输出**。
+- 「上下文用量」(来自 `thread/tokenUsage/updated`):本轮输入/输出/命中缓存 tokens 与上下文窗口大小,
+  同一回合内原地更新。想快只有两条路:换更快的端点,或者少带上下文(关掉的 MCP 工具、启用的 skill
+  会直接体现在 `tools` 那 18KB 里)。
+
+## 6. 排障:诊断行怎么读
+
+所有诊断走同一条出口:内存环形缓冲 + `ee.log`(`logger.warn('[codex] …')`)+ 推送 `codex/diagnostic`。
+现在每行带层前缀:
+
+| 前缀 | 出处 | 看到什么说明什么 |
+| --- | --- | --- |
+| `layer=rpc <方法> 用了 N 秒` / `失败(N 秒):…` | `codexRuntime.#request` | 主进程 ↔ app-server 这一段慢或断。30 秒预算打满 = 子进程没回话,通常是它自己挂了或 stdio 被堵 |
+| `layer=turn turn=… status=… 用时 …s` | `codexRuntime.runTurn` | 一整轮的真实耗时。`status=failed` 去翻同一 turnId 的 `error` 事件 |
+| `layer=turn turn=… 宿主侧期限已到(1200 秒)` | 同上 | 是我们 20 分钟期限到点,**不是端点故障** |
+| 无 `layer=` 前缀、含 `idle timeout waiting for SSE` | Codex 转上来的 `error` 事件 | 空闲窗口不够大:调 `stream_idle_timeout_ms`(模型管理 config 里显式写即可覆盖默认) |
+| 含 `Reconnecting... n/m` | Codex 自己的重试播报 | 上游流断了。我们已把 `stream_max_retries=0`,出现这行说明该会话被显式改过参数 |
+
+三条最常见的病:
+
+1. **发一条就报错、但只问了一次上游** → 空闲窗口太小,见第 3 节表格,改 `stream_idle_timeout_ms`。
+2. **报"端点没有 /responses"** → 端点构建不支持 Responses,客户端不会替它翻译,升级端点。
+3. **`Codex 协议流已关闭` / 子进程退出码** → 十有八九是 `-c` 参数被 Codex 判非法(例如覆盖内置 provider id、
+   或 config 里塞了它不认的键)。退出信息已经拼上 stderr 原文并落 `ee.log`,直接看那一行。