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.
This commit is contained in:
Jason
2026-05-30 23:58:20 +08:00
parent 8bf1660237
commit 0fbba4267c
3 changed files with 265 additions and 2 deletions
+87
View File
@@ -596,6 +596,19 @@ fn restore_live_settings_for_provider_backfill(
);
}
// `modelCatalog` is a cc-switchprivate 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"
);
}
}
@@ -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<string, unknown>)
.modelCatalog;
if (dbCatalog !== undefined) {
return { ...base, modelCatalog: dbCatalog };
}
}
return base;
}, [liveSettings, provider?.settingsConfig, appId]); // 只依赖 settingsConfig,不依赖整个 provider
// 固定 initialData,防止 provider 对象更新时重置表单
const initialData = useMemo(() => {
@@ -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 ? (
<div>
<div>{children}</div>
<div>{footer}</div>
</div>
) : null,
}));
vi.mock("@/components/providers/forms/ProviderForm", () => ({
ProviderForm: ({
initialData,
onSubmit,
}: {
initialData: {
name?: string;
websiteUrl?: string;
notes?: string;
settingsConfig?: Record<string, unknown>;
meta?: Record<string, unknown>;
icon?: string;
iconColor?: string;
};
onSubmit: (values: {
name: string;
websiteUrl: string;
notes?: string;
settingsConfig: string;
meta?: Record<string, unknown>;
icon?: string;
iconColor?: string;
}) => void;
}) => (
<form
id="provider-form"
onSubmit={(event) => {
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,
});
}}
>
<output data-testid="settings-config">
{JSON.stringify(initialData.settingsConfig ?? {})}
</output>
</form>
),
}));
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(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={handleSubmit}
appId="codex"
/>,
);
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,
});
});
});