diff --git a/src/commands/models/provider-aliases.test.ts b/src/commands/models/provider-aliases.test.ts new file mode 100644 index 000000000000..419875c7b48e --- /dev/null +++ b/src/commands/models/provider-aliases.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { canonicalizeModelCatalogProviderAlias } from "./provider-aliases.js"; + +const basePlugin = { + channels: [], + cliBackends: [], + diagnostics: [], + hooks: [], + manifestPath: "/tmp/openclaw.plugin.json", + providers: [], + rootDir: "/tmp/plugin", + skills: [], + source: "test", +}; + +describe("canonicalizeModelCatalogProviderAlias", () => { + it("skips unreadable manifest alias metadata while preserving healthy aliases", () => { + const unreadableModelCatalog = { + ...basePlugin, + id: "unreadable-model-catalog", + origin: "global", + get modelCatalog() { + throw new Error("model alias metadata exploded"); + }, + }; + const unreadableAliasMap = { + ...basePlugin, + id: "unreadable-alias-map", + origin: "global", + modelCatalog: { + aliases: new Proxy( + {}, + { + ownKeys() { + throw new Error("model alias map exploded"); + }, + }, + ), + }, + }; + const healthyAlias = { + ...basePlugin, + id: "healthy-alias", + origin: "global", + modelCatalog: { + aliases: { + broken: { + get provider() { + throw new Error("model alias provider exploded"); + }, + }, + kimi: { provider: "moonshot" }, + }, + }, + }; + + expect( + canonicalizeModelCatalogProviderAlias("kimi", { + cfg: {}, + metadataSnapshot: { + manifestRegistry: { + plugins: [unreadableModelCatalog, unreadableAliasMap, healthyAlias], + diagnostics: [], + }, + } as never, + }), + ).toBe("moonshot"); + }); +}); diff --git a/src/commands/models/provider-aliases.ts b/src/commands/models/provider-aliases.ts index d0c1d73da5c2..87b13b17a869 100644 --- a/src/commands/models/provider-aliases.ts +++ b/src/commands/models/provider-aliases.ts @@ -14,6 +14,8 @@ type ProviderAliasSource = { metadataSnapshot?: Pick; }; +type SourcePeerPlugin = Pick; + const sourcePeerModelCatalogCache = new Map(); function listManifestPlugins(params: ProviderAliasSource): readonly PluginManifestRecord[] { @@ -25,9 +27,36 @@ function listManifestPlugins(params: ProviderAliasSource): readonly PluginManife ); } -function resolveSourcePeerPluginRoot( - plugin: Pick, -): string | undefined { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readPluginModelCatalog( + plugin: PluginManifestRecord, +): PluginManifestModelCatalog | undefined { + try { + return plugin.modelCatalog; + } catch { + return undefined; + } +} + +function readSourcePeerPlugin(plugin: PluginManifestRecord): SourcePeerPlugin | undefined { + try { + if ( + typeof plugin.id !== "string" || + typeof plugin.origin !== "string" || + typeof plugin.rootDir !== "string" + ) { + return undefined; + } + return { id: plugin.id, origin: plugin.origin, rootDir: plugin.rootDir }; + } catch { + return undefined; + } +} + +function resolveSourcePeerPluginRoot(plugin: SourcePeerPlugin): string | undefined { if (plugin.origin !== "bundled") { return undefined; } @@ -48,7 +77,7 @@ function resolveSourcePeerPluginRoot( } function loadSourcePeerModelCatalog( - plugin: Pick, + plugin: SourcePeerPlugin, ): PluginManifestModelCatalog | undefined { const cacheKey = path.resolve(plugin.rootDir); const cached = sourcePeerModelCatalogCache.get(cacheKey); @@ -70,17 +99,64 @@ function loadSourcePeerModelCatalog( return modelCatalog ?? undefined; } +function loadSourcePeerModelCatalogForPlugin( + plugin: PluginManifestRecord, +): PluginManifestModelCatalog | undefined { + const sourcePeerPlugin = readSourcePeerPlugin(plugin); + return sourcePeerPlugin ? loadSourcePeerModelCatalog(sourcePeerPlugin) : undefined; +} + +function readModelCatalogAliasEntries( + modelCatalog: PluginManifestModelCatalog | undefined, +): readonly (readonly [string, Record])[] { + let aliases: unknown; + try { + aliases = modelCatalog?.aliases; + } catch { + return []; + } + if (!isRecord(aliases)) { + return []; + } + let keys: string[]; + try { + keys = Object.keys(aliases); + } catch { + return []; + } + const entries: [string, Record][] = []; + for (const key of keys) { + try { + const target = aliases[key]; + if (isRecord(target)) { + entries.push([key, target]); + } + } catch { + continue; + } + } + return entries; +} + function hasModelCatalogAliases(modelCatalog: PluginManifestModelCatalog | undefined): boolean { - return Object.keys(modelCatalog?.aliases ?? {}).length > 0; + return readModelCatalogAliasEntries(modelCatalog).length > 0; +} + +function readAliasTargetProvider(target: Record): string { + try { + return typeof target.provider === "string" ? target.provider : ""; + } catch { + return ""; + } } function collectModelCatalogAliases( aliases: Map, modelCatalog: PluginManifestModelCatalog | undefined, ): void { - for (const [aliasProvider, target] of Object.entries(modelCatalog?.aliases ?? {})) { + for (const [aliasProvider, target] of readModelCatalogAliasEntries(modelCatalog)) { const alias = normalizeProviderId(aliasProvider); - const provider = normalizeProviderId(target.provider); + const provider = normalizeProviderId(readAliasTargetProvider(target)); if (alias && provider) { aliases.set(alias, provider); } @@ -90,9 +166,10 @@ function collectModelCatalogAliases( function buildProviderAliasMap(params: ProviderAliasSource): ReadonlyMap { const aliases = new Map(); for (const plugin of listManifestPlugins(params)) { - collectModelCatalogAliases(aliases, plugin.modelCatalog); - if (!hasModelCatalogAliases(plugin.modelCatalog) && plugin.origin === "bundled") { - collectModelCatalogAliases(aliases, loadSourcePeerModelCatalog(plugin)); + const modelCatalog = readPluginModelCatalog(plugin); + collectModelCatalogAliases(aliases, modelCatalog); + if (!hasModelCatalogAliases(modelCatalog)) { + collectModelCatalogAliases(aliases, loadSourcePeerModelCatalogForPlugin(plugin)); } } return aliases;