From fc2a15af5c163472ef23712f37bc8a4ca7d02f25 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 18:58:06 +0800 Subject: [PATCH] fix(model-config-import): stop routing pi providers through cc-switch snapshot CC Switch only stores a one-shot v18 migration snapshot of ~/.pi/agent/models.json, and our importer dedupe key looks at endpoint + apiStyle + secret without comparing model coverage. When scanModelConfigs returned [...ccSwitch, ...extra], any pi provider that had grown after the sync landed under the cc-switch source with the snapshot's stale model list, and the fuller pi native draft was silently filtered out because it matched an earlier candidate. The reporter saw opencode-go lose deepseek-flash and deepseek lose two models after import. parseCcSwitchProvider now returns [] for app_type='pi' so the pi scanner keeps ownership of that authoritative file. Providers still missing on disk are simply skipped, which matches every other source whose native config no longer exists. fixes #588 --- .../desktop/test/model-config-import.test.mjs | 57 +++++++++++++++++++ .../shared/src/model-config-import.test.ts | 32 +++++++++++ packages/shared/src/model-config-import.ts | 11 ++-- 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/desktop/test/model-config-import.test.mjs b/apps/desktop/test/model-config-import.test.mjs index ea7ed8d79a..45d28c6398 100644 --- a/apps/desktop/test/model-config-import.test.mjs +++ b/apps/desktop/test/model-config-import.test.mjs @@ -157,3 +157,60 @@ test("scanModelConfigs keeps same-endpoint CC Switch profiles with different key assert.equal(drafts.find((d) => d.externalId === "claude:packy")?.secretValue, "sk-cc"); assert.equal(drafts.find((d) => d.externalId === "claude:other")?.secretValue, "sk-other"); }); + +// Regression: issue #588. Reproduces the reporter's scenario — a stale +// cc-switch v18 pi snapshot missing one model, plus a newer ~/.pi/agent/models.json +// that lists all ten. The old code returned the snapshot under the "cc-switch" +// source and silently dropped the tenth model. The fix routes pi rows through +// the "pi" scanner so the authoritative file wins. +test("scanModelConfigs keeps the pi native config authoritative when a cc-switch snapshot exists", async () => { + const home = await mkdtemp(join(tmpdir(), "pi-cc-switch-pi-")); + await mkdir(join(home, ".pi", "agent"), { recursive: true }); + await mkdir(join(home, ".cc-switch"), { recursive: true }); + + // Newer, authoritative pi config with ten models. + await writeFile( + join(home, ".pi", "agent", "models.json"), + JSON.stringify({ + providers: { + "opencode-go": { + name: "opencode-go", + baseUrl: "https://api.oj.ink/v1", + api: "openai-completions", + apiKey: "sk-pi", + models: Array.from({ length: 10 }, (_, i) => ({ id: `m${i + 1}` })), + }, + }, + }), + ); + + // Stale cc-switch snapshot: same endpoint + key, only nine models, and a + // renamed label that used to leak into the UI. + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(join(home, ".cc-switch", "cc-switch.db")); + db.exec( + "CREATE TABLE providers (id TEXT, app_type TEXT, name TEXT, settings_config TEXT)", + ); + db.prepare("INSERT INTO providers VALUES (?, ?, ?, ?)").run( + "opencode-go", + "pi", + "OpenCode Zen Go", + JSON.stringify({ + baseUrl: "https://api.oj.ink/v1", + api: "openai-completions", + apiKey: "sk-pi", + models: Array.from({ length: 9 }, (_, i) => ({ id: `m${i + 1}` })), + }), + ); + db.close(); + + const drafts = await scanModelConfigs({ homeDir: home, env: {} }); + const piDrafts = drafts.filter((d) => d.source === "pi"); + const ccDrafts = drafts.filter((d) => d.source === "cc-switch"); + assert.equal(ccDrafts.length, 0, "no pi rows should surface under the cc-switch source"); + assert.equal(piDrafts.length, 1); + assert.equal(piDrafts[0].externalId, "opencode-go"); + assert.equal(piDrafts[0].name, "opencode-go"); + assert.equal(piDrafts[0].modelIds.length, 10); + assert.ok(piDrafts[0].modelIds.includes("m10")); +}); diff --git a/packages/shared/src/model-config-import.test.ts b/packages/shared/src/model-config-import.test.ts index 381ea3d72d..650e7526a7 100644 --- a/packages/shared/src/model-config-import.test.ts +++ b/packages/shared/src/model-config-import.test.ts @@ -391,6 +391,38 @@ describe("parseCcSwitchProviders", () => { expect(rows).toHaveLength(1); expect(parseCcSwitchProviders(rows)[0]?.source).toBe("cc-switch"); }); + + // Regression: issue #588 — a stale cc-switch snapshot of ~/.pi/agent/models.json + // used to silently outrank the pi source and drop any models added after the + // one-shot sync. Rows with app_type='pi' must be ignored here so the "pi" + // scanner keeps ownership of the authoritative file. + it("skips app_type='pi' rows so the pi native config stays authoritative", () => { + const drafts = parseCcSwitchProviders([ + { + id: "opencode-go", + appType: "pi", + name: "OpenCode Zen Go", + settingsConfig: { + baseUrl: "https://api.oj.ink/v1", + api: "openai-completions", + apiKey: "sk-pi", + // Stale snapshot: only nine of ten models — missing `deepseek-flash`. + models: [ + { id: "m1" }, + { id: "m2" }, + { id: "m3" }, + { id: "m4" }, + { id: "m5" }, + { id: "m6" }, + { id: "m7" }, + { id: "m8" }, + { id: "m9" }, + ], + }, + }, + ]); + expect(drafts).toEqual([]); + }); }); describe("parseJsonDocument / parseTomlSubset", () => { diff --git a/packages/shared/src/model-config-import.ts b/packages/shared/src/model-config-import.ts index 7ee845f517..a6561dcff7 100644 --- a/packages/shared/src/model-config-import.ts +++ b/packages/shared/src/model-config-import.ts @@ -538,10 +538,13 @@ function parseCcSwitchProvider( ); } if (appType === "pi") { - return retagCcSwitch( - row, - parsePiModelConfig({ providers: { [row.id]: row.settingsConfig } }, env), - ); + // Pi providers have an authoritative native config (`~/.pi/agent/models.json`). + // CC Switch only stores a one-shot v18 migration snapshot of that file; models + // added afterwards never make it back in, and our dedupe key ignores `models` + // coverage. Trusting the snapshot silently drops the newer entries and lands + // pi providers under the "CC Switch" group with a renamed label. Let the `pi` + // scanner own these rows so the source of truth wins. See issue #588. + return []; } if (appType === "codex" || appType === "grokbuild") { return parseCcSwitchTomlApp(row, env, appType === "codex" ? "responses" : "chat_completions");