cc 1 nedēļu atpakaļ
vecāks
revīzija
2c983598bf

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

@@ -85,6 +85,12 @@ describe('buildArgs', () => {
     expect(withWsTimeout).toContain('model_providers.zsjz.websocket_connect_timeout_ms=5000');
     expect(withWsTimeout).toContain('model_providers.zsjz.websocket_connect_timeout_ms=5000');
   });
   });
 
 
+  it('modelCatalogPath 会以 model_catalog_json 传入,未设置时不出现该键', () => {
+    const withCatalog = buildArgs({ ...custom, modelCatalogPath: 'C:\\data\\codex-data\\model-catalog.json' }).join(' ');
+    expect(withCatalog).toContain('model_catalog_json="C:\\\\data\\\\codex-data\\\\model-catalog.json"');
+    expect(buildArgs(custom).join(' ')).not.toContain('model_catalog_json');
+  });
+
   it('值里的引号与反斜杠被 TOML 安全转义', () => {
   it('值里的引号与反斜杠被 TOML 安全转义', () => {
     const joined = buildArgs({
     const joined = buildArgs({
       ...custom,
       ...custom,

+ 9 - 0
ai-electron/electron/service/codex/codexRuntime.ts

@@ -73,6 +73,12 @@ export interface CodexProviderSpec {
   envHttpHeaders?: Record<string, string> | null;
   envHttpHeaders?: Record<string, string> | null;
   /** 仅放行 PROVIDER_EXTRA_ALLOWLIST 里的键,其余在 providerService 就被丢弃 */
   /** 仅放行 PROVIDER_EXTRA_ALLOWLIST 里的键,其余在 providerService 就被丢弃 */
   extra?: Record<string, JsonValue> | null;
   extra?: Record<string, JsonValue> | null;
+  /**
+   * model_catalog_json 的绝对路径(providerService 落盘后填入)。
+   * 自定义模型不在 codex 内置目录里,没有目录就会用兜底元数据并打
+   * "Model metadata not found" 警告;目录整体替换内置目录,-c 随进程启动生效。
+   */
+  modelCatalogPath?: string | null;
 }
 }
 
 
 export interface CodexRuntimeOptions {
 export interface CodexRuntimeOptions {
@@ -760,6 +766,9 @@ export function buildArgs(provider: CodexProviderSpec | null): string[] {
     if (!PROVIDER_EXTRA_ALLOWED.has(key) || value === null || value === undefined) continue;
     if (!PROVIDER_EXTRA_ALLOWED.has(key) || value === null || value === undefined) continue;
     args.push('-c', `${prefix}.${key}=${tomlValue(value)}`);
     args.push('-c', `${prefix}.${key}=${tomlValue(value)}`);
   }
   }
+  if (provider.modelCatalogPath) {
+    args.push('-c', `model_catalog_json=${tomlString(provider.modelCatalogPath)}`);
+  }
   return args;
   return args;
 }
 }
 
 

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 6 - 0
ai-electron/electron/service/codex/defaultModelInstructions.ts


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

@@ -1,5 +1,5 @@
 import { createServer, type Server } from 'node:http';
 import { createServer, type Server } from 'node:http';
-import { mkdtempSync } from 'node:fs';
+import { mkdtempSync, readFileSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import { join } from 'node:path';
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -241,6 +241,33 @@ describe('applyProvider 交给运行时的 spec', () => {
     expect(applied).not.toHaveProperty('bridged');
     expect(applied).not.toHaveProperty('bridged');
   });
   });
 
 
+  it('落盘 model-catalog.json 并把路径挂到 spec,目录条目带 slug 与 instructions_template', async () => {
+    responsesStatus = 400;
+    const stub = new StubRuntime();
+    await applyProvider(stub as unknown as CodexRuntime, {
+      modelId: 'qwen',
+      name: '自部署 Qwen',
+      baseUrl: `http://127.0.0.1:${port}/v1`,
+    });
+    const spec = stub.spec;
+    expect(spec?.modelCatalogPath).toBeTruthy();
+
+    const catalog = JSON.parse(readFileSync(spec!.modelCatalogPath!, 'utf8')) as {
+      models: Array<Record<string, unknown>>;
+    };
+    expect(catalog.models).toHaveLength(1);
+    const entry = catalog.models[0] as Record<string, unknown> & {
+      slug: string;
+      display_name: string;
+      model_messages: { instructions_template: string };
+    };
+    // slug 必须与应用的 model 一致,codex 靠它命中元数据(used_fallback 归零、警告消失)
+    expect(entry.slug).toBe('qwen');
+    expect(entry.display_name).toBe('自部署 Qwen');
+    // 0.155.1 校验:目录模型缺 instructions_template 会在启动时报错
+    expect(entry.model_messages.instructions_template.length).toBeGreaterThan(1000);
+  });
+
   it('端点只会 Chat 时直接报错,且不碰运行时', async () => {
   it('端点只会 Chat 时直接报错,且不碰运行时', async () => {
     responsesStatus = 404;
     responsesStatus = 404;
     chatStatus = 400;
     chatStatus = 400;

+ 64 - 0
ai-electron/electron/service/codex/providerService.ts

@@ -1,6 +1,7 @@
 import { mkdir, readFile, writeFile } from 'node:fs/promises';
 import { mkdir, readFile, writeFile } from 'node:fs/promises';
 import { join } from 'node:path';
 import { join } from 'node:path';
 import { getCodexDataDir } from './codexHome';
 import { getCodexDataDir } from './codexHome';
+import { DEFAULT_MODEL_INSTRUCTIONS } from './defaultModelInstructions';
 import { DEFAULT_API_KEY_ENV, PROVIDER_EXTRA_ALLOWLIST, type CodexProviderSpec, type CodexRuntime, type JsonValue } from './codexRuntime';
 import { DEFAULT_API_KEY_ENV, PROVIDER_EXTRA_ALLOWLIST, type CodexProviderSpec, type CodexRuntime, type JsonValue } from './codexRuntime';
 import type { AppliedProviderInfo, ApplyProviderInput } from './types';
 import type { AppliedProviderInfo, ApplyProviderInput } from './types';
 
 
@@ -99,6 +100,10 @@ function readStringMap(raw: unknown): Record<string, string> | null {
  * 后端「模型管理」的一条记录一律翻译成自定义 provider:Codex 0.155.1 把 ollama / lmstudio
  * 后端「模型管理」的一条记录一律翻译成自定义 provider:Codex 0.155.1 把 ollama / lmstudio
  * 等内置 id 列为保留字,覆盖 model_providers.<内置 id> 会让 app-server 启动即退出。
  * 等内置 id 列为保留字,覆盖 model_providers.<内置 id> 会让 app-server 启动即退出。
  * 地址缺失就是配置缺失,直接报错让页面提示,不做 localhost 兜底。
  * 地址缺失就是配置缺失,直接报错让页面提示,不做 localhost 兜底。
+ *
+ * model 目录(model_catalog_json)在 applyProvider 里另行生成落盘后挂到 spec 上:
+ * 自定义模型 slug 不在 codex 内置目录里,线程会落到兜底元数据并打出
+ * "Model metadata for `xxx` not found" 警告(上下文窗口/截断策略全靠猜)。
  */
  */
 export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
 export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
   const model = `${input.modelId ?? ''}`.trim();
   const model = `${input.modelId ?? ''}`.trim();
@@ -264,6 +269,64 @@ async function writeAppliedProvider(value: AppliedProvider | null): Promise<void
   await writeFile(providerFile(), JSON.stringify(value ?? {}, null, 2), 'utf8');
   await writeFile(providerFile(), JSON.stringify(value ?? {}, null, 2), 'utf8');
 }
 }
 
 
+/**
+ * 生成单条模型的目录条目,字段与 codex rust-v0.155.1 的 ModelInfo serde 形状对齐
+ * (实测 0.155.1 会校验:catalog 模型必须携带 base_instructions/instructions_template,
+ * 未知字段会报错,所以只写确认过的键)。元数据取 codex 对未知模型的兜底值,
+ * 这样除 used_fallback 归零(警告消失、特性门控放行)外,请求行为与之前完全一致。
+ */
+function buildModelCatalog(spec: CodexProviderSpec): Record<string, unknown> {
+  return {
+    models: [
+      {
+        slug: spec.model,
+        display_name: spec.name ?? spec.model,
+        description: null,
+        default_reasoning_level: null,
+        supported_reasoning_levels: [],
+        shell_type: 'shell_command',
+        visibility: 'list',
+        supported_in_api: true,
+        priority: 1000,
+        additional_speed_tiers: [],
+        service_tiers: [],
+        default_service_tier: null,
+        availability_nux: null,
+        upgrade: null,
+        default_reasoning_summary: 'none',
+        support_verbosity: false,
+        default_verbosity: null,
+        supports_image_detail_original: false,
+        supports_parallel_tool_calls: false,
+        supports_reasoning_summaries: true,
+        supports_search_tool: false,
+        truncation_policy: { mode: 'bytes', limit: 10_000 },
+        context_window: 272_000,
+        max_context_window: 272_000,
+        effective_context_window_percent: 95,
+        experimental_supported_tools: [],
+        input_modalities: ['text'],
+        model_messages: { instructions_template: DEFAULT_MODEL_INSTRUCTIONS },
+      },
+    ],
+  };
+}
+
+function catalogFile(): string {
+  return join(getCodexDataDir(), 'model-catalog.json');
+}
+
+/**
+ * 把目录文件落盘并返回路径。目录整体替换 codex 的内置目录(进程级),
+ * 单条 provider 对应单条模型;换模型会走 applyProvider 重启子进程,天然重建。
+ */
+async function writeModelCatalog(spec: CodexProviderSpec): Promise<string> {
+  const path = catalogFile();
+  await mkdir(getCodexDataDir(), { recursive: true });
+  await writeFile(path, JSON.stringify(buildModelCatalog(spec), null, 2), 'utf8');
+  return path;
+}
+
 /** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */
 /** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */
 export async function applyProvider(
 export async function applyProvider(
   runtime: CodexRuntime,
   runtime: CodexRuntime,
@@ -279,6 +342,7 @@ export async function applyProvider(
   });
   });
   if (probe.protocol !== 'responses') throw new Error(probe.detail);
   if (probe.protocol !== 'responses') throw new Error(probe.detail);
 
 
+  spec.modelCatalogPath = await writeModelCatalog(spec);
   await runtime.applyProvider(spec);
   await runtime.applyProvider(spec);
 
 
   const applied: AppliedProvider = {
   const applied: AppliedProvider = {

+ 20 - 2
ai-electron/frontend/src/codex/store/codexChatStore.ts

@@ -146,6 +146,11 @@ const MAX_PROMPTS_PER_THREAD = 200;
 /** 单条提问落盘上限 */
 /** 单条提问落盘上限 */
 const MAX_PROMPT_TEXT = 20_000;
 const MAX_PROMPT_TEXT = 20_000;
 
 
+/** base_url 比对前去掉收尾斜杠,与应用时 providerService 的归一化对齐 */
+function trimSlashes(value: string | null | undefined): string {
+  return (value ?? '').trim().replace(/\/+$/u, '');
+}
+
 /**
 /**
  * 提问必须落盘:Codex 的事件流里只有 agent 侧的输出,
  * 提问必须落盘:Codex 的事件流里只有 agent 侧的输出,
  * `eventsRead` 回读不到我们发出去的文本,不存本地就会出现
  * `eventsRead` 回读不到我们发出去的文本,不存本地就会出现
@@ -250,10 +255,23 @@ export const useCodexChatStore = defineStore('codex-chat', {
       if (model) return this.models.find((item) => item.modelId === model)?.id ?? '';
       if (model) return this.models.find((item) => item.modelId === model)?.id ?? '';
       return this.models.find((item) => item.defaultModel)?.id ?? '';
       return this.models.find((item) => item.defaultModel)?.id ?? '';
     },
     },
-    /** 会话选中的模型是否已经应用到运行时(provider 是全局单例,按后端记录 id 比对) */
+    /**
+     * 会话选中的模型是否已经应用到运行时。三处都得对上:
+     * 1. 运行时进程当前真的带着 provider —— applied 只是落盘快照,应用重启后
+     *    进程里刻意不自动恢复 provider,只信快照会让 codex 裸跑到内置 openai
+     *    provider 上(supports_websockets=true,每轮先试 WS 连 chatgpt.com,
+     *    超时后打出 "Falling back from WebSockets to HTTPS transport")。
+     * 2. 快照的记录 id 与会话选中的记录一致。
+     * 3. 快照的 base_url 与记录当前地址一致 —— 只改了地址没重新应用时也要重放。
+     */
     modelApplied(): boolean {
     modelApplied(): boolean {
       const recordId = this.sessionModelRecordId;
       const recordId = this.sessionModelRecordId;
-      return !!recordId && this.runtime?.applied?.modelRecordId === recordId;
+      if (!recordId || this.runtime?.providerId == null) return false;
+      const applied = this.runtime?.applied;
+      if (applied?.modelRecordId !== recordId) return false;
+      const record = this.models.find((item) => item.id === recordId);
+      if (!record) return false;
+      return trimSlashes(applied.baseUrl) === trimSlashes(record.baseUrl);
     },
     },
     /** 没有可用模型时的提示文案,发送前的两道校验共用 */
     /** 没有可用模型时的提示文案,发送前的两道校验共用 */
     modelGuardMessage(state): string {
     modelGuardMessage(state): string {

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels