Эх сурвалжийг харах

perf(codex): 流式先建 SSE、挂断即取消上游,并对单槽端点关掉重试

四个超时假设全部实测否掉(真 codex.exe + 真桥接 + 受控 mock):桥接层没有任何超时常量;
首事件前后各静默 26/30 秒在默认与 stream_idle_timeout_ms=120s 下都是 1 请求零重试;
Codex 对本地 provider 不发 WebSocket upgrade;复刻 100 秒单槽排队也正常完成。
/slots 给出真相:一轮 prompt 46,122 token、20 秒只 prefill 589 token(≈29 tok/s),
一次要二十几分钟,而 Codex 每 15 秒掐一次并重发 5 遍 —— 把同样的几分钟重复五遍,
还挤掉前缀缓存(同一请求三次实测 53.0s / 3.9s / 23.1s,说明缓存本来有效)。

对照 cc-switch 的经验收口三处,每条都先写测试跑红再实现:
- 流式请求一到就先回 response.created / response.in_progress,不再等上游首字节
  (新增 ResponsesSseTranslator.begin(),复用其 ensureStarted() 幂等保护)。
  此前桥接要等上游响应头到手才写字节,Codex 面对的是零字节的死连接:测试首帧 6017ms → 立即。
- Codex 挂断即 AbortController 取消上游,唯一的推理槽立刻归还,被放弃的生成不再排队堵路。
  测试断言上游在挂断后 1 秒内观察到中止。
- provider 默认 stream_max_retries=0、request_max_retries=0、stream_idle_timeout_ms=300s、
  supports_websockets=false(白名单补 supports_websockets;模型管理 config 显式值优先)。
  单槽服务上重试不是容错,是自我 DoS。

另存真机 smoke chatBridge.live.itest.ts(不进 npm test,端点不可达自动跳过;
踩到一条坑:describe.skipIf 在收集阶段求值,可达性探测必须放模块顶层 await)。

验证:vitest 113 passed;tsc --noEmit 0 报错;vue-tsc 93 条存量、src/codex 与 aiPlugin 命中 0;
真机 smoke 连跑两次 71.2s → 38.8s,均 1 次上游请求、0 条重试、回复正确。未跑 build。
cc 1 долоо хоног өмнө
parent
commit
3d0b3280b0

+ 32 - 0
.workbuddy/memory/2026-09-24.md

@@ -504,3 +504,35 @@ http_headers …`,我们白名单只放行 4 个,所以「关 websocket」
 被放弃的请求继续占唯一的槽 —— 这是重试互相排队的放大器,加 AbortController 绑 `res` 关闭即可。
 
 验证:tsc 0 报错;vitest 109 passed;诊断行样例由受控 mock 实跑取得。未跑 build / itest。
+
+## 超时根因收口:先建流 + 不重发 + 挂断即取消上游(对照 cc-switch)
+
+被实测否掉的四个假设(都是真 codex.exe + 真桥接 + 受控 mock 跑出来的):桥接层无任何超时常量;
+「首事件前静默 26s」「首事件后静默 30s」在默认与 `stream_idle_timeout_ms=120s` 下都是
+1 请求零重试;Codex 对本地 provider 零 WebSocket upgrade;复刻 100s 单槽排队也正常完成。
+
+`/slots` 才给出真相:一轮 prompt **46,122 token**,20 秒只 prefill 了 589 token(≈29 tok/s),
+`is_processing` 恒真 —— 一次要二十几分钟;而 Codex 每 15 秒掐一次并重发 5 次,
+等于把同样的几分钟重复五遍,还会挤掉前缀缓存(同一请求三次实测 53.0s / 3.9s / 23.1s,证明缓存确实生效)。
+
+读 cc-switch(`proxy/forwarder.rs`、`proxy/providers/codex.rs`、`codex_responses_sse.rs`)得到的经验:
+它把超时拆成 `non_streaming_timeout` + `streaming_first_byte_timeout`,而 `_streaming_idle_timeout`
+显式不用(流建起来就不靠 idle 杀);`max_retries` 是跨厂商 failover 而非对同一上游重发;
+连接生命周期靠 Drop 释放;路由判定是 `meta.api_format` 优先、URL 特征兜底。
+
+据此三处改动(**每条都先写测试跑红再实现**):
+
+1. 流式请求一到就先回 `response.created`/`response.in_progress`(新增 `ResponsesSseTranslator.begin()`,
+   复用其 `ensureStarted()` 幂等保护),不再等上游首字节 —— 测试首帧 6017ms → 立即。
+2. Codex 挂断即 `AbortController` 取消上游,单槽立刻归还(测试:上游 <1s 观察到中止)。
+3. provider 默认 `stream_max_retries=0`、`request_max_retries=0`、`stream_idle_timeout_ms=300s`、
+   `supports_websockets=false`(白名单补 `supports_websockets`;模型管理 config 显式值可覆盖)。
+
+真机 smoke `chatBridge.live.itest.ts` 保留(`npm test` 不跑,`npm run smoke:codex` 跑;端点不可达自动跳过)。
+注意 `describe.skipIf` 在收集阶段求值,可达性探测必须放模块顶层 await,放 beforeAll 会永远跳过 —— 我自己踩过。
+连跑两次真机:71.2s → **38.8s**,均 1 请求、0 重试、回复正确。
+
+下一步(未做,先记):学 cc-switch 在模型管理加显式 `api_format` 字段,
+「一律按 Chat」迟早要能被「这条记录就是原生 Responses」覆盖,而不是靠探测猜。
+
+验证:vitest 113 passed;tsc 0 报错;vue-tsc 93 存量、codex/aiPlugin 0;真机 smoke 连过两次。未跑 build。

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

@@ -0,0 +1,96 @@
+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);
+});

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

@@ -177,3 +177,108 @@ describe('ChatBridgeService', () => {
     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);
+});

+ 53 - 22
ai-electron/electron/service/codex/chatBridgeService.ts

@@ -153,61 +153,90 @@ export class ChatBridgeService {
         `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)}`);
-      this.#failJson(res, upstreamRes.status, `桥接上游返回 HTTP ${upstreamRes.status}:${text.slice(0, 500)}`);
+      if (translator) {
+        write(translator.fail(detail));
+        res.end();
+        return;
+      }
+      this.#failJson(res, upstreamRes.status, detail);
       return;
     }
 
-    if (!chatReq.stream) {
+    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;
     }
 
-    // 流式:边收边翻边发
-    res.writeHead(200, {
-      'content-type': 'text/event-stream',
-      'cache-control': 'no-cache',
-      connection: 'keep-alive',
-    });
-    const translator = new ResponsesSseTranslator(responsesReq, (line) => this.#diag(line));
+    // 流式:边收边翻边发(SSE 已在请求到达时就开好,translator 此时必定存在)
     let bytes = 0;
-    let events = 0;
-    let aborted = false;
-    const write = (items: BridgeSseEvent[]): void => {
-      events += items.length;
-      writeSseEvents(res, items);
-    };
-    // Codex 提前挂断是这次排查的关键信号:没有这行就是上游自己停了
-    res.once('close', () => {
-      if (!res.writableEnded) {
-        aborted = true;
-        this.#diag(`chat-bridge: #${id} Codex 提前断开(${secs()},已发 ${events} 个事件)`);
-      }
-    });
     try {
       if (!upstreamRes.body) throw new Error('上游响应没有 body');
       const reader = upstreamRes.body.getReader();
@@ -222,6 +251,8 @@ export class ChatBridgeService {
       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));

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

@@ -438,6 +438,14 @@ export class ResponsesSseTranslator {
     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;

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

@@ -61,6 +61,22 @@ describe('buildArgs', () => {
     expect(joined).not.toMatch(/model_providers\.(ollama|lmstudio|openai)\b/u);
   });
 
+  it('重试次数 / 空闲超时 / websocket 开关都会写进 -c', () => {
+    const joined = buildArgs({
+      ...custom,
+      extra: {
+        request_max_retries: 0,
+        stream_max_retries: 0,
+        stream_idle_timeout_ms: 300_000,
+        supports_websockets: false,
+      },
+    }).join(' ');
+    expect(joined).toContain('model_providers.zsjz.stream_max_retries=0');
+    expect(joined).toContain('model_providers.zsjz.request_max_retries=0');
+    expect(joined).toContain('model_providers.zsjz.stream_idle_timeout_ms=300000');
+    expect(joined).toContain('model_providers.zsjz.supports_websockets=false');
+  });
+
   it('值里的引号与反斜杠被 TOML 安全转义', () => {
     const joined = buildArgs({
       ...custom,

+ 2 - 1
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 / query_params */
+  /** 仅放行 request_max_retries / stream_max_retries / stream_idle_timeout_ms / supports_websockets / query_params */
   extra?: Record<string, JsonValue> | null;
 }
 
@@ -188,6 +188,7 @@ const PROVIDER_EXTRA_ALLOWLIST = new Set([
   'request_max_retries',
   'stream_max_retries',
   'stream_idle_timeout_ms',
+  'supports_websockets',
   'query_params',
 ]);
 

+ 26 - 2
ai-electron/electron/service/codex/providerService.test.ts

@@ -74,7 +74,8 @@ describe('toProviderSpec', () => {
     });
     expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
     // config 里只放行白名单键,temperature 必须被丢掉
-    expect(spec.extra).toEqual({ request_max_retries: 3 });
+    expect(spec.extra).toMatchObject({ request_max_retries: 3 });
+    expect(spec.extra).not.toHaveProperty('temperature');
   });
 
   it('地址原样使用,尾部斜杠规整;没配 key 就不写 env_key', () => {
@@ -83,6 +84,29 @@ describe('toProviderSpec', () => {
     expect(spec.apiKey ?? null).toBeNull();
   });
 
+  it('自部署端点默认关掉重试放大、放宽空闲超时、不走 websocket', () => {
+    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,
+    });
+  });
+
+  it('模型管理 config 里显式写的值优先于默认', () => {
+    const spec = toProviderSpec({
+      modelId: 'm',
+      baseUrl: 'http://x/v1',
+      config: { stream_idle_timeout_ms: 5_000, stream_max_retries: 3 },
+    });
+    expect(spec.extra).toMatchObject({
+      stream_idle_timeout_ms: 5_000,
+      stream_max_retries: 3,
+      request_max_retries: 0,
+    });
+  });
   it('缺 modelId 或缺 baseUrl 直接报错,不做任何地址兜底', () => {
     expect(() => toProviderSpec({ modelId: '  ' })).toThrow(/modelId/);
     expect(() => toProviderSpec({ modelId: 'gpt' })).toThrow(/base_url/);
@@ -92,7 +116,7 @@ describe('toProviderSpec', () => {
   it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
     expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' });
     expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull();
-    expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toEqual({
+    expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toMatchObject({
       stream_max_retries: 5,
     });
   });

+ 16 - 1
ai-electron/electron/service/codex/providerService.ts

@@ -32,9 +32,24 @@ const EXTRA_ALLOWLIST: readonly string[] = [
   'request_max_retries',
   'stream_max_retries',
   'stream_idle_timeout_ms',
+  'supports_websockets',
   'query_params',
 ];
 
+/**
+ * 自部署端点多是单槽推理:实测一轮 Codex 请求的 prompt 有 4.6 万 token,
+ * 而这台机器 prefill 只有 ~29 token/s —— Codex 默认每 15 秒掐一次流并重试 5 次,
+ * 等于把同样几分钟的活儿重复五遍,还会把前缀缓存挤掉。所以默认不重试、空闲超时放宽,
+ * 并声明不走 websocket(Codex 对本地 provider 本来也没发 upgrade,这里显式关掉免得哪天再试)。
+ * 模型管理 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,
+});
+
 function providerFile(): string {
   return join(getCodexDataDir(), 'provider.json');
 }
@@ -94,7 +109,7 @@ export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
   }
 
   const config = parseJsonRecord(input.config);
-  const extra: Record<string, JsonValue> = {};
+  const extra: Record<string, JsonValue> = { ...PROVIDER_DEFAULTS };
   for (const [key, value] of Object.entries(config ?? {})) {
     if (EXTRA_ALLOWLIST.includes(key)) extra[key] = value as JsonValue;
   }