diff --git a/src/cli/models.ts b/src/cli/models.ts index 28d83536b49..59256e4f515 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -94,9 +94,17 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] // defaultModel first if (prov.defaultModel) addModel(prov.defaultModel, true); - // models array - if (prov.models) { - for (const m of prov.models) addModel(m, m === prov.defaultModel); + // Keep the CLI list current when this config predates a provider's registry seed. + // knownModelIdsForProvider is the router's own derivation: config models first, then + // registry seeds, hint-map keys, and live-discovered cache ids (registry-derived + // sources only enter when the transport guard matches -- a custom endpoint named like + // a registry provider gets none of them), then customModels. Because hint-map keys + // are included, wire-only ids a provider's collapsed picker hides still appear here; + // that is router parity. A model the user pruned from `models` reappears if the + // registry still seeds it -- the list is a union, not the user's curation. The + // user's saved models remain first and are never discarded. + for (const m of [...(prov.models ?? []), ...knownModelIdsForProvider(provName, prov, config)]) { + addModel(m, m === prov.defaultModel); } } @@ -392,7 +400,7 @@ function handleConfiguredModels(args: string[]): void { if (wantsJson) { console.log(JSON.stringify({ models, - note: "Static config models only. Providers with liveModels=true may have additional models at runtime.", + note: "Saved config models and router-known models (registry seeds, hint-map keys, and discovered cache ids). Providers with liveModels=true may have additional models at runtime.", }, null, 2)); return; } diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 9dbfb6c7195..a9d1dec2681 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -148,6 +148,62 @@ describe("ocx models", () => { } }); + // The registry-sync merge must go through the router's guarded derivation, not a raw + // registry read: a provider whose transport diverges from its namesake registry entry + // gets nothing, a matching one gets the seed, and stale saved configs gain new seed ids + // while saved models stay first. cline is a registry entry with preserveCustomDestination + // (fixed key destination), so the transport guard actually discriminates for it. + test("registry seed syncs into a stale saved config when the transport matches", () => { + const { dir } = freshConfig({ providers: { cline: { + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + authMode: "key", + apiKey: "sk-test", + defaultModel: "anthropic/claude-sonnet-4-6", + // Deliberately predates the current seed: saved subset, plus one custom-only id. + models: ["anthropic/claude-sonnet-4-6", "custom-only-model"], + } } }); + try { + const result = runCli(["models", "--provider", "cline", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models.map((m: { model: string; isDefault: boolean }) => m); + expect(rows[0]).toMatchObject({ model: "anthropic/claude-sonnet-4-6", isDefault: true }); + expect(rows[1]).toMatchObject({ model: "custom-only-model", isDefault: false }); + // Seed ids absent from the stale saved config still appear. + const ids = rows.map((m: { model: string }) => m.model); + expect(ids).toContain("openai/gpt-4o"); + expect(ids).toContain("google/gemini-2.5-pro"); + // No duplicate rows: saved ids also come back from the router union, and the + // per-provider seen-dedupe must collapse them. + expect(new Set(ids).size).toBe(ids.length); + expect(rows.filter((m: { isDefault: boolean }) => m.isDefault)).toHaveLength(1); + } finally { + removeTreeWithRetry(dir); + } + }); + + test("registry seed does NOT leak into a provider whose transport diverges from its namesake", () => { + const { dir } = freshConfig({ providers: { cline: { + adapter: "openai-chat", + baseUrl: "https://elsewhere.example.com/v1", + authMode: "key", + apiKey: "sk-test", + defaultModel: "my-model", + models: ["my-model"], + } } }); + try { + // The cline registry entry carries a non-empty seed (the matching-transport test + // above receives it), so listing only the saved id here means the transport guard + // is what excluded the seed, not an empty registry. + const result = runCli(["models", "--provider", "cline", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows.map((m: { model: string }) => m.model)).toEqual(["my-model"]); + } finally { + removeTreeWithRetry(dir); + } + }); + test("models --provider X --json combines flags", () => { const { dir } = freshConfig(); try {