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

fix(codex): 移除内置 provider 兜底,启动失败改为可读原因

聊天页选 Ollama 模型后 codex 起不来,只报「Codex 协议流已关闭」。用 vendor 里的
codex.exe 0.155.1 实跑复现:buildArgs 对内置 provider 发 -c model_providers.ollama.base_url,
而 0.155.1 把内置 id 列为保留字,app-server 直接 exit 1;stdout 随之关闭,挂着的
initialize 被 reject 成那句没有信息量的话。

按不兜底的口径把内置 provider 这条路整体删掉:resolveBuiltinProvider、normalizeOssBaseUrl
(含自动补 /v1)、toProviderSpec 与 buildArgs 的内置分支、spec 与 IPC 契约上的
builtinProvider、codexCtl 里两处探 localhost 默认地址的兜底,一律翻译为自定义 provider zsjz,
id 与 baseUrl 收紧为必填。缺 base_url 直接提示去「模型管理」补全;聊天页未选模型时拦住发送。

顺带修两处放大故障的问题:applyProvider 失败回滚 provider(坏 spec 曾常驻内存,之后每次
启动都重放同一组参数,直到重启应用为止);createExitWatcher 等 stderr 收完再拼
「Codex App Server 退出(1):<原文>」并同步落 ee.log,下次不必靠复现才能定位。

验证:tsc --noEmit 0 报错;vitest 111 passed(内置 provider 的 6 条用例随功能删除,
新增保留 id 断言);vue-tsc 在 src/codex 与 src/ai/views/aiPlugin 上 0 报错。另用真实
codex.exe + 临时 codexHome 跑过三条端到端(错误信息、失败回滚后仍可启动、Ollama 记录
生成的参数不含保留 id),临时用例已删。未跑 build 与 itest。
cc 2 долоо хоног өмнө
parent
commit
613dbabb44

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

@@ -360,3 +360,37 @@ D(主进程 watcher:只覆盖活会话,要养常驻进程)、E(按回
 - 边界:未登录后端 → models=[] → 「未配置可用模型」(同现状静默降级)。
 
 验证:pnpm type:check 89 基线 / codex 包 0 报错;pnpm build exit=0。主进程未动。
+
+## Codex「协议流已关闭」:内置 provider 覆盖是保留字,兜底整体移除
+
+现象:聊天页选模型(Ollama 记录)后 codex 起不来,报 `Codex 协议流已关闭`,之后点多少次启动都是同一句。
+根因(用 vendor 里的 codex.exe 0.155.1 实跑复现):`buildArgs` 对内置 provider 发
+`-c model_providers.ollama.base_url=…`,而 0.155.1 把内置 id 列为保留字——
+`model_providers contains reserved built-in provider IDs: ollama` → **app-server exit 1**,
+stdout 随之关闭,`jsonRpcPeer` 把挂着的 `initialize` reject 成那句没信息量的话。
+(同法验证:`model=""` 报 provider name must not be empty;不带地址的 `model_provider="ollama"` 正常。)
+
+- 二次伤害:`CodexRuntime.applyProvider` 先写 `#provider` 再 start,失败不回滚 →
+  坏 spec 常驻内存,之后每次 start 重放同一组参数,日志里连着一排 `start failed`,
+  只有重启应用或「清除模型」才能解锁。
+- 按拍板**删掉内置 provider 这条路**(不做任何 localhost 兜底):
+  `resolveBuiltinProvider` / `normalizeOssBaseUrl`(含自动补 `/v1`)/ `toProviderSpec` 内置分支 /
+  `buildArgs` 内置分支 / `CodexProviderSpec.builtinProvider` / `ApplyProviderInput.builtinProvider` /
+  `AppliedProviderInfo.builtinProvider` / codexCtl 里两处「Ollama 未配地址就探 localhost」全删;
+  一律翻译成自定义 provider `zsjz`,`id`/`baseUrl` 收紧为必填。
+  `probeEndpoint` 不再收 builtinProvider,Ollama 消歧只看 `providerType`。
+- 新增校验:缺 base_url → 「模型「x」没有配置 base_url,请到「模型管理」补全(形如 https://host:port/v1)」;
+  聊天页未选模型点发送 → 拦下发并提示(有候选提示去选,无候选提示去模型管理加)。
+  理由:未应用 provider 时 Codex 打它自己的默认端点,失败信息完全不可读。
+- 可诊断性:`createExitWatcher` 统一 stdout 关闭 / exit / spawn error 三个出口,等 close(≤300ms)
+  收完 stderr 再拼 `Codex App Server 退出(1):<Error 原文>`,`#start` 的 catch 用它改写
+  `error.message`(保留错误对象与 stack);`pushDiagnostic` 同步 `logger.warn` 落 ee.log
+  (原来只进内存环形缓冲,进程一没就查不到)。
+- **行为变化**:库里把 Ollama 存成 `http://host:11434`(缺 `/v1`)的记录不再被自动补齐,
+  探测会判 unsupported,需去模型管理补版本段(`baseUrlHint` 本来就一直在提示)。
+
+验证:tsc 0 报错;vitest 111 passed(内置 provider 相关 6 条用例随功能删除,新增保留 id 断言);
+前端 vue-tsc 在 src/codex 与 src/ai/views/aiPlugin 上 0 报错(93 条存量在别处);
+另用真实 codex.exe + 临时 codexHome 跑过三条端到端(错误信息含 exit 1 与原文、
+applyProvider 失败后 provider 回滚且下次 start 到 ready、Ollama 记录生成的参数不含保留 id 且进程起得来),
+临时用例已删。未跑 build / itest。

+ 2 - 14
ai-electron/electron/controller/codexCtl.ts

@@ -126,20 +126,14 @@ class CodexCtl {
     apiKey?: string | null;
     modelId?: string | null;
     providerType?: string | null;
-    builtinProvider?: string | null;
   }): Promise<Rpc<unknown>> {
     return guard('probeProvider', async () => {
-      const isOllama =
-        params?.builtinProvider === 'ollama' ||
-        `${params?.providerType ?? ''}`.trim().toUpperCase() === 'OLLAMA';
-      // Ollama 未配地址时探默认 localhost(库里常只选模型不填地址)
-      const baseUrl = `${params?.baseUrl ?? ''}`.trim() || (isOllama ? 'http://localhost:11434/v1' : requireString(params?.baseUrl, 'baseUrl'));
+      const baseUrl = requireString(params?.baseUrl, 'baseUrl');
       const result = await probeEndpoint({
         baseUrl,
         modelId: params?.modelId ?? null,
         apiKey: params?.apiKey ?? null,
         providerType: params?.providerType ?? null,
-        builtinProvider: params?.builtinProvider ?? null,
       });
       return { ...result, hint: baseUrlHint(baseUrl) };
     });
@@ -152,15 +146,9 @@ class CodexCtl {
       const { runtime } = await getCodex();
       const applied = await applyProviderToRuntime(runtime, params);
       setAppliedProvider(applied);
-      let hint = baseUrlHint(applied.baseUrl);
-      // 内置 Ollama 未配地址时补探默认 localhost:只警告不拦截(本地服务可能还没起)
-      if (applied.builtinProvider === 'ollama' && !applied.baseUrl) {
-        const probe = await probeEndpoint({ baseUrl: 'http://localhost:11434/v1', modelId: applied.model, builtinProvider: 'ollama' });
-        if (probe.protocol !== 'responses') hint = [hint, probe.detail].filter(Boolean).join(';');
-      }
       return {
         applied,
-        hint,
+        hint: baseUrlHint(applied.baseUrl),
         droppedConfigKeys: describeDroppedKeys(params),
         status: toStatusResult(runtime.status),
       };

+ 5 - 18
ai-electron/electron/service/codex/codexRuntime.test.ts

@@ -54,24 +54,11 @@ describe('buildArgs', () => {
     expect(joined).not.toContain('temperature');
   });
 
-  it('内置 ollama:带上真实 base_url,否则只会打 localhost', () => {
-    const args = buildArgs({
-      id: null,
-      builtinProvider: 'ollama',
-      model: 'qwen',
-      baseUrl: 'http://10.66.66.66:8080/v1',
-    });
-    const joined = args.join(' ');
-    expect(joined).toContain('model_provider="ollama"');
-    expect(joined).toContain('model_providers.ollama.base_url="http://10.66.66.66:8080/v1"');
-    expect(joined).not.toContain('wire_api');
-    expect(joined).not.toContain('env_key');
-  });
-
-  it('内置 provider 未配地址时不覆盖 base_url', () => {
-    const joined = buildArgs({ id: null, builtinProvider: 'lmstudio', model: 'local-model' }).join(' ');
-    expect(joined).toContain('model_provider="lmstudio"');
-    expect(joined).not.toContain('base_url');
+  it('provider 一律用自定义 id,绝不落在 Codex 保留的内置 id 上', () => {
+    const joined = buildArgs(custom).join(' ');
+    expect(joined).toContain('model_provider="zsjz"');
+    // 覆盖 model_providers.ollama 之类会让 app-server 启动即退出
+    expect(joined).not.toMatch(/model_providers\.(ollama|lmstudio|openai)\b/u);
   });
 
   it('值里的引号与反斜杠被 TOML 安全转义', () => {

+ 106 - 34
ai-electron/electron/service/codex/codexRuntime.ts

@@ -60,14 +60,12 @@ export interface CodexSkillSummary {
   cwd: string;
 }
 
-/** 一次 spawn 用的模型来源。id 为 null 表示使用 Codex 内置 provider(ollama / lmstudio) */
+/** 一次 spawn 用的模型来源:一律是自定义 provider,Codex 的内置 id 是保留字、不可覆盖 */
 export interface CodexProviderSpec {
-  id: string | null;
+  id: string;
   name?: string | null;
-  baseUrl?: string | null;
+  baseUrl: string;
   model: string;
-  /** 内置 provider id,例如 'ollama';给了就不再写 model_providers 段 */
-  builtinProvider?: string | null;
   /** apiKey 只注入子进程环境变量,不落任何盘 */
   apiKey?: string | null;
   envKey?: string;
@@ -182,6 +180,9 @@ interface TurnWaiter {
 }
 
 const TURN_TIMEOUT_MS = 20 * 60 * 1_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([
   'request_max_retries',
@@ -224,9 +225,17 @@ export class CodexRuntime extends EventEmitter {
 
   /** 换 provider 必须重启子进程:api_key 走的是子进程环境变量,无法热更新 */
   async applyProvider(provider: CodexProviderSpec | null): Promise<RuntimeStatus> {
+    const previous = this.#provider;
     this.#provider = provider;
     if (this.#child) await this.stop();
-    return this.start();
+    try {
+      return await this.start();
+    } catch (error) {
+      // 失败要回滚:坏 spec 留在内存里,之后每次 start 都会重放同一组参数,
+      // 一条选错的模型能把运行时锁死到重启应用为止。
+      this.#provider = previous;
+      throw error;
+    }
   }
 
   async start(): Promise<RuntimeStatus> {
@@ -524,6 +533,7 @@ export class CodexRuntime extends EventEmitter {
     this.emit('status', this.status);
     let child: ChildProcessWithoutNullStreams | null = null;
     let peer: JsonRpcPeer | null = null;
+    let watcher: ExitWatcher | null = null;
     try {
       const binaryPath = await locateCodexBinary();
       this.#assertGeneration(generation);
@@ -539,25 +549,27 @@ export class CodexRuntime extends EventEmitter {
       this.#child = child;
       peer = new JsonRpcPeer(child.stdout, child.stdin);
       this.#peer = peer;
+      const spawnedChild = child;
+      const spawnedPeer = peer;
       peer.on('notification', (notification) => this.#handleNotification(notification));
       peer.on('serverRequest', (request: JsonRpcServerRequest) => this.emit('serverRequest', request));
       peer.on('protocolError', (error) => this.emit('diagnostic', asError(error).message));
-      peer.once('closed', (error) => {
-        if (this.#peer !== peer || this.#child !== child) return;
-        if (child?.exitCode === null) child.kill('SIGTERM');
-        this.#handleExit(child!, asError(error));
-      });
-      peer.start();
+      watcher = createExitWatcher(
+        spawnedChild,
+        (line) => this.emit('diagnostic', sanitizeDiagnostic(line)),
+        (error) => {
+          if (this.#peer !== spawnedPeer || this.#child !== spawnedChild) return;
+          this.#handleExit(spawnedChild, error);
+        },
+      );
       child.stderr.setEncoding('utf8');
       child.stderr.on('data', (chunk: string) => {
-        for (const line of chunk.split(/\r?\n/u).filter(Boolean)) {
-          this.emit('diagnostic', sanitizeDiagnostic(line));
-        }
-      });
-      child.once('error', (error) => this.#handleExit(child!, error));
-      child.once('exit', (code, signal) => {
-        this.#handleExit(child!, new Error(`Codex App Server 退出(${code ?? signal ?? 'unknown'})`));
+        for (const line of chunk.split(/\r?\n/u).filter(Boolean)) watcher?.noteStderr(line);
       });
+      child.once('error', (error) => watcher?.shutdown(error.message));
+      child.once('exit', () => watcher?.shutdown('Codex App Server 已退出'));
+      peer.once('closed', (error) => watcher?.shutdown(asError(error).message));
+      peer.start();
 
       const initialized = await peer.request<InitializeResponse>('initialize', {
         clientInfo: this.#clientInfo,
@@ -571,12 +583,14 @@ export class CodexRuntime extends EventEmitter {
         binaryPath,
         version,
         codexHome: initialized.codexHome,
-        providerId: provider?.id ?? provider?.builtinProvider ?? null,
+        providerId: provider?.id ?? null,
       };
       return await this.refresh();
     } catch (error) {
-      const message = asError(error).message;
-      peer?.close(asError(error));
+      // 子进程启动即退出的话真话只在 stderr 里:等它落定,别把「协议流已关闭」丢给用户猜
+      const fallback = asError(error).message;
+      const message = watcher ? (await watcher.describe(fallback)).message : fallback;
+      peer?.close(new Error(message));
       if (this.#peer === peer) this.#peer = null;
       if (this.#child === child) this.#child = null;
       if (child && child.exitCode === null) child.kill('SIGTERM');
@@ -584,6 +598,8 @@ export class CodexRuntime extends EventEmitter {
         this.#runtime = { ...this.#runtime, state: 'error', error: sanitizeDiagnostic(message) };
         this.emit('status', this.status);
       }
+      // 换掉原文而不是换掉错误对象:调用方拿到的类型与 stack 不变,只是话变清楚了
+      if (error instanceof Error) error.message = message;
       throw error;
     }
   }
@@ -676,18 +692,8 @@ export function buildArgs(provider: CodexProviderSpec | null): string[] {
   const args = ['app-server', '--listen', 'stdio://'];
   if (!provider) return args;
 
-  const providerKey = provider.builtinProvider ?? provider.id;
-  if (!providerKey) return args;
-  args.push('-c', `model_provider=${tomlString(providerKey)}`);
+  args.push('-c', `model_provider=${tomlString(provider.id)}`);
   args.push('-c', `model=${tomlString(provider.model)}`);
-  if (provider.builtinProvider) {
-    // 内置 provider 只允许覆盖 base_url,其余字段由 Codex 自己决定;
-    // 不覆盖就会打到 localhost:11434,远端 Ollama / LM Studio 连不上
-    if (provider.baseUrl) {
-      args.push('-c', `model_providers.${provider.builtinProvider}.base_url=${tomlString(provider.baseUrl)}`);
-    }
-    return args;
-  }
 
   const prefix = `model_providers.${provider.id}`;
   if (provider.name) args.push('-c', `${prefix}.name=${tomlString(provider.name)}`);
@@ -719,7 +725,7 @@ function buildEnv(codexHome: string, provider: CodexProviderSpec | null): NodeJS
     LOG_FORMAT: 'json',
   };
   // 密钥只存在于子进程环境变量,config.toml 里只写 env_key 名字
-  if (provider?.apiKey && !provider.builtinProvider) {
+  if (provider?.apiKey) {
     env[provider.envKey ?? DEFAULT_API_KEY_ENV] = provider.apiKey;
   }
   return env;
@@ -812,3 +818,69 @@ export function sanitizeDiagnostic(value: string): string {
     .replace(/Bearer\s+[A-Za-z0-9._~-]+/giu, 'Bearer [redacted]')
     .slice(0, 4_000);
 }
+
+function delay(ms: number): Promise<void> {
+  return new Promise((resolve) => {
+    const timer = setTimeout(resolve, ms);
+    timer.unref();
+  });
+}
+
+/**
+ * Codex 的致命错误由 anyhow 打成纯文本(`Error: ...`),RUST_LOG 的常规日志是 JSON 行;
+ * 纯文本里还混着 `WARNING: ...` 这类噪音,所以优先取 Error 行,取不到才退化成第一条纯文本行。
+ */
+function pickFatalLine(tail: string[]): string | null {
+  const plain = tail
+    .map((line) => line.trim())
+    .filter((line) => line && !line.startsWith('{'));
+  if (!plain.length) return null;
+  const fatal = plain.find((line) => /^(error|panic)\b/iu.test(line) || line.includes('panicked at'));
+  return sanitizeDiagnostic((fatal ?? plain[0]).slice(0, 500));
+}
+
+interface ExitWatcher {
+  /** 收 stderr:既实时推诊断,也留尾部若干行用于拼退出原因 */
+  noteStderr(line: string): void;
+  /** 统一退出出口(流关闭 / 进程退出 / spawn 失败),只生效一次 */
+  shutdown(fallback: string): void;
+  /** 等 stderr 收完,把没有信息量的 fallback 换成带 exit code 与原因原文的错误 */
+  describe(fallback: string): Promise<Error>;
+}
+
+function createExitWatcher(
+  child: ChildProcessWithoutNullStreams,
+  onDiagnostic: (line: string) => void,
+  onExit: (error: Error) => void,
+): ExitWatcher {
+  const tail: string[] = [];
+  const closed = new Promise<void>((resolve) => child.once('close', () => resolve()));
+  let shuttingDown = false;
+
+  const describe = async (fallback: string): Promise<Error> => {
+    // 等不到 close 也得往下走:一次卡住的退出不能把启动的 Promise 挂在那里
+    await Promise.race([closed, delay(EXIT_GRACE_MS)]);
+    const code = child.exitCode ?? child.signalCode;
+    const reason = code === null || code === undefined ? fallback : `Codex App Server 退出(${code})`;
+    const detail = pickFatalLine(tail);
+    return new Error(detail ? `${reason}:${detail}` : reason);
+  };
+
+  return {
+    noteStderr(line: string): void {
+      tail.push(line);
+      if (tail.length > STDERR_TAIL_LINES) tail.shift();
+      onDiagnostic(line);
+    },
+    describe,
+    shutdown(fallback: string): void {
+      if (shuttingDown) return;
+      shuttingDown = true;
+      void describe(fallback).then((error) => {
+        // 流都关了进程还活着(卡死的子进程)只能自己收
+        if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
+        onExit(error);
+      });
+    },
+  };
+}

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

@@ -60,6 +60,8 @@ function pushDiagnostic(line: string): void {
   if (diagnostics.length > MAX_DIAGNOSTICS) {
     diagnostics.splice(0, diagnostics.length - MAX_DIAGNOSTICS);
   }
+  // 环形缓冲只在内存里,进程一崩就没了:同步落 ee.log,否则启动失败无从复盘
+  logger.warn(`[codex] ${line}`);
   send(CODEX_DIAGNOSTIC_CHANNEL, entry);
 }
 

+ 12 - 21
ai-electron/electron/service/codex/providerService.test.ts

@@ -83,35 +83,26 @@ describe('toProviderSpec', () => {
       apiKey: 'sk-test-key',
       envKey: 'ZSJZ_CODEX_API_KEY',
     });
-    expect(spec.builtinProvider ?? null).toBeNull();
     expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
     // config 里只放行白名单键,temperature 必须被丢掉
     expect(spec.extra).toEqual({ request_max_retries: 3 });
   });
 
-  it('OLLAMA 走 Codex 内置 provider,不需要 baseUrl 与 key', () => {
-    const spec = toProviderSpec({ modelId: 'qwen3:8b', providerType: 'OLLAMA' });
-    expect(spec).toMatchObject({ id: null, builtinProvider: 'ollama', model: 'qwen3:8b' });
-    expect(spec.baseUrl).toBeNull();
-  });
-
-  it('远端 Ollama 必须保留并补齐 base_url,不能退回 localhost:11434', () => {
-    expect(
-      toProviderSpec({ modelId: 'qwen', providerType: 'OLLAMA', baseUrl: 'http://10.66.66.66:8080' }).baseUrl,
-    ).toBe('http://10.66.66.66:8080/v1');
-    expect(
-      toProviderSpec({ modelId: 'qwen', providerType: 'OLLAMA', baseUrl: 'http://host:11434/v1/' }).baseUrl,
-    ).toBe('http://host:11434/v1');
-  });
-
-  it('可显式指定内置 provider(lmstudio)', () => {
-    const spec = toProviderSpec({ modelId: 'local-model', builtinProvider: 'lmstudio' });
-    expect(spec.builtinProvider).toBe('lmstudio');
+  it('OLLAMA 同样走自定义 provider:内置 id 是 Codex 保留字,地址原样使用', () => {
+    const spec = toProviderSpec({
+      modelId: 'qwen',
+      providerType: 'OLLAMA',
+      baseUrl: 'http://10.66.66.66:8080/v1/',
+    });
+    expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1' });
+    // 本地服务没有 key,就不该写 env_key
+    expect(spec.apiKey ?? null).toBeNull();
   });
 
-  it('缺 modelId 或非内置 provider 缺 baseUrl 时报错', () => {
+  it('缺 modelId 或缺 baseUrl 直接报错,不退回 localhost 内置 provider', () => {
     expect(() => toProviderSpec({ modelId: '  ' })).toThrow(/modelId/);
-    expect(() => toProviderSpec({ modelId: 'gpt', providerType: 'OPENAI' })).toThrow(/baseUrl/);
+    expect(() => toProviderSpec({ modelId: 'gpt', providerType: 'OPENAI' })).toThrow(/base_url/);
+    expect(() => toProviderSpec({ modelId: 'qwen3:8b', providerType: 'OLLAMA' })).toThrow(/base_url/);
   });
 
   it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {

+ 26 - 59
ai-electron/electron/service/codex/providerService.ts

@@ -82,40 +82,19 @@ function readStringMap(raw: unknown): Record<string, string> | null {
   return Object.keys(result).length ? result : null;
 }
 
-/** Codex 内置 ollama/lmstudio 走 OpenAI 兼容根(.../v1),而库里常只存到 host:port */
-function normalizeOssBaseUrl(raw: string | null | undefined): string | null {
-  const base = normalizeBaseUrl(raw);
-  if (!base) return null;
-  return /\/v\d+$/u.test(base) ? base : `${base}/v1`;
-}
-
-/** OLLAMA 用 Codex 内置 provider(Responses API,默认端口 11434),不需要 api key */
-function resolveBuiltinProvider(input: ApplyProviderInput): string | null {
-  if (input.builtinProvider) return input.builtinProvider;
-  const type = `${input.providerType ?? ''}`.trim().toUpperCase();
-  if (type === 'OLLAMA') return 'ollama';
-  if (type === 'LMSTUDIO') return 'lmstudio';
-  return null;
-}
-
+/**
+ * 后端「模型管理」的一条记录一律翻译成自定义 provider:Codex 0.155.1 把 ollama / lmstudio
+ * 等内置 id 列为保留字,覆盖 model_providers.<内置 id> 会让 app-server 启动即退出。
+ * 地址缺失就是配置缺失,直接报错让页面提示,不做 localhost 兜底。
+ */
 export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
   const model = `${input.modelId ?? ''}`.trim();
   if (!model) throw new Error('缺少 modelId');
 
-  const builtinProvider = resolveBuiltinProvider(input);
-  if (builtinProvider) {
-    return {
-      id: null,
-      builtinProvider,
-      model,
-      name: input.name ?? `${builtinProvider} 本地模型`,
-      // 内置 provider 的默认地址是 localhost,必须把库里的真实地址带上,否则连不到远端 Ollama
-      baseUrl: normalizeOssBaseUrl(input.baseUrl),
-    };
-  }
-
   const baseUrl = normalizeBaseUrl(input.baseUrl);
-  if (!baseUrl) throw new Error('缺少 baseUrl');
+  if (!baseUrl) {
+    throw new Error(`模型「${input.name || model}」没有配置 base_url,请到「模型管理」补全(形如 https://host:port/v1)`);
+  }
 
   const config = parseJsonRecord(input.config);
   const extra: Record<string, JsonValue> = {};
@@ -217,13 +196,10 @@ export async function probeEndpoint(params: {
   modelId?: string | null;
   apiKey?: string | null;
   providerType?: string | null;
-  builtinProvider?: string | null;
 }): Promise<EndpointProbeResult> {
   const baseUrl = params.baseUrl.replace(/\/+$/u, '');
   const model = params.modelId?.trim() || 'probe';
-  const isOllama =
-    params.builtinProvider === 'ollama' ||
-    `${params.providerType ?? ''}`.trim().toUpperCase() === 'OLLAMA';
+  const isOllama = `${params.providerType ?? ''}`.trim().toUpperCase() === 'OLLAMA';
 
   const responses = await postProbe(
     `${baseUrl}/responses`,
@@ -305,35 +281,27 @@ export async function applyProvider(
 ): Promise<AppliedProvider> {
   const spec = toProviderSpec(input);
   /** 桥接时落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */
-  const displayBaseUrl = spec.baseUrl ?? null;
+  const displayBaseUrl = spec.baseUrl;
   let bridged = false;
 
-  // 只要显式配了地址就探(含远端 Ollama):应用成功后才发现连不上,比这里直接报错更难排查。
-  // 内置 provider 没配地址时不探(默认 localhost,可能压根没起本地服务)。
-  if (spec.baseUrl) {
-    const probe = await probeEndpoint({
-      baseUrl: spec.baseUrl,
-      modelId: spec.model,
-      apiKey: spec.apiKey,
-      providerType: input.providerType,
-      builtinProvider: spec.builtinProvider,
-    });
-    if (probe.protocol !== 'responses' && probe.protocol !== 'chat-only') {
-      throw new Error(probe.detail);
-    }
-    if (probe.protocol === 'chat-only') {
-      // 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;
-    }
+  // 一定要先探端点:应用成功后才发现连不上,比这里直接报错更难排查
+  const probe = await probeEndpoint({
+    baseUrl: spec.baseUrl,
+    modelId: spec.model,
+    apiKey: spec.apiKey,
+    providerType: input.providerType,
+  });
+  if (probe.protocol !== 'responses' && probe.protocol !== 'chat-only') {
+    throw new Error(probe.detail);
+  }
+  if (probe.protocol === 'chat-only') {
+    // chat-only 端点:启动内置桥接,Codex 改连本地代理的 /v1/responses
+    const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null });
+    spec.baseUrl = bridge.url;
+    bridged = true;
   }
 
-  // 直通(或未配地址的内置 provider):确保上一轮的桥接已停
+  // 直通:确保上一轮的桥接已停
   if (!bridged) await chatBridge.stop();
 
   try {
@@ -351,7 +319,6 @@ export async function applyProvider(
       : String(input.modelRecordId),
     baseUrl: displayBaseUrl,
     providerId: spec.id,
-    builtinProvider: spec.builtinProvider ?? null,
     name: spec.name ?? null,
     appliedAt: new Date().toISOString(),
     ...(bridged ? { bridged: true } : {}),

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

@@ -39,7 +39,8 @@ export interface BackendModelRecord {
 
 /**
  * 应用到 Codex 运行时的入参。厂商协议类型在 agent_model_provider.type 上(OLLAMA/OPENAI/…),
- * 不在 model 记录里,所以由渲染进程把 /models 与 /providers 关联好后显式传进来。
+ * 不在 model 记录里,所以由渲染进程把 /models 与 /providers 关联好后显式传进来
+ * (只用于端点探测的 Ollama 消歧,provider 一律走自定义)。
  */
 export interface ApplyProviderInput {
   modelId: string;
@@ -48,10 +49,8 @@ export interface ApplyProviderInput {
   apiKey?: string | null;
   headersJson?: string | Record<string, string> | null;
   config?: string | Record<string, unknown> | null;
-  /** agent_model_provider.type;OLLAMA 走 Codex 内置 provider,不需要 api key */
+  /** agent_model_provider.type */
   providerType?: string | null;
-  /** 显式指定内置 provider id(默认由 providerType 推导,如 ollama) */
-  builtinProvider?: string | null;
 }
 
 export interface CodexPingResult {
@@ -84,7 +83,6 @@ export interface AppliedProviderInfo {
   modelRecordId: string | null;
   baseUrl: string | null;
   providerId: string | null;
-  builtinProvider: string | null;
   name: string | null;
   appliedAt: string;
   /**

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

@@ -257,7 +257,7 @@
           <div v-if="status?.applied" class="ai-plugin__applied">
             已应用:<b>{{ status.applied.name || status.applied.model }}</b>
             <span class="ai-plugin__mono">{{ status.applied.model }}</span>
-            <span class="ai-plugin__mono">{{ status.applied.baseUrl || status.applied.builtinProvider }}</span>
+            <span class="ai-plugin__mono">{{ status.applied.baseUrl }}</span>
             <span v-if="status.applied.bridged" class="ai-plugin__bridge-chip" title="端点只提供 /chat/completions,由内置桥接转换为 Responses 协议">
               桥接
             </span>

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

@@ -96,7 +96,6 @@ export interface AppliedProviderInfo {
   modelRecordId: string | null;
   baseUrl: string | null;
   providerId: string | null;
-  builtinProvider: string | null;
   name: string | null;
   appliedAt: string;
   /** true 表示经内置桥接转换(chat-only 端点);此时 baseUrl 是上游真实地址 */
@@ -149,7 +148,6 @@ export interface ApplyProviderInput {
   headersJson?: string | Record<string, string> | null;
   config?: string | Record<string, unknown> | null;
   providerType?: string | null;
-  builtinProvider?: string | null;
   modelRecordId?: string | number | null;
 }
 
@@ -178,7 +176,6 @@ export function codexProbeProvider(input: {
   apiKey?: string | null;
   modelId?: string | null;
   providerType?: string | null;
-  builtinProvider?: string | null;
 }): Promise<ProviderProbeResult> {
   return codexInvoke<ProviderProbeResult>('probeProvider', input);
 }

+ 10 - 0
ai-electron/frontend/src/codex/views/codexAnalysis/codexAnalysis.vue

@@ -228,7 +228,17 @@
     }
   }
 
+  /** 未应用 provider 时 Codex 只会打它自己的默认端点,报错还看不懂,所以没选模型就不发 */
+  const modelMissing = computed(() => !store.activeSession?.model);
+
   async function onSend(text: string) {
+    if (modelMissing.value) {
+      showMessage(
+        store.models.length ? '请先在下方选择本次会话使用的模型' : '后端「模型管理」里还没有可用模型,请先添加',
+        'warning',
+      );
+      return;
+    }
     try {
       await store.send(text);
     } catch (error: any) {