fix: report model catalog manifest conflicts

This commit is contained in:
Shakker
2026-04-25 04:02:27 +01:00
committed by Shakker
parent 9e190f1f6a
commit b6c24e5322
3 changed files with 51 additions and 9 deletions

View File

@@ -10,6 +10,7 @@ export {
} from "./normalize.js";
export { planManifestModelCatalogRows } from "./manifest-planner.js";
export type {
ManifestModelCatalogConflict,
ManifestModelCatalogPlan,
ManifestModelCatalogPlanEntry,
ManifestModelCatalogPlugin,

View File

@@ -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",
});
});
});

View File

@@ -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<string>();
const rowCandidates: NormalizedModelCatalogRow[] = [];
const seenRows = new Map<string, { pluginId: string; row: NormalizedModelCatalogRow }>();
const conflicts = new Map<string, ManifestModelCatalogConflict>();
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),