diff --git a/src/model-catalog/index.ts b/src/model-catalog/index.ts index b8d6680af95..dac4ecbcc0d 100644 --- a/src/model-catalog/index.ts +++ b/src/model-catalog/index.ts @@ -10,6 +10,7 @@ export { } from "./normalize.js"; export { planManifestModelCatalogRows } from "./manifest-planner.js"; export type { + ManifestModelCatalogConflict, ManifestModelCatalogPlan, ManifestModelCatalogPlanEntry, ManifestModelCatalogPlugin, diff --git a/src/model-catalog/manifest-planner.test.ts b/src/model-catalog/manifest-planner.test.ts index a4490bd97b9..1535c2a484b 100644 --- a/src/model-catalog/manifest-planner.test.ts +++ b/src/model-catalog/manifest-planner.test.ts @@ -51,6 +51,7 @@ describe("manifest model catalog planner", () => { }, ]); expect(plan.rows.map((row) => row.ref)).toEqual(["moonshot/kimi-k2.6"]); + expect(plan.conflicts).toEqual([]); }); it("filters providers before row planning", () => { @@ -84,9 +85,10 @@ describe("manifest model catalog planner", () => { expect(plan.entries.map((entry) => entry.pluginId)).toEqual(["openrouter"]); expect(plan.rows.map((row) => row.ref)).toEqual(["openrouter/anthropic/claude-sonnet-4.6"]); + expect(plan.conflicts).toEqual([]); }); - it("keeps the first registry row for duplicate provider/model keys", () => { + it("reports duplicate provider/model keys and excludes conflicted rows", () => { const plan = planManifestModelCatalogRows({ registry: { plugins: [ @@ -95,7 +97,10 @@ describe("manifest model catalog planner", () => { modelCatalog: { providers: { openai: { - models: [{ id: "gpt-5.4", name: "First GPT-5.4" }], + models: [ + { id: "gpt-5.4", name: "First GPT-5.4" }, + { id: "gpt-5.5", name: "GPT-5.5" }, + ], }, }, }, @@ -115,10 +120,20 @@ describe("manifest model catalog planner", () => { }); expect(plan.entries).toHaveLength(2); + expect(plan.conflicts).toEqual([ + { + mergeKey: "openai::gpt-5.4", + ref: "openai/gpt-5.4", + provider: "openai", + modelId: "gpt-5.4", + firstPluginId: "z-first", + secondPluginId: "a-second", + }, + ]); expect(plan.rows).toHaveLength(1); expect(plan.rows[0]).toMatchObject({ - mergeKey: "openai::gpt-5.4", - name: "First GPT-5.4", + mergeKey: "openai::gpt-5.5", + name: "GPT-5.5", }); }); }); diff --git a/src/model-catalog/manifest-planner.ts b/src/model-catalog/manifest-planner.ts index eb7543bfa67..e0f0f42843e 100644 --- a/src/model-catalog/manifest-planner.ts +++ b/src/model-catalog/manifest-planner.ts @@ -17,9 +17,19 @@ export type ManifestModelCatalogPlanEntry = { rows: readonly NormalizedModelCatalogRow[]; }; +export type ManifestModelCatalogConflict = { + mergeKey: string; + ref: string; + provider: string; + modelId: string; + firstPluginId: string; + secondPluginId: string; +}; + export type ManifestModelCatalogPlan = { rows: readonly NormalizedModelCatalogRow[]; entries: readonly ManifestModelCatalogPlanEntry[]; + conflicts: readonly ManifestModelCatalogConflict[]; }; export function planManifestModelCatalogRows(params: { @@ -37,20 +47,36 @@ export function planManifestModelCatalogRows(params: { } } - const rows: NormalizedModelCatalogRow[] = []; - const seenMergeKeys = new Set(); + const rowCandidates: NormalizedModelCatalogRow[] = []; + const seenRows = new Map(); + const conflicts = new Map(); for (const entry of entries) { for (const row of entry.rows) { - if (seenMergeKeys.has(row.mergeKey)) { + const seen = seenRows.get(row.mergeKey); + if (seen) { + if (!conflicts.has(row.mergeKey)) { + conflicts.set(row.mergeKey, { + mergeKey: row.mergeKey, + ref: seen.row.ref, + provider: seen.row.provider, + modelId: seen.row.id, + firstPluginId: seen.pluginId, + secondPluginId: entry.pluginId, + }); + } continue; } - seenMergeKeys.add(row.mergeKey); - rows.push(row); + seenRows.set(row.mergeKey, { pluginId: entry.pluginId, row }); + rowCandidates.push(row); } } + const conflictedMergeKeys = new Set(conflicts.keys()); + const rows = rowCandidates.filter((row) => !conflictedMergeKeys.has(row.mergeKey)); + return { entries, + conflicts: [...conflicts.values()], rows: rows.toSorted( (left, right) => left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id),