From 0b2bc8c5f6020c2f5ffc7487b9277de76e07c8d8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 23:16:26 -0700 Subject: [PATCH] feat(models): read provider owners from installed index --- .../models/list.provider-catalog.test.ts | 59 +++++++++++++++++++ src/commands/models/list.provider-catalog.ts | 44 ++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index e894c9ccbf2..615a7158b87 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -6,12 +6,19 @@ import { } from "./list.provider-catalog.js"; const providerDiscoveryMocks = vi.hoisted(() => ({ + loadInstalledPluginIndex: vi.fn(), resolveBundledProviderCompatPluginIds: vi.fn(), + resolveInstalledPluginContributions: vi.fn(), resolveOwningPluginIdsForProvider: vi.fn(), resolvePluginDiscoveryProviders: vi.fn(), resolveProviderContractPluginIdsForProviderAlias: vi.fn(), })); +vi.mock("../../plugins/installed-plugin-index.js", () => ({ + loadInstalledPluginIndex: providerDiscoveryMocks.loadInstalledPluginIndex, + resolveInstalledPluginContributions: providerDiscoveryMocks.resolveInstalledPluginContributions, +})); + vi.mock("../../plugins/providers.js", () => ({ resolveBundledProviderCompatPluginIds: providerDiscoveryMocks.resolveBundledProviderCompatPluginIds, @@ -102,9 +109,34 @@ const catalogOnlyProvider = { const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider]; +function createContributionMaps(params: { + providers?: ReadonlyMap; + cliBackends?: ReadonlyMap; +}) { + return { + providers: params.providers ?? new Map(), + channels: new Map(), + channelConfigs: new Map(), + setupProviders: new Map(), + cliBackends: params.cliBackends ?? new Map(), + modelCatalogProviders: new Map(), + commandAliases: new Map(), + contracts: new Map(), + }; +} + describe("loadProviderCatalogModelsForList", () => { beforeEach(() => { vi.clearAllMocks(); + providerDiscoveryMocks.loadInstalledPluginIndex.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValue( + createContributionMaps({ + providers: new Map(defaultProviders.map((provider) => [provider.id, [provider.pluginId]])), + }), + ); providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([ "chutes", "moonshot", @@ -167,6 +199,22 @@ describe("loadProviderCatalogModelsForList", () => { ); }); + it("resolves provider owners from the installed plugin index before manifest fallback", async () => { + await expect( + resolveProviderCatalogPluginIdsForFilter({ + cfg: baseParams.cfg, + env: baseParams.env, + providerFilter: "moonshot", + }), + ).resolves.toEqual(["moonshot"]); + + expect(providerDiscoveryMocks.loadInstalledPluginIndex).toHaveBeenCalledWith({ + config: baseParams.cfg, + env: baseParams.env, + }); + expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); + }); + it("returns an empty catalog when a static provider catalog throws", async () => { providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValueOnce([ { @@ -224,6 +272,9 @@ describe("loadProviderCatalogModelsForList", () => { }); it("does not skip registry for non-bundled static catalog owners", async () => { + providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce( + createContributionMaps({}), + ); providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce([ "workspace-static-provider", ]); @@ -241,6 +292,10 @@ describe("loadProviderCatalogModelsForList", () => { }); it("recognizes bundled provider hook aliases before the unknown-provider short-circuit", async () => { + providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce( + createContributionMaps({}), + ); + await expect( resolveProviderCatalogPluginIdsForFilter({ cfg: baseParams.cfg, @@ -291,6 +346,10 @@ describe("loadProviderCatalogModelsForList", () => { }); it("keeps unknown provider filters eligible for early empty results", async () => { + providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce( + createContributionMaps({}), + ); + await expect( resolveProviderCatalogPluginIdsForFilter({ cfg: baseParams.cfg, diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index 815bd4caae7..62d1a9af670 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -4,6 +4,10 @@ import type { ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + loadInstalledPluginIndex, + resolveInstalledPluginContributions, +} from "../../plugins/installed-plugin-index.js"; import { groupPluginDiscoveryProvidersByOrder, normalizePluginDiscoveryResult, @@ -31,6 +35,38 @@ function providerMatchesFilter(params: { ].some((providerId) => normalizeProviderId(providerId) === params.providerFilter); } +function collectMatchingContributionPluginIds( + contributions: ReadonlyMap, + providerFilter: string, +): string[] { + const pluginIds: string[] = []; + for (const [contributionId, ownerPluginIds] of contributions) { + if (normalizeProviderId(contributionId) === providerFilter) { + pluginIds.push(...ownerPluginIds); + } + } + return [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveInstalledIndexPluginIdsForProviderFilter(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + providerFilter: string; +}): string[] | undefined { + const index = loadInstalledPluginIndex({ + config: params.cfg, + env: params.env, + }); + const contributions = resolveInstalledPluginContributions(index); + const pluginIds = [ + ...collectMatchingContributionPluginIds(contributions.providers, params.providerFilter), + ...collectMatchingContributionPluginIds(contributions.cliBackends, params.providerFilter), + ]; + return pluginIds.length > 0 + ? [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)) + : undefined; +} + export async function resolveProviderCatalogPluginIdsForFilter(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -40,6 +76,14 @@ export async function resolveProviderCatalogPluginIdsForFilter(params: { if (!providerFilter) { return undefined; } + const installedIndexPluginIds = resolveInstalledIndexPluginIdsForProviderFilter({ + cfg: params.cfg, + env: params.env, + providerFilter, + }); + if (installedIndexPluginIds) { + return installedIndexPluginIds; + } const manifestPluginIds = resolveOwningPluginIdsForProvider({ provider: providerFilter, config: params.cfg,