From 0fbba4267c77168f777113a996fed39aafe5ccbd Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 30 May 2026 23:58:20 +0800 Subject: [PATCH] Fix Codex model catalog being wiped by live-config backfill `modelCatalog` is a cc-switch-private field whose SSOT is the database; Live's config.toml only carries a lossy `model_catalog_json` projection. Proxy takeover/restore cycles and the official Codex.app rewriting config.toml can drop that projection, so `read_live_settings` reconstructs an empty catalog. Two paths then overwrote the stored mapping with that empty Live snapshot: - Switch-away backfill (`switch_normal` -> `restore_live_settings_for_provider_backfill`): now overlays the DB provider's `modelCatalog`, falling back to the Live-reconstructed one only when the DB has none. - Edit dialog (`EditProviderDialog`): when editing the active Codex provider it preferred Live over the DB SSOT; now keeps the DB `modelCatalog` so opening + saving no longer clears the mapping table. Add Rust backfill tests (preserve DB catalog when Live lacks it; keep Live catalog when DB has none) and a frontend regression test for the edit dialog. --- src-tauri/src/services/provider/live.rs | 87 ++++++++++ .../providers/EditProviderDialog.tsx | 24 ++- tests/components/EditProviderDialog.test.tsx | 156 ++++++++++++++++++ 3 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 tests/components/EditProviderDialog.test.tsx diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index b4f70bd8e..90b5a4214 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -596,6 +596,19 @@ fn restore_live_settings_for_provider_backfill( ); } + // `modelCatalog` is a cc-switch–private field whose SSOT is the DB. Live's + // `config.toml` only carries a lossy projection (`model_catalog_json` → + // generated catalog file) that proxy takeover/restore cycles and Codex.app + // config rewrites can drop, so `read_live_settings` may reconstruct it as + // absent. Never let a switch-away backfill from Live erase the stored + // mapping: prefer the DB provider's `modelCatalog`, falling back to whatever + // Live reconstructed only when the DB has none. + if let Some(stored_catalog) = provider.settings_config.get("modelCatalog") { + if let Some(obj) = settings.as_object_mut() { + obj.insert("modelCatalog".to_string(), stored_catalog.clone()); + } + } + settings } @@ -1647,4 +1660,78 @@ mod tests { .collect(); assert_eq!(values, vec!["tool2"]); } + + #[test] + fn codex_switch_backfill_preserves_stored_model_catalog_when_live_lacks_it() { + // Reproduces the data-loss bug: switching away from a Codex provider + // backfills the outgoing provider from Live, but Live's config.toml had + // already lost its `model_catalog_json` projection (proxy cycle / + // Codex.app rewrite), so `read_live_settings` reconstructs no catalog. + // The stored mapping must survive the backfill. + let mut provider = Provider::with_id( + "deepseek".to_string(), + "DeepSeek".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-deepseek" }, + "config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-pro\"\n", + "modelCatalog": { + "models": [ + { "model": "deepseek-v4-pro", "contextWindow": 1_000_000 } + ] + } + }), + None, + ); + provider.category = Some("cn_official".to_string()); + + // Live snapshot as captured during switch: no `modelCatalog` field. + let live_settings = json!({ + "auth": { "OPENAI_API_KEY": "sk-deepseek" }, + "config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-pro\"\n" + }); + + let result = + restore_live_settings_for_provider_backfill(&AppType::Codex, &provider, live_settings); + + assert_eq!( + result.get("modelCatalog"), + provider.settings_config.get("modelCatalog"), + "switch-away backfill must keep the DB-stored modelCatalog when Live has none" + ); + } + + #[test] + fn codex_switch_backfill_keeps_live_catalog_when_db_has_none() { + // When the DB provider has no stored catalog, a catalog reconstructed + // from Live (if any) should be left intact — the DB-preference overlay + // must not wipe it. + let mut provider = Provider::with_id( + "deepseek".to_string(), + "DeepSeek".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-deepseek" }, + "config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-pro\"\n" + }), + None, + ); + provider.category = Some("cn_official".to_string()); + + let live_settings = json!({ + "auth": { "OPENAI_API_KEY": "sk-deepseek" }, + "config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-pro\"\n", + "modelCatalog": { "models": [ { "model": "deepseek-v4-pro" } ] } + }); + + let result = restore_live_settings_for_provider_backfill( + &AppType::Codex, + &provider, + live_settings.clone(), + ); + + assert_eq!( + result.get("modelCatalog"), + live_settings.get("modelCatalog"), + "backfill must keep the Live-reconstructed catalog when the DB has none" + ); + } } diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index b1c7e593c..2536eb9d6 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -132,11 +132,31 @@ export function EditProviderDialog({ }, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象 const initialSettingsConfig = useMemo(() => { - return (liveSettings ?? provider?.settingsConfig ?? {}) as Record< + const base = (liveSettings ?? provider?.settingsConfig ?? {}) as Record< string, unknown >; - }, [liveSettings, provider?.settingsConfig]); // 只依赖 settingsConfig,不依赖整个 provider + + // Codex 的 modelCatalog 是 cc-switch 私有字段,SSOT 在数据库。Live 的 config.toml + // 仅在写入时投影出 model_catalog_json 指针;Codex.app 改写配置、代理接管/恢复周期、 + // 来回切换供应商都可能让 Live 丢失该投影,从而 read_live_settings 反解为空。 + // 若放任 Live 覆盖,编辑界面会显示空映射表,保存后连同数据库里的映射一起清空(数据丢失)。 + // 因此始终以数据库 SSOT 的 modelCatalog 为准,仅在数据库确实没有时才回退到 Live 反解结果。 + if ( + appId === "codex" && + liveSettings && + provider?.settingsConfig && + typeof provider.settingsConfig === "object" + ) { + const dbCatalog = (provider.settingsConfig as Record) + .modelCatalog; + if (dbCatalog !== undefined) { + return { ...base, modelCatalog: dbCatalog }; + } + } + + return base; + }, [liveSettings, provider?.settingsConfig, appId]); // 只依赖 settingsConfig,不依赖整个 provider // 固定 initialData,防止 provider 对象更新时重置表单 const initialData = useMemo(() => { diff --git a/tests/components/EditProviderDialog.test.tsx b/tests/components/EditProviderDialog.test.tsx new file mode 100644 index 000000000..b4b35a29d --- /dev/null +++ b/tests/components/EditProviderDialog.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Provider } from "@/types"; + +const apiMocks = vi.hoisted(() => ({ + getCurrent: vi.fn(), + getLiveProviderSettings: vi.fn(), + getOpenClawLiveProvider: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + providersApi: { + getCurrent: apiMocks.getCurrent, + }, + vscodeApi: { + getLiveProviderSettings: apiMocks.getLiveProviderSettings, + }, + openclawApi: { + getLiveProvider: apiMocks.getOpenClawLiveProvider, + }, +})); + +vi.mock("@/components/common/FullScreenPanel", () => ({ + FullScreenPanel: ({ + isOpen, + children, + footer, + }: { + isOpen: boolean; + children: React.ReactNode; + footer?: React.ReactNode; + }) => + isOpen ? ( +
+
{children}
+
{footer}
+
+ ) : null, +})); + +vi.mock("@/components/providers/forms/ProviderForm", () => ({ + ProviderForm: ({ + initialData, + onSubmit, + }: { + initialData: { + name?: string; + websiteUrl?: string; + notes?: string; + settingsConfig?: Record; + meta?: Record; + icon?: string; + iconColor?: string; + }; + onSubmit: (values: { + name: string; + websiteUrl: string; + notes?: string; + settingsConfig: string; + meta?: Record; + icon?: string; + iconColor?: string; + }) => void; + }) => ( +
{ + event.preventDefault(); + onSubmit({ + name: initialData.name ?? "", + websiteUrl: initialData.websiteUrl ?? "", + notes: initialData.notes, + settingsConfig: JSON.stringify(initialData.settingsConfig ?? {}), + meta: initialData.meta, + icon: initialData.icon, + iconColor: initialData.iconColor, + }); + }} + > + + {JSON.stringify(initialData.settingsConfig ?? {})} + +
+ ), +})); + +import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; + +describe("EditProviderDialog", () => { + beforeEach(() => { + apiMocks.getCurrent.mockReset(); + apiMocks.getLiveProviderSettings.mockReset(); + apiMocks.getOpenClawLiveProvider.mockReset(); + }); + + it("保留 Codex 数据库中的 modelCatalog,避免 live 配置缺字段时清空模型映射", async () => { + const dbModelCatalog = { + models: [ + { + model: "deepseek-v4-flash", + displayName: "DeepSeek V4 Flash", + contextWindow: 1000000, + }, + ], + }; + const provider: Provider = { + id: "deepseek", + name: "DeepSeek", + category: "aggregator", + settingsConfig: { + auth: { + OPENAI_API_KEY: "db-key", + }, + config: 'model_provider = "custom"\nmodel = "deepseek-v4-flash"\n', + modelCatalog: dbModelCatalog, + }, + }; + const liveSettings = { + auth: { + OPENAI_API_KEY: "live-key", + }, + config: 'model_provider = "custom"\nmodel = "deepseek-v4-pro"\n', + }; + const handleSubmit = vi.fn().mockResolvedValue(undefined); + + apiMocks.getCurrent.mockResolvedValue(provider.id); + apiMocks.getLiveProviderSettings.mockResolvedValue(liveSettings); + + render( + , + ); + + await waitFor(() => { + expect( + JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"), + ).toEqual({ + ...liveSettings, + modelCatalog: dbModelCatalog, + }); + }); + + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); + expect(handleSubmit.mock.calls[0][0].provider.settingsConfig).toEqual({ + ...liveSettings, + modelCatalog: dbModelCatalog, + }); + }); +});