Browse Source

feat(codex): 端点探测改造为四态判定(P1)

probeResponsesEndpoint 替换为 probeEndpoint:用真实 modelId 探测(修 Ollama 对未拉取模型返回 404 被误判为不支持 Responses 的问题);四态判定 responses/chat-only/unsupported/unreachable;Ollama 经 /api/version 消歧(≥0.13.3 判直通,低于则 chat-only 并标记需升级)。controller probeProvider 贯通 modelId/providerType,Ollama 未配地址时探默认 localhost;applyProvider 对未配地址的内置 Ollama 补探 localhost 并降级为警告。前端探测结果三态展示(直通/桥接/不可对接)。chat-only 本期仍拒绝应用,桥接在 P3 接入。

验证:主进程 tsc --noEmit 0 报错、94 单测全过(+3);前端 type:check 89 条基线、本改动 0 报错。
cc 2 weeks ago
parent
commit
cf328ab357

+ 27 - 5
ai-electron/electron/controller/codexCtl.ts

@@ -8,7 +8,7 @@ import {
   baseUrlHint,
   clearProvider as clearRuntimeProvider,
   describeDroppedKeys,
-  probeResponsesEndpoint,
+  probeEndpoint,
 } from '../service/codex/providerService';
 import {
   defaultWorkspaceDir,
@@ -121,10 +121,26 @@ class CodexCtl {
   }
 
   /** 只做端点探测,不改运行时;页面在「应用」前给用户预览兼容性 */
-  async probeProvider(params: { baseUrl?: string; apiKey?: string | null }): Promise<Rpc<unknown>> {
+  async probeProvider(params: {
+    baseUrl?: string;
+    apiKey?: string | null;
+    modelId?: string | null;
+    providerType?: string | null;
+    builtinProvider?: string | null;
+  }): Promise<Rpc<unknown>> {
     return guard('probeProvider', async () => {
-      const baseUrl = requireString(params?.baseUrl, 'baseUrl');
-      const result = await probeResponsesEndpoint(baseUrl, params?.apiKey ?? null);
+      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 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) };
     });
   }
@@ -136,9 +152,15 @@ 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: baseUrlHint(applied.baseUrl),
+        hint,
         droppedConfigKeys: describeDroppedKeys(params),
         status: toStatusResult(runtime.status),
       };

+ 2 - 2
ai-electron/electron/service/codex/codexRuntime.itest.ts

@@ -210,11 +210,11 @@ describe('切换 provider 会重启子进程', () => {
     expect(JSON.stringify(persisted)).not.toContain(API_KEY);
   });
 
-  it('端点未实现 Responses API 时拒绝应用', async () => {
+  it('双协议都不存在的端点拒绝应用', async () => {
     nextStatus = 404;
     await expect(
       applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY, providerType: 'OPENAI' }),
-    ).rejects.toThrow(/未实现 Responses API/);
+    ).rejects.toThrow(/无法对接/);
   });
 
   it('clearProvider 回到无 provider 状态', async () => {

+ 79 - 28
ai-electron/electron/service/codex/providerService.test.ts

@@ -4,22 +4,40 @@ import {
   baseUrlHint,
   describeDroppedKeys,
   normalizeBaseUrl,
-  probeResponsesEndpoint,
+  probeEndpoint,
   toProviderSpec,
 } from './providerService';
 import type { ApplyProviderInput } from './types';
 
 let server: Server;
 let port = 0;
-let nextStatus = 404;
-let lastRequest: { url: string | null; auth: string | null } = { url: null, auth: null };
+let responsesStatus = 404;
+let chatStatus = 404;
+let ollamaVersion: string | null = null;
+let lastRequest: { url: string | null; auth: string | null; body: string | null } = { url: null, auth: null, body: null };
 
 beforeAll(async () => {
   server = createServer((req, res) => {
-    lastRequest = { url: req.url ?? null, auth: req.headers.authorization ?? null };
-    req.resume();
-    res.writeHead(nextStatus, { 'content-type': 'application/json' });
-    res.end(JSON.stringify({ status: nextStatus }));
+    const chunks: Buffer[] = [];
+    req.on('data', (chunk: Buffer) => chunks.push(chunk));
+    req.on('end', () => {
+      lastRequest = {
+        url: req.url ?? null,
+        auth: req.headers.authorization ?? null,
+        body: Buffer.concat(chunks).toString('utf8') || null,
+      };
+      res.setHeader('content-type', 'application/json');
+      if (req.url === '/api/version') {
+        if (ollamaVersion === null) {
+          res.writeHead(404).end('{}');
+        } else {
+          res.writeHead(200).end(JSON.stringify({ version: ollamaVersion }));
+        }
+        return;
+      }
+      const status = req.url?.endsWith('/chat/completions') ? chatStatus : responsesStatus;
+      res.writeHead(status).end(JSON.stringify({ status }));
+    });
   });
   await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
   const address = server.address();
@@ -110,36 +128,69 @@ describe('toProviderSpec', () => {
   });
 });
 
-describe('probeResponsesEndpoint', () => {
-  it('打到 base_url 下的 /responses', async () => {
-    nextStatus = 401;
-    await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1/`, 'sk-test-key');
+describe('probeEndpoint', () => {
+  const base = () => `http://127.0.0.1:${port}/v1`;
+
+  it('用真实模型名探 /responses,带上 api key', async () => {
+    responsesStatus = 401;
+    const result = await probeEndpoint({
+      baseUrl: `${base()}/`,
+      modelId: 'qwen3-max',
+      apiKey: 'sk-test-key',
+    });
+    expect(result.protocol).toBe('responses');
     expect(lastRequest.url).toBe('/v1/responses');
     expect(lastRequest.auth).toBe('Bearer sk-test-key');
+    expect(JSON.parse(lastRequest.body ?? '{}').model).toBe('qwen3-max');
   });
 
-  it('404/405 判为未实现 Responses API', async () => {
-    nextStatus = 404;
-    const notFound = await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`);
-    expect(notFound).toMatchObject({ compatible: false, status: 404 });
-    expect(notFound.detail).toMatch(/未实现 Responses API/);
+  it('401/400/2xx 判为 responses 直通', async () => {
+    for (const status of [401, 400, 200]) {
+      responsesStatus = status;
+      const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
+      expect(result).toMatchObject({ protocol: 'responses', responsesStatus: status });
+    }
+  });
 
-    nextStatus = 405;
-    expect((await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`)).compatible).toBe(false);
+  it('双协议 404 判 unsupported', async () => {
+    responsesStatus = 404;
+    chatStatus = 404;
+    const result = await probeEndpoint({ baseUrl: base(), modelId: 'x', providerType: 'OPENAI' });
+    expect(result.protocol).toBe('unsupported');
+    expect(result.detail).toMatch(/无法对接/);
   });
 
-  it('401/400/2xx 判为路由存在(兼容)', async () => {
-    for (const status of [401, 400, 200]) {
-      nextStatus = status;
-      const result = await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`);
-      expect(result).toMatchObject({ compatible: true, status });
-    }
+  it('responses 404 + chat 存在判 chat-only', async () => {
+    responsesStatus = 404;
+    chatStatus = 200;
+    const result = await probeEndpoint({ baseUrl: base(), modelId: 'x', providerType: 'VLLM' });
+    expect(result).toMatchObject({ protocol: 'chat-only', responsesStatus: 404, chatStatus: 200 });
+    expect(result.detail).toMatch(/chat\/completions/);
+  });
+
+  it('Ollama ≥ 0.13.3:/responses 404 只是模型未拉取,判 responses', async () => {
+    responsesStatus = 404;
+    chatStatus = 404;
+    ollamaVersion = '0.13.3';
+    const result = await probeEndpoint({ baseUrl: base(), modelId: 'qwen3:8b', providerType: 'OLLAMA' });
+    expect(result).toMatchObject({ protocol: 'responses', ollamaVersion: '0.13.3', ollamaNeedsUpgrade: false });
+    expect(result.detail).toMatch(/ollama pull/);
+  });
+
+  it('Ollama < 0.13.3:判 chat-only 并标记需要升级', async () => {
+    responsesStatus = 404;
+    chatStatus = 200;
+    ollamaVersion = '0.12.9';
+    const result = await probeEndpoint({ baseUrl: base(), modelId: 'qwen3:8b', providerType: 'OLLAMA' });
+    expect(result).toMatchObject({ protocol: 'chat-only', ollamaVersion: '0.12.9', ollamaNeedsUpgrade: true });
+    expect(result.detail).toMatch(/低于 0\.13\.3/);
+    ollamaVersion = null;
   });
 
-  it('端点不可达时 status 为 null', async () => {
-    const result = await probeResponsesEndpoint('http://127.0.0.1:1/v1');
-    expect(result.compatible).toBe(false);
-    expect(result.status).toBeNull();
+  it('端点不可达判 unreachable', async () => {
+    const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
+    expect(result.protocol).toBe('unreachable');
+    expect(result.responsesStatus).toBeNull();
     expect(result.detail).toMatch(/不可达/);
   });
 });

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

@@ -14,10 +14,19 @@ export const PROVIDER_ID = 'zsjz';
 
 export type AppliedProvider = AppliedProviderInfo;
 
-export interface ProviderCompatibility {
-  compatible: boolean;
-  /** null 表示网络不可达,无法判定 */
-  status: number | null;
+/** 端点协议判定:responses 直通;chat-only 走内置桥接;unsupported 双协议都没有;unreachable 网络不可达 */
+export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
+
+export interface EndpointProbeResult {
+  protocol: EndpointProtocol;
+  /** /responses 的 HTTP 状态;未探或网络失败为 null */
+  responsesStatus: number | null;
+  /** /chat/completions 的 HTTP 状态;未探为 null */
+  chatStatus: number | null;
+  /** /api/version 探到的 Ollama 版本;非 Ollama 或未探为 null */
+  ollamaVersion: string | null;
+  /** Ollama 低于 0.13.3(没有非状态化 /v1/responses):提示升级,但允许走桥接 */
+  ollamaNeedsUpgrade: boolean;
   detail: string;
 }
 
@@ -131,17 +140,15 @@ export function describeDroppedKeys(input: ApplyProviderInput): string[] {
   return Object.keys(config ?? {}).filter((key) => !EXTRA_ALLOWLIST.includes(key));
 }
 
-/**
- * 探测端点是否实现了 Responses API。
- * 404/405 = 未实现(Codex 0.155 起已下线 wire_api="chat",chat-only 端点用不了);
- * 401/403/400/422/2xx = 路由存在,判为兼容。
- */
-export async function probeResponsesEndpoint(
-  baseUrl: string,
-  apiKey?: string | null,
-): Promise<ProviderCompatibility> {
-  const url = `${baseUrl.replace(/\/+$/u, '')}/responses`;
-  let status: number;
+/** Ollama 自 0.13.3 起提供非状态化 /v1/responses(流式 / 工具调用 / reasoning summaries) */
+const OLLAMA_RESPONSES_MIN_VERSION: readonly number[] = [0, 13, 3];
+
+interface ProbeAttempt {
+  status: number | null;
+  error: string | null;
+}
+
+async function postProbe(url: string, body: Record<string, unknown>, apiKey?: string | null): Promise<ProbeAttempt> {
   try {
     const response = await fetch(url, {
       method: 'POST',
@@ -149,25 +156,131 @@ export async function probeResponsesEndpoint(
         'content-type': 'application/json',
         ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
       },
-      body: JSON.stringify({ model: 'probe', input: 'probe' }),
+      body: JSON.stringify(body),
       signal: AbortSignal.timeout(8_000),
     });
-    status = response.status;
+    return { status: response.status, error: null };
   } catch (error) {
-    return {
-      compatible: false,
-      status: null,
-      detail: `端点不可达:${error instanceof Error ? error.message : String(error)}`,
-    };
+    return { status: null, error: error instanceof Error ? error.message : String(error) };
   }
-  if (status === 404 || status === 405) {
-    return {
-      compatible: false,
-      status,
-      detail: `该端点未实现 Responses API(HTTP ${status})。Codex 0.155 起已下线 wire_api="chat",只提供 /chat/completions 的服务需要额外网关转换。`,
-    };
+}
+
+/** Ollama 的 /api/version 挂在 API 根之外(去掉 /v1 尾段);非 Ollama 端点返回 null */
+async function probeOllamaVersion(baseUrl: string): Promise<string | null> {
+  const origin = baseUrl.replace(/\/v\d+$/u, '');
+  try {
+    const response = await fetch(`${origin}/api/version`, { signal: AbortSignal.timeout(5_000) });
+    if (response.status !== 200) return null;
+    const parsed = (await response.json()) as { version?: unknown };
+    return typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
+  } catch {
+    return null;
+  }
+}
+
+function parseVersion(raw: string): number[] | null {
+  const match = raw.trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?/u);
+  if (!match) return null;
+  return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
+}
+
+function versionLt(a: number[], b: readonly number[]): boolean {
+  for (let index = 0; index < 3; index += 1) {
+    if (a[index] !== b[index]) return a[index] < b[index];
+  }
+  return false;
+}
+
+function probeResult(partial: Partial<EndpointProbeResult> & { protocol: EndpointProtocol; detail: string }): EndpointProbeResult {
+  return {
+    responsesStatus: null,
+    chatStatus: null,
+    ollamaVersion: null,
+    ollamaNeedsUpgrade: false,
+    ...partial,
+  };
+}
+
+/**
+ * 探测端点协议能力(四态判定)。
+ *
+ * Codex 0.155+ 只会发 Responses API:responses → 直通;chat-only → 内置桥接转换;
+ * 其余两种拒绝。判定顺序:
+ *  1. POST /responses(用**真实模型名**——Ollama 对未拉取的模型返回 404,不能直接判端点不支持);
+ *  2. 404/405 且是 Ollama → 查 /api/version 消歧:≥0.13.3 说明 404 只是模型没拉(判 responses),
+ *     低于 0.13.3 直接判 chat-only(/chat/completions 必有);
+ *  3. 否则再 POST /chat/completions:路由存在判 chat-only,也不存在判 unsupported。
+ */
+export async function probeEndpoint(params: {
+  baseUrl: string;
+  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 responses = await postProbe(
+    `${baseUrl}/responses`,
+    { model, input: 'ping', max_output_tokens: 16, stream: false },
+    params.apiKey,
+  );
+  if (responses.status === null) {
+    return probeResult({ protocol: 'unreachable', detail: `端点不可达:${responses.error ?? '网络错误'}` });
+  }
+  if (responses.status !== 404 && responses.status !== 405) {
+    return probeResult({
+      protocol: 'responses',
+      responsesStatus: responses.status,
+      detail: `端点已实现 Responses API(HTTP ${responses.status})`,
+    });
+  }
+
+  if (isOllama) {
+    const version = await probeOllamaVersion(baseUrl);
+    if (version) {
+      const parsed = parseVersion(version);
+      if (parsed && !versionLt(parsed, OLLAMA_RESPONSES_MIN_VERSION)) {
+        return probeResult({
+          protocol: 'responses',
+          responsesStatus: responses.status,
+          ollamaVersion: version,
+          detail: `Ollama ${version} 支持 Responses API;/responses 返回 404 通常只是模型「${model}」未拉取,请先 ollama pull ${model}`,
+        });
+      }
+      return probeResult({
+        protocol: 'chat-only',
+        responsesStatus: responses.status,
+        ollamaVersion: version,
+        ollamaNeedsUpgrade: true,
+        detail: `Ollama ${version} 低于 0.13.3,只提供 /chat/completions(建议升级 Ollama 以直连)`,
+      });
+    }
   }
-  return { compatible: true, status, detail: `端点已实现 Responses API(HTTP ${status})` };
+
+  const chat = await postProbe(
+    `${baseUrl}/chat/completions`,
+    { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false },
+    params.apiKey,
+  );
+  if (chat.status !== null && chat.status !== 404 && chat.status !== 405) {
+    return probeResult({
+      protocol: 'chat-only',
+      responsesStatus: responses.status,
+      chatStatus: chat.status,
+      detail: `端点只提供 /chat/completions(/responses 返回 HTTP ${responses.status})`,
+    });
+  }
+  return probeResult({
+    protocol: 'unsupported',
+    responsesStatus: responses.status,
+    chatStatus: chat.status,
+    detail: `端点 Responses 与 Chat Completions 均不存在(HTTP ${responses.status}/${chat.status ?? '网络错误'}),无法对接`,
+  });
 }
 
 export async function readAppliedProvider(): Promise<AppliedProvider | null> {
@@ -193,8 +306,17 @@ export async function applyProvider(
   // 只要显式配了地址就探(含远端 Ollama):应用成功后才发现连不上,比这里直接报错更难排查。
   // 内置 provider 没配地址时不探(默认 localhost,可能压根没起本地服务)。
   if (spec.baseUrl) {
-    const probe = await probeResponsesEndpoint(spec.baseUrl, spec.apiKey);
-    if (!probe.compatible) throw new Error(probe.detail);
+    const probe = await probeEndpoint({
+      baseUrl: spec.baseUrl,
+      modelId: spec.model,
+      apiKey: spec.apiKey,
+      providerType: input.providerType,
+      builtinProvider: spec.builtinProvider,
+    });
+    if (probe.protocol === 'chat-only') {
+      throw new Error(`${probe.detail}。内置桥接转换尚未启用,当前请先升级端点(Ollama 需 ≥ 0.13.3)`);
+    }
+    if (probe.protocol !== 'responses') throw new Error(probe.detail);
   }
   await runtime.applyProvider(spec);
   const applied: AppliedProvider = {

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

@@ -159,8 +159,8 @@
       <a-tab-pane key="runtime" tab="Codex 运行时">
         <div class="ai-plugin__hint">
           本客户端<b>不做任何 Codex / OpenAI 账号登录</b>:模型来自后端「模型管理」, 选中后由主进程写入本机 Codex
-          配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 起只支持 Responses 协议,仅提供
-          <b>/chat/completions</b> 的端点会在探测阶段被拦下。
+          配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 起只发 Responses 协议;仅提供
+          <b>/chat/completions</b> 的端点会由内置桥接自动转换,本地 Ollama 建议 0.13.3+ 以直连。
         </div>
 
         <div class="ai-plugin__panel">
@@ -235,9 +235,9 @@
           <a-alert
             v-if="probeResult"
             class="ai-plugin__alert"
-            :type="probeResult.compatible ? 'success' : 'error'"
+            :type="probeAlertType"
             show-icon
-            :message="probeResult.compatible ? '端点兼容' : '端点不兼容'"
+            :message="probeAlertTitle"
             :description="probeResult.detail"
           />
           <a-alert
@@ -510,6 +510,25 @@
 
   const selectedModel = computed(() => models.value.find((item) => item.id === selectedModelId.value) || null);
 
+  /** 探测结果三态:responses 直通(绿)/ chat-only 桥接(黄)/ 其余(红) */
+  const probeAlertType = computed<'success' | 'warning' | 'error'>(() => {
+    if (probeResult.value?.protocol === 'responses') return 'success';
+    if (probeResult.value?.protocol === 'chat-only') return 'warning';
+    return 'error';
+  });
+  const probeAlertTitle = computed(() => {
+    switch (probeResult.value?.protocol) {
+      case 'responses':
+        return '端点兼容(Responses 协议直通)';
+      case 'chat-only':
+        return '仅支持 Chat 协议(应用时将启用内置桥接)';
+      case 'unsupported':
+        return '端点无法对接(双协议均不存在)';
+      default:
+        return '端点不可达';
+    }
+  });
+
   function mcpTarget(record: codexApi.CodexMcpServer) {
     if (record.transport === 'http') return record.url || '—';
     return [record.command, ...(record.args || [])].filter(Boolean).join(' ') || '—';
@@ -611,6 +630,8 @@
       probeResult.value = await codexApi.codexProbeProvider({
         baseUrl: input.baseUrl || '',
         apiKey: input.apiKey,
+        modelId: input.modelId,
+        providerType: input.providerType,
       });
     } catch (e: any) {
       showMessage(e?.message || '探测失败', 'error');

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

@@ -163,9 +163,15 @@ export interface ApplyProviderInput {
   modelRecordId?: string | number | null;
 }
 
+/** 端点协议判定:responses 直通;chat-only 走内置桥接;unsupported 双协议都没有;unreachable 不可达 */
+export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable';
+
 export interface ProviderProbeResult {
-  compatible: boolean;
-  status: number | null;
+  protocol: EndpointProtocol;
+  responsesStatus: number | null;
+  chatStatus: number | null;
+  ollamaVersion: string | null;
+  ollamaNeedsUpgrade: boolean;
   detail: string;
   hint: string | null;
 }
@@ -177,7 +183,13 @@ export interface ApplyProviderResult {
   status: CodexStatus;
 }
 
-export function codexProbeProvider(input: { baseUrl: string; apiKey?: string | null }): Promise<ProviderProbeResult> {
+export function codexProbeProvider(input: {
+  baseUrl: string;
+  apiKey?: string | null;
+  modelId?: string | null;
+  providerType?: string | null;
+  builtinProvider?: string | null;
+}): Promise<ProviderProbeResult> {
   return codexInvoke<ProviderProbeResult>('probeProvider', input);
 }