mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user