From b2685e72c129ca2bf5015cb9389cfa813312b94d Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 27 Apr 2026 16:18:23 +0100 Subject: [PATCH] refactor: plan manifest catalog aliases and suppressions --- src/model-catalog/index.ts | 7 +- src/model-catalog/manifest-planner.test.ts | 108 +++++++++++- src/model-catalog/manifest-planner.ts | 181 ++++++++++++++++++--- 3 files changed, 272 insertions(+), 24 deletions(-) diff --git a/src/model-catalog/index.ts b/src/model-catalog/index.ts index 720e3fc7d6e..6f93133d5a0 100644 --- a/src/model-catalog/index.ts +++ b/src/model-catalog/index.ts @@ -16,7 +16,10 @@ export { loadOpenClawProviderIndex, normalizeOpenClawProviderIndex, } from "./provider-index/index.js"; -export { planManifestModelCatalogRows } from "./manifest-planner.js"; +export { + planManifestModelCatalogRows, + planManifestModelCatalogSuppressions, +} from "./manifest-planner.js"; export { planProviderIndexModelCatalogRows } from "./provider-index-planner.js"; export type { ProviderIndexModelCatalogPlan, @@ -28,6 +31,8 @@ export type { ManifestModelCatalogPlanEntry, ManifestModelCatalogPlugin, ManifestModelCatalogRegistry, + ManifestModelCatalogSuppressionEntry, + ManifestModelCatalogSuppressionPlan, } from "./manifest-planner.js"; export type { ModelCatalog, diff --git a/src/model-catalog/manifest-planner.test.ts b/src/model-catalog/manifest-planner.test.ts index 906c3b77d94..3cc258b730f 100644 --- a/src/model-catalog/manifest-planner.test.ts +++ b/src/model-catalog/manifest-planner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { planManifestModelCatalogRows } from "./index.js"; +import { planManifestModelCatalogRows, planManifestModelCatalogSuppressions } from "./index.js"; describe("manifest model catalog planner", () => { it("builds manifest rows from plugin-owned catalog providers", () => { @@ -92,6 +92,57 @@ describe("manifest model catalog planner", () => { expect(plan.conflicts).toEqual([]); }); + it("plans alias-filtered rows from owned provider catalogs", () => { + const plan = planManifestModelCatalogRows({ + providerFilter: "azure-openai-responses", + registry: { + plugins: [ + { + id: "openai", + providers: ["openai"], + modelCatalog: { + aliases: { + "azure-openai-responses": { + provider: "openai", + api: "azure-openai-responses", + baseUrl: "https://example.openai.azure.com/openai/v1", + }, + }, + discovery: { + openai: "static", + }, + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.4", name: "GPT-5.4" }], + }, + }, + }, + }, + ], + }, + }); + + expect(plan.entries).toEqual([ + expect.objectContaining({ + pluginId: "openai", + provider: "azure-openai-responses", + discovery: "static", + }), + ]); + expect(plan.rows).toEqual([ + expect.objectContaining({ + provider: "azure-openai-responses", + id: "gpt-5.4", + ref: "azure-openai-responses/gpt-5.4", + mergeKey: "azure-openai-responses::gpt-5.4", + api: "azure-openai-responses", + baseUrl: "https://example.openai.azure.com/openai/v1", + }), + ]); + }); + it("reports duplicate provider/model keys and excludes conflicted rows", () => { const plan = planManifestModelCatalogRows({ registry: { @@ -141,3 +192,58 @@ describe("manifest model catalog planner", () => { }); }); }); + +describe("manifest model catalog suppression planner", () => { + it("plans suppressions for owned providers and declared provider aliases", () => { + const plan = planManifestModelCatalogSuppressions({ + registry: { + plugins: [ + { + id: "openai", + providers: ["openai", "openai-codex"], + modelCatalog: { + aliases: { + "azure-openai-responses": { + provider: "openai", + }, + }, + suppressions: [ + { + provider: "openai", + model: "gpt-5.3-codex-spark", + reason: "Use openai/gpt-5.5.", + }, + { + provider: "azure-openai-responses", + model: "GPT-5.3-Codex-Spark", + reason: "Use openai/gpt-5.5.", + }, + { + provider: "openrouter", + model: "foreign-row", + }, + ], + }, + }, + ], + }, + }); + + expect(plan.suppressions).toEqual([ + { + pluginId: "openai", + provider: "azure-openai-responses", + model: "gpt-5.3-codex-spark", + mergeKey: "azure-openai-responses::gpt-5.3-codex-spark", + reason: "Use openai/gpt-5.5.", + }, + { + pluginId: "openai", + provider: "openai", + model: "gpt-5.3-codex-spark", + mergeKey: "openai::gpt-5.3-codex-spark", + reason: "Use openai/gpt-5.5.", + }, + ]); + }); +}); diff --git a/src/model-catalog/manifest-planner.ts b/src/model-catalog/manifest-planner.ts index f14b6d9b61d..62b0c8a2672 100644 --- a/src/model-catalog/manifest-planner.ts +++ b/src/model-catalog/manifest-planner.ts @@ -1,10 +1,17 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeModelCatalogProviderRows } from "./normalize.js"; -import { normalizeModelCatalogProviderId } from "./refs.js"; -import type { ModelCatalog, ModelCatalogDiscovery, NormalizedModelCatalogRow } from "./types.js"; +import { buildModelCatalogMergeKey, normalizeModelCatalogProviderId } from "./refs.js"; +import type { + ModelCatalog, + ModelCatalogAlias, + ModelCatalogDiscovery, + NormalizedModelCatalogRow, +} from "./types.js"; export type ManifestModelCatalogPlugin = { id: string; - modelCatalog?: Pick; + providers?: readonly string[]; + modelCatalog?: Pick; }; export type ManifestModelCatalogRegistry = { @@ -33,6 +40,18 @@ export type ManifestModelCatalogPlan = { conflicts: readonly ManifestModelCatalogConflict[]; }; +export type ManifestModelCatalogSuppressionEntry = { + pluginId: string; + provider: string; + model: string; + mergeKey: string; + reason?: string; +}; + +export type ManifestModelCatalogSuppressionPlan = { + suppressions: readonly ManifestModelCatalogSuppressionEntry[]; +}; + export function planManifestModelCatalogRows(params: { registry: ManifestModelCatalogRegistry; providerFilter?: string; @@ -94,29 +113,147 @@ function planManifestModelCatalogPluginEntries(params: { return []; } + const aliasesByTargetProvider = buildModelCatalogProviderAliasTargets(params.plugin); + return Object.entries(providers).flatMap(([provider, providerCatalog]) => { const normalizedProvider = normalizeModelCatalogProviderId(provider); - if ( - !normalizedProvider || - (params.providerFilter && normalizedProvider !== params.providerFilter) - ) { + if (!normalizedProvider) { return []; } - const rows = normalizeModelCatalogProviderRows({ - provider: normalizedProvider, - providerCatalog, - source: "manifest", + const providerAliases = aliasesByTargetProvider.get(normalizedProvider) ?? []; + const plannedProviders = params.providerFilter + ? providerAliases.includes(params.providerFilter) || + normalizedProvider === params.providerFilter + ? [params.providerFilter] + : [] + : [normalizedProvider]; + if (plannedProviders.length === 0) { + return []; + } + return plannedProviders.flatMap((plannedProvider) => { + const rows = normalizeModelCatalogProviderRows({ + provider: plannedProvider, + providerCatalog, + source: "manifest", + }); + if (rows.length === 0) { + return []; + } + return [ + { + pluginId: params.plugin.id, + provider: plannedProvider, + discovery: params.plugin.modelCatalog?.discovery?.[normalizedProvider], + rows: applyModelCatalogAliasOverrides({ + rows, + alias: params.plugin.modelCatalog?.aliases?.[plannedProvider], + }), + }, + ]; }); - if (rows.length === 0) { - return []; - } - return [ - { - pluginId: params.plugin.id, - provider: normalizedProvider, - discovery: params.plugin.modelCatalog?.discovery?.[normalizedProvider], - rows, - }, - ]; }); } + +function buildOwnedProviderSet(plugin: ManifestModelCatalogPlugin): ReadonlySet { + return new Set((plugin.providers ?? []).map(normalizeModelCatalogProviderId).filter(Boolean)); +} + +function buildModelCatalogProviderAliasTargets( + plugin: ManifestModelCatalogPlugin, +): ReadonlyMap { + const ownedProviders = buildOwnedProviderSet(plugin); + const aliasesByTargetProvider = new Map(); + for (const [rawAlias, alias] of Object.entries(plugin.modelCatalog?.aliases ?? {})) { + const aliasProvider = normalizeModelCatalogProviderId(rawAlias); + const targetProvider = normalizeModelCatalogProviderId(alias.provider); + if (!aliasProvider || !targetProvider || !ownedProviders.has(targetProvider)) { + continue; + } + const aliases = aliasesByTargetProvider.get(targetProvider) ?? []; + aliases.push(aliasProvider); + aliasesByTargetProvider.set(targetProvider, aliases); + } + return aliasesByTargetProvider; +} + +function applyModelCatalogAliasOverrides(params: { + rows: readonly NormalizedModelCatalogRow[]; + alias?: ModelCatalogAlias; +}): readonly NormalizedModelCatalogRow[] { + if (!params.alias) { + return params.rows; + } + return params.rows.map((row) => ({ + ...row, + ...(params.alias.api ? { api: params.alias.api } : {}), + ...(params.alias.baseUrl ? { baseUrl: params.alias.baseUrl } : {}), + })); +} + +function pluginOwnsModelCatalogProviderRef(params: { + plugin: ManifestModelCatalogPlugin; + provider: string; +}): boolean { + const provider = normalizeModelCatalogProviderId(params.provider); + if (!provider) { + return false; + } + const ownedProviders = buildOwnedProviderSet(params.plugin); + if (ownedProviders.has(provider)) { + return true; + } + return Object.entries(params.plugin.modelCatalog?.aliases ?? {}).some(([rawAlias, alias]) => { + const aliasProvider = normalizeModelCatalogProviderId(rawAlias); + const targetProvider = normalizeModelCatalogProviderId(alias.provider); + return ( + aliasProvider === provider && Boolean(targetProvider) && ownedProviders.has(targetProvider) + ); + }); +} + +export function planManifestModelCatalogSuppressions(params: { + registry: ManifestModelCatalogRegistry; + providerFilter?: string; + modelFilter?: string; +}): ManifestModelCatalogSuppressionPlan { + const providerFilter = params.providerFilter + ? normalizeModelCatalogProviderId(params.providerFilter) + : undefined; + const modelFilter = params.modelFilter + ? normalizeLowercaseStringOrEmpty(params.modelFilter) + : undefined; + const suppressions: ManifestModelCatalogSuppressionEntry[] = []; + for (const plugin of params.registry.plugins) { + for (const suppression of plugin.modelCatalog?.suppressions ?? []) { + const provider = normalizeModelCatalogProviderId(suppression.provider); + const model = normalizeLowercaseStringOrEmpty(suppression.model); + if (!provider || !model) { + continue; + } + if (providerFilter && provider !== providerFilter) { + continue; + } + if (modelFilter && model !== modelFilter) { + continue; + } + if (!pluginOwnsModelCatalogProviderRef({ plugin, provider })) { + continue; + } + suppressions.push({ + pluginId: plugin.id, + provider, + model, + mergeKey: buildModelCatalogMergeKey(provider, model), + ...(suppression.reason ? { reason: suppression.reason } : {}), + }); + } + } + return { + suppressions: suppressions.toSorted( + (left, right) => + left.provider.localeCompare(right.provider) || + left.model.localeCompare(right.model) || + left.pluginId.localeCompare(right.pluginId), + ), + }; +}