Sfoglia il codice sorgente

feat(codex): 内置 Responses→Chat 桥接,chat-only 端点自动接入(P3)

chatBridgeService:127.0.0.1 随机端口的本地代理,只接 POST /v1/responses,请求/响应翻译复用 chatBridgeTranslate;头透传(authorization+配置头,accept-encoding 强制 identity 防 gzip 切碎 SSE);上游非 2xx 透回错误体,流式中断补 response.failed;不持密钥。providerService.applyProvider 集成:探测判 chat-only 时自动起桥接并把 spec 降级为自定义 provider(内置 ollama 路径只允许覆盖 base_url,不降级会绕过桥接);失败回滚停桥接;直通/清除时确保桥接停止。AppliedProviderInfo 加 bridged(落盘存上游真实地址,桥接 URL 不落盘);CodexStatusResult 加 bridge 运行状态;disposeCodex 兜底停桥接。

验证:tsc --noEmit 0 报错;114 单测全过;smoke:codex 真实二进制经桥接跑通两轮 turn(第二轮 messages 含第一轮全部内容,验证 Codex 无状态调用与 Ollama 非状态化兼容);版本探测与 electron 安装两个冒烟失败为基线既有环境问题(git stash 对照证实);build-electron exit=0。
cc 1 settimana fa
parent
commit
b9075dda7f

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

@@ -0,0 +1,115 @@
+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-only 上游(/responses 404,/chat/completions 返回 SSE 固定文本)
+ * → 内置桥接 → 真实 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;
+    }
+    // /responses 一律 404 → 探测走 chat 分支
+    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-only 端点接入', () => {
+  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`,
+        providerType: 'VLLM',
+      });
+      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);
+});

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

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

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

@@ -0,0 +1,204 @@
+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;
+
+  /** 装配层(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`;
+
+    let upstreamRes: Response;
+    try {
+      upstreamRes = await fetch(upstream, {
+        method: 'POST',
+        headers: this.#forwardHeaders(req),
+        body: JSON.stringify(chatReq),
+      });
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      this.#diag(`chat-bridge: 上游不可达:${message}`);
+      this.#failJson(res, 502, `桥接上游不可达:${message}`);
+      return;
+    }
+
+    if (!upstreamRes.ok) {
+      const text = await upstreamRes.text().catch(() => '');
+      this.#diag(`chat-bridge: 上游 HTTP ${upstreamRes.status}:${text.slice(0, 300)}`);
+      this.#failJson(res, upstreamRes.status, `桥接上游返回 HTTP ${upstreamRes.status}:${text.slice(0, 500)}`);
+      return;
+    }
+
+    if (!chatReq.stream) {
+      try {
+        const chatJson = (await upstreamRes.json()) as Parameters<typeof chatResponseToResponses>[0];
+        res.writeHead(200, { 'content-type': 'application/json' });
+        res.end(JSON.stringify(chatResponseToResponses(chatJson, responsesReq)));
+      } catch (error) {
+        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));
+    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;
+        writeSseEvents(res, translator.push(decoder.decode(value, { stream: true })));
+      }
+      writeSseEvents(res, translator.push(decoder.decode()));
+      writeSseEvents(res, translator.finish());
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      this.#diag(`chat-bridge: 流式转发中断:${message}`);
+      writeSseEvents(res, translator.fail(message));
+    }
+    res.end();
+  }
+}
+
+/** 模块级单例:providerService 应用/清除模型时启停,装配层退出时兜底 stop */
+export const chatBridge = new ChatBridgeService();

+ 30 - 4
ai-electron/electron/service/codex/codexRuntime.itest.ts

@@ -18,7 +18,8 @@ const API_KEY = 'sk-smoke-secret-value-1234567890';
 const SKILL_MD = ['---', 'name: smoke-skill', 'description: 冒烟测试用的技能', '---', '', '# smoke', ''].join('\n');
 
 let server: Server;
-let nextStatus = 401;
+let responsesStatus = 401;
+let chatStatus = 401;
 let baseUrl: string;
 let codexHome: string;
 let runtime: CodexRuntime;
@@ -28,7 +29,8 @@ beforeAll(async () => {
   // 假端点:401 表示「路由存在但需要鉴权」,足以让 provider 探测判为兼容
   server = createServer((req, res) => {
     req.resume();
-    res.writeHead(nextStatus, { 'content-type': 'application/json' });
+    const status = req.url?.endsWith('/chat/completions') ? chatStatus : responsesStatus;
+    res.writeHead(status, { 'content-type': 'application/json' });
     res.end(JSON.stringify({ error: { message: 'smoke endpoint' } }));
   });
   await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -188,7 +190,8 @@ describe('Skill 安装能被 Codex 识别', () => {
 
 describe('切换 provider 会重启子进程', () => {
   it('applyProvider 后 config 更新且磁盘上没有密钥', async () => {
-    nextStatus = 401;
+    responsesStatus = 401;
+    chatStatus = 401;
     const applied = await applyProvider(runtime, {
       modelId: 'smoke-model-2',
       name: '冒烟端点 2',
@@ -210,8 +213,29 @@ describe('切换 provider 会重启子进程', () => {
     expect(JSON.stringify(persisted)).not.toContain(API_KEY);
   });
 
+  it('chat-only 端点经内置桥接应用成功,落盘存上游真实地址', async () => {
+    responsesStatus = 404;
+    chatStatus = 200;
+    const applied = await applyProvider(runtime, {
+      modelId: 'smoke-chat-model',
+      baseUrl,
+      apiKey: API_KEY,
+      providerType: 'VLLM',
+    });
+    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?.wire_api).toBe('responses');
+  });
+
   it('双协议都不存在的端点拒绝应用', async () => {
-    nextStatus = 404;
+    responsesStatus = 404;
+    chatStatus = 404;
     await expect(
       applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY, providerType: 'OPENAI' }),
     ).rejects.toThrow(/无法对接/);
@@ -221,6 +245,8 @@ describe('切换 provider 会重启子进程', () => {
     await clearProvider(runtime);
     expect(await readAppliedProvider()).toBeNull();
     expect(runtime.provider).toBeNull();
+    responsesStatus = 401;
+    chatStatus = 401;
   });
 });
 

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

@@ -3,6 +3,7 @@ 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';
@@ -67,6 +68,8 @@ 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());
@@ -125,6 +128,7 @@ 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',
@@ -136,6 +140,7 @@ export function toStatusResult(status: RuntimeStatus): CodexStatusResult {
     degraded: status.degraded,
     error: status.error,
     applied: appliedCache,
+    bridge: bridgeInfo ? { running: true, upstream: bridgeInfo.upstreamBaseUrl } : null,
   };
 }
 
@@ -152,6 +157,8 @@ 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;

+ 31 - 4
ai-electron/electron/service/codex/providerService.ts

@@ -1,5 +1,6 @@
 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 type { AppliedProviderInfo, ApplyProviderInput } from './types';
@@ -303,6 +304,10 @@ export async function applyProvider(
   input: ApplyProviderInput & { modelRecordId?: string | number | null },
 ): Promise<AppliedProvider> {
   const spec = toProviderSpec(input);
+  /** 桥接时落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */
+  const displayBaseUrl = spec.baseUrl ?? null;
+  let bridged = false;
+
   // 只要显式配了地址就探(含远端 Ollama):应用成功后才发现连不上,比这里直接报错更难排查。
   // 内置 provider 没配地址时不探(默认 localhost,可能压根没起本地服务)。
   if (spec.baseUrl) {
@@ -313,28 +318,50 @@ export async function applyProvider(
       providerType: input.providerType,
       builtinProvider: spec.builtinProvider,
     });
+    if (probe.protocol !== 'responses' && probe.protocol !== 'chat-only') {
+      throw new Error(probe.detail);
+    }
     if (probe.protocol === 'chat-only') {
-      throw new Error(`${probe.detail}。内置桥接转换尚未启用,当前请先升级端点(Ollama 需 ≥ 0.13.3)`);
+      // chat-only 端点:启动内置桥接,Codex 改连本地代理的 /v1/responses。
+      // Ollama 桥接特例:内置 provider 的 buildArgs 只允许覆盖 base_url,
+      // 不降级为自定义 provider 的话 Codex 会绕过桥接直连上游。
+      const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null });
+      spec.id = PROVIDER_ID;
+      spec.builtinProvider = null;
+      spec.baseUrl = bridge.url;
+      bridged = true;
     }
-    if (probe.protocol !== 'responses') throw new Error(probe.detail);
   }
-  await runtime.applyProvider(spec);
+
+  // 直通(或未配地址的内置 provider):确保上一轮的桥接已停
+  if (!bridged) await chatBridge.stop();
+
+  try {
+    await runtime.applyProvider(spec);
+  } catch (error) {
+    // 应用失败回滚桥接,避免留下一个指着旧上游的孤儿代理
+    if (bridged) await chatBridge.stop();
+    throw error;
+  }
+
   const applied: AppliedProvider = {
     model: spec.model,
     modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null
       ? null
       : String(input.modelRecordId),
-    baseUrl: spec.baseUrl ?? null,
+    baseUrl: displayBaseUrl,
     providerId: spec.id,
     builtinProvider: spec.builtinProvider ?? null,
     name: spec.name ?? null,
     appliedAt: new Date().toISOString(),
+    ...(bridged ? { 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);
 }

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

@@ -75,6 +75,8 @@ export interface CodexStatusResult {
   error: string | null;
   /** 上次应用的模型(不含密钥);重启后需要页面重新应用才能注入 api key */
   applied: AppliedProviderInfo | null;
+  /** 内置 Responses→Chat 桥接运行状态;未桥接为 null */
+  bridge?: { running: boolean; upstream: string | null } | null;
 }
 
 export interface AppliedProviderInfo {
@@ -85,6 +87,11 @@ export interface AppliedProviderInfo {
   builtinProvider: string | null;
   name: string | null;
   appliedAt: string;
+  /**
+   * true 表示经内置桥接转换(chat-only 端点)。此时 baseUrl 存的是上游真实地址,
+   * 桥接的本地 URL 每次应用临时分配、不落盘,重启后由页面重新「应用」重建。
+   */
+  bridged?: boolean;
 }
 
 /** 主进程 → 渲染进程的事件推送 channel */