diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index 30d399ff297..1637ce61d26 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -1,13 +1,66 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import type { ModelProviderConfig } from "../config/types.js"; +import type { PluginCandidate } from "./discovery.js"; import { groupPluginDiscoveryProvidersByOrder, normalizePluginDiscoveryResult, + resolveInstalledPluginProviderContributionIds, runProviderCatalog, runProviderStaticCatalog, } from "./provider-discovery.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; import type { ProviderCatalogResult, ProviderDiscoveryOrder, ProviderPlugin } from "./types.js"; +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +function makeTempDir() { + return makeTrackedTempDir("openclaw-provider-discovery", tempDirs); +} + +function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + ...overrides, + }; +} + +function createProviderContributionCandidate(params: { + pluginId?: string; + providerIds?: readonly string[]; +}): PluginCandidate { + const rootDir = makeTempDir(); + fs.writeFileSync( + path.join(rootDir, "index.ts"), + "throw new Error('runtime provider entry should not load for cold contribution ids');\n", + "utf-8", + ); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + id: params.pluginId ?? "demo", + configSchema: { type: "object" }, + providers: params.providerIds ?? ["demo"], + }), + "utf-8", + ); + return { + idHint: params.pluginId ?? "demo", + source: path.join(rootDir, "index.ts"), + rootDir, + origin: "global", + }; +} + function makeProvider(params: { id: string; label?: string; @@ -112,6 +165,50 @@ async function expectProviderCatalogResult(params: { ).resolves.toEqual(params.expected); } +describe("resolveInstalledPluginProviderContributionIds", () => { + it("reads provider ids from the installed plugin index without importing runtime entries", () => { + const candidate = createProviderContributionCandidate({ + pluginId: "demo", + providerIds: ["demo", "demo-alias"], + }); + + expect( + resolveInstalledPluginProviderContributionIds({ + candidates: [candidate], + env: hermeticEnv(), + }), + ).toEqual(["demo", "demo-alias"]); + }); + + it("omits disabled plugin provider ids unless explicitly requested", () => { + const candidate = createProviderContributionCandidate({ + pluginId: "demo", + providerIds: ["demo"], + }); + const params = { + candidates: [candidate], + config: { + plugins: { + entries: { + demo: { + enabled: false, + }, + }, + }, + }, + env: hermeticEnv(), + }; + + expect(resolveInstalledPluginProviderContributionIds(params)).toEqual([]); + expect( + resolveInstalledPluginProviderContributionIds({ + ...params, + includeDisabled: true, + }), + ).toEqual(["demo"]); + }); +}); + describe("groupPluginDiscoveryProvidersByOrder", () => { it.each([ { diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 45c893c5c0a..649b1969773 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -1,6 +1,11 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + loadInstalledPluginIndex, + type InstalledPluginIndex, + type LoadInstalledPluginIndexParams, +} from "./installed-plugin-index.js"; import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js"; const DISCOVERY_ORDER: readonly ProviderDiscoveryOrder[] = ["simple", "profile", "paired", "late"]; @@ -28,7 +33,7 @@ function isSafeProviderConfigKey(value: string): boolean { return value !== "" && !DANGEROUS_PROVIDER_KEYS.has(value); } -export async function resolvePluginDiscoveryProviders(params: { +export type ResolveRuntimePluginDiscoveryProvidersParams = { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; @@ -36,12 +41,45 @@ export async function resolvePluginDiscoveryProviders(params: { includeUntrustedWorkspacePlugins?: boolean; requireCompleteDiscoveryEntryCoverage?: boolean; discoveryEntriesOnly?: boolean; -}): Promise { +}; + +export type ResolveInstalledPluginProviderContributionIdsParams = LoadInstalledPluginIndexParams & { + index?: InstalledPluginIndex; + includeDisabled?: boolean; +}; + +function sortedValues(values: Iterable): string[] { + return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); +} + +export function resolveInstalledPluginProviderContributionIds( + params: ResolveInstalledPluginProviderContributionIdsParams = {}, +): string[] { + const index = params.index ?? loadInstalledPluginIndex(params); + const providerIds: string[] = []; + for (const plugin of index.plugins) { + if (!params.includeDisabled && !plugin.enabled) { + continue; + } + providerIds.push(...plugin.contributions.providers); + } + return sortedValues(providerIds); +} + +export async function resolveRuntimePluginDiscoveryProviders( + params: ResolveRuntimePluginDiscoveryProvidersParams, +): Promise { return (await loadProviderRuntime()) .resolvePluginDiscoveryProvidersRuntime(params) .filter((provider) => resolveProviderCatalogOrderHook(provider)); } +export async function resolvePluginDiscoveryProviders( + params: ResolveRuntimePluginDiscoveryProvidersParams, +): Promise { + return resolveRuntimePluginDiscoveryProviders(params); +} + export function groupPluginDiscoveryProvidersByOrder( providers: ProviderPlugin[], ): Record {