diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index be391398a0e..e181685cde0 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -46,6 +46,8 @@ Use it for: - config validation - auth and onboarding metadata that should be available without booting plugin runtime +- static capability ownership snapshots used for bundled compat wiring and + contract coverage - config UI hints Do not use it for: @@ -129,6 +131,7 @@ Those belong in your plugin code and `package.json`. | `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. | | `providerAuthEnvVars` | No | `Record` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. | | `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. | +| `contracts` | No | `object` | Static bundled capability snapshot for speech, media-understanding, image-generation, web search, and tool ownership. | | `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. | | `name` | No | `string` | Human-readable plugin name. | | `description` | No | `string` | Short summary shown in plugin surfaces. | @@ -184,6 +187,33 @@ Each field hint can include: | `sensitive` | `boolean` | Marks the field as secret or sensitive. | | `placeholder` | `string` | Placeholder text for form inputs. | +## contracts reference + +Use `contracts` only for static capability ownership metadata that OpenClaw can +read without importing the plugin runtime. + +```json +{ + "contracts": { + "speechProviders": ["openai"], + "mediaUnderstandingProviders": ["openai", "openai-codex"], + "imageGenerationProviders": ["openai"], + "webSearchProviders": ["gemini"], + "tools": ["firecrawl_search", "firecrawl_scrape"] + } +} +``` + +Each list is optional: + +| Field | Type | What it means | +| ----------------------------- | ---------- | -------------------------------------------------------------- | +| `speechProviders` | `string[]` | Speech provider ids this plugin owns. | +| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. | +| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. | +| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. | +| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. | + ## Manifest versus package.json The two files serve different jobs: diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 4dcae3c5de4..71d55ce56f9 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -41,6 +41,9 @@ "cliDescription": "Anthropic API key" } ], + "contracts": { + "mediaUnderstandingProviders": ["anthropic"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/brave/openclaw.plugin.json b/extensions/brave/openclaw.plugin.json index 791a413ec66..397e576698b 100644 --- a/extensions/brave/openclaw.plugin.json +++ b/extensions/brave/openclaw.plugin.json @@ -15,6 +15,9 @@ "help": "Brave Search mode: web or llm-context." } }, + "contracts": { + "webSearchProviders": ["brave"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/deepgram/openclaw.plugin.json b/extensions/deepgram/openclaw.plugin.json index d522ec8be6a..efc6ab670fa 100644 --- a/extensions/deepgram/openclaw.plugin.json +++ b/extensions/deepgram/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "deepgram", "mediaUnderstandingProviders": ["deepgram"], + "contracts": { + "mediaUnderstandingProviders": ["deepgram"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/duckduckgo/openclaw.plugin.json b/extensions/duckduckgo/openclaw.plugin.json index e6c4d620275..bc5cbe9a4b8 100644 --- a/extensions/duckduckgo/openclaw.plugin.json +++ b/extensions/duckduckgo/openclaw.plugin.json @@ -10,6 +10,9 @@ "help": "SafeSearch level for DuckDuckGo results." } }, + "contracts": { + "webSearchProviders": ["duckduckgo"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/elevenlabs/openclaw.plugin.json b/extensions/elevenlabs/openclaw.plugin.json index abffc3c4f49..d92f2c4e8a6 100644 --- a/extensions/elevenlabs/openclaw.plugin.json +++ b/extensions/elevenlabs/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "elevenlabs", "speechProviders": ["elevenlabs"], + "contracts": { + "speechProviders": ["elevenlabs"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/exa/openclaw.plugin.json b/extensions/exa/openclaw.plugin.json index b8630cfccdb..1e54139b04a 100644 --- a/extensions/exa/openclaw.plugin.json +++ b/extensions/exa/openclaw.plugin.json @@ -11,6 +11,9 @@ "placeholder": "exa-..." } }, + "contracts": { + "webSearchProviders": ["exa"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/fal/openclaw.plugin.json b/extensions/fal/openclaw.plugin.json index 99ac7d3d1f9..dda80afad56 100644 --- a/extensions/fal/openclaw.plugin.json +++ b/extensions/fal/openclaw.plugin.json @@ -21,6 +21,9 @@ "cliDescription": "fal API key" } ], + "contracts": { + "imageGenerationProviders": ["fal"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/firecrawl/openclaw.plugin.json b/extensions/firecrawl/openclaw.plugin.json index adbe2a2a9c8..a3ee0f4636e 100644 --- a/extensions/firecrawl/openclaw.plugin.json +++ b/extensions/firecrawl/openclaw.plugin.json @@ -15,6 +15,10 @@ "help": "Firecrawl Search base URL override." } }, + "contracts": { + "webSearchProviders": ["firecrawl"], + "tools": ["firecrawl_search", "firecrawl_scrape"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 576d4992fce..6658f5e682f 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -44,6 +44,11 @@ "help": "Gemini model override for web search grounding." } }, + "contracts": { + "mediaUnderstandingProviders": ["google"], + "imageGenerationProviders": ["google"], + "webSearchProviders": ["gemini"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/groq/openclaw.plugin.json b/extensions/groq/openclaw.plugin.json index 7da82942848..37f72463723 100644 --- a/extensions/groq/openclaw.plugin.json +++ b/extensions/groq/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "groq", "mediaUnderstandingProviders": ["groq"], + "contracts": { + "mediaUnderstandingProviders": ["groq"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/microsoft/openclaw.plugin.json b/extensions/microsoft/openclaw.plugin.json index 7ab6a523125..a1b9660f591 100644 --- a/extensions/microsoft/openclaw.plugin.json +++ b/extensions/microsoft/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "microsoft", "speechProviders": ["microsoft"], + "contracts": { + "speechProviders": ["microsoft"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 381865d93ed..628c1425bb3 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -57,6 +57,10 @@ "cliDescription": "MiniMax API key" } ], + "contracts": { + "mediaUnderstandingProviders": ["minimax", "minimax-portal"], + "imageGenerationProviders": ["minimax", "minimax-portal"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index ec142023431..e170222c669 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -20,6 +20,9 @@ "cliDescription": "Mistral API key" } ], + "contracts": { + "mediaUnderstandingProviders": ["mistral"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index 39f36e7ecaa..5c6ba50d33e 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -48,6 +48,10 @@ "help": "Kimi model override." } }, + "contracts": { + "mediaUnderstandingProviders": ["moonshot"], + "webSearchProviders": ["kimi"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 68f3ba07670..a4f78e3ba4e 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -33,6 +33,11 @@ "cliDescription": "OpenAI API key" } ], + "contracts": { + "speechProviders": ["openai"], + "mediaUnderstandingProviders": ["openai", "openai-codex"], + "imageGenerationProviders": ["openai"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/perplexity/openclaw.plugin.json b/extensions/perplexity/openclaw.plugin.json index 32567c76cb2..06858badea0 100644 --- a/extensions/perplexity/openclaw.plugin.json +++ b/extensions/perplexity/openclaw.plugin.json @@ -19,6 +19,9 @@ "help": "Optional Sonar/OpenRouter model override." } }, + "contracts": { + "webSearchProviders": ["perplexity"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/tavily/openclaw.plugin.json b/extensions/tavily/openclaw.plugin.json index 9ed930bfe63..37c9450b195 100644 --- a/extensions/tavily/openclaw.plugin.json +++ b/extensions/tavily/openclaw.plugin.json @@ -16,6 +16,10 @@ "help": "Tavily API base URL override." } }, + "contracts": { + "webSearchProviders": ["tavily"], + "tools": ["tavily_search", "tavily_extract"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 69ec2574083..ef1da4587af 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -34,6 +34,9 @@ "help": "Include inline markdown citations in Grok responses." } }, + "contracts": { + "webSearchProviders": ["grok"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index 0e998d152f7..6841b55ae61 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -76,6 +76,9 @@ "cliDescription": "Z.AI API key" } ], + "contracts": { + "mediaUnderstandingProviders": ["zai"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/scripts/generate-bundled-plugin-metadata.mjs b/scripts/generate-bundled-plugin-metadata.mjs index f78525c288b..f72611169a8 100644 --- a/scripts/generate-bundled-plugin-metadata.mjs +++ b/scripts/generate-bundled-plugin-metadata.mjs @@ -50,6 +50,33 @@ function normalizeStringList(values) { return normalized.length > 0 ? normalized : undefined; } +function normalizeManifestContracts(raw) { + const contracts = normalizeObject(raw); + if (!contracts) { + return undefined; + } + const normalized = { + ...(normalizeStringList(contracts.speechProviders) + ? { speechProviders: normalizeStringList(contracts.speechProviders) } + : {}), + ...(normalizeStringList(contracts.mediaUnderstandingProviders) + ? { + mediaUnderstandingProviders: normalizeStringList(contracts.mediaUnderstandingProviders), + } + : {}), + ...(normalizeStringList(contracts.imageGenerationProviders) + ? { imageGenerationProviders: normalizeStringList(contracts.imageGenerationProviders) } + : {}), + ...(normalizeStringList(contracts.webSearchProviders) + ? { webSearchProviders: normalizeStringList(contracts.webSearchProviders) } + : {}), + ...(normalizeStringList(contracts.tools) + ? { tools: normalizeStringList(contracts.tools) } + : {}), + }; + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + function normalizeObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; @@ -109,6 +136,9 @@ function normalizePluginManifest(raw) { ...(normalizeStringList(raw.imageGenerationProviders) ? { imageGenerationProviders: normalizeStringList(raw.imageGenerationProviders) } : {}), + ...(normalizeStringList(raw.cliBackends) + ? { cliBackends: normalizeStringList(raw.cliBackends) } + : {}), ...(normalizeObject(raw.providerAuthEnvVars) ? { providerAuthEnvVars: raw.providerAuthEnvVars } : {}), @@ -120,6 +150,9 @@ function normalizePluginManifest(raw) { ...(typeof raw.description === "string" ? { description: raw.description.trim() } : {}), ...(typeof raw.version === "string" ? { version: raw.version.trim() } : {}), ...(normalizeObject(raw.uiHints) ? { uiHints: raw.uiHints } : {}), + ...(normalizeManifestContracts(raw.contracts) + ? { contracts: normalizeManifestContracts(raw.contracts) } + : {}), }; } diff --git a/src/bundled-web-search-registry.ts b/src/bundled-web-search-registry.ts deleted file mode 100644 index 2797bdab97f..00000000000 --- a/src/bundled-web-search-registry.ts +++ /dev/null @@ -1,75 +0,0 @@ -import bravePlugin from "../extensions/brave/index.js"; -import duckduckgoPlugin from "../extensions/duckduckgo/index.js"; -import exaPlugin from "../extensions/exa/index.js"; -import firecrawlPlugin from "../extensions/firecrawl/index.js"; -import googlePlugin from "../extensions/google/index.js"; -import moonshotPlugin from "../extensions/moonshot/index.js"; -import perplexityPlugin from "../extensions/perplexity/index.js"; -import tavilyPlugin from "../extensions/tavily/index.js"; -import xaiPlugin from "../extensions/xai/index.js"; -import type { OpenClawPluginApi } from "./plugins/types.js"; - -type RegistrablePlugin = { - id: string; - register: (api: OpenClawPluginApi) => void; -}; - -export const bundledWebSearchPluginRegistrations: ReadonlyArray<{ - readonly plugin: RegistrablePlugin; - credentialValue: unknown; -}> = [ - { - get plugin() { - return bravePlugin; - }, - credentialValue: "BSA-test", - }, - { - get plugin() { - return exaPlugin; - }, - credentialValue: "exa-test", - }, - { - get plugin() { - return duckduckgoPlugin; - }, - credentialValue: "duckduckgo-no-key-needed", - }, - { - get plugin() { - return firecrawlPlugin; - }, - credentialValue: "fc-test", - }, - { - get plugin() { - return googlePlugin; - }, - credentialValue: "AIza-test", - }, - { - get plugin() { - return moonshotPlugin; - }, - credentialValue: "sk-test", - }, - { - get plugin() { - return perplexityPlugin; - }, - credentialValue: "pplx-test", - }, - { - get plugin() { - return tavilyPlugin; - }, - credentialValue: "tvly-test", - }, - { - get plugin() { - return xaiPlugin; - }, - credentialValue: "xai-test", - }, -]; diff --git a/src/plugins/bundled-capability-metadata.ts b/src/plugins/bundled-capability-metadata.ts new file mode 100644 index 00000000000..982196e8915 --- /dev/null +++ b/src/plugins/bundled-capability-metadata.ts @@ -0,0 +1,92 @@ +import { BUNDLED_PLUGIN_METADATA } from "./bundled-plugin-metadata.js"; + +export type BundledPluginContractSnapshot = { + pluginId: string; + cliBackendIds: string[]; + providerIds: string[]; + speechProviderIds: string[]; + mediaUnderstandingProviderIds: string[]; + imageGenerationProviderIds: string[]; + webSearchProviderIds: string[]; + toolNames: string[]; +}; + +function uniqueStrings(values: readonly string[] | undefined): string[] { + const result: string[] = []; + const seen = new Set(); + for (const value of values ?? []) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + +export const BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS: readonly BundledPluginContractSnapshot[] = + BUNDLED_PLUGIN_METADATA.map(({ manifest }) => ({ + pluginId: manifest.id, + cliBackendIds: uniqueStrings(manifest.cliBackends), + providerIds: uniqueStrings(manifest.providers), + speechProviderIds: uniqueStrings(manifest.contracts?.speechProviders), + mediaUnderstandingProviderIds: uniqueStrings(manifest.contracts?.mediaUnderstandingProviders), + imageGenerationProviderIds: uniqueStrings(manifest.contracts?.imageGenerationProviders), + webSearchProviderIds: uniqueStrings(manifest.contracts?.webSearchProviders), + toolNames: uniqueStrings(manifest.contracts?.tools), + })) + .filter( + (entry) => + entry.cliBackendIds.length > 0 || + entry.providerIds.length > 0 || + entry.speechProviderIds.length > 0 || + entry.mediaUnderstandingProviderIds.length > 0 || + entry.imageGenerationProviderIds.length > 0 || + entry.webSearchProviderIds.length > 0 || + entry.toolNames.length > 0, + ) + .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)); + +function collectPluginIds( + pick: (entry: BundledPluginContractSnapshot) => readonly string[], +): readonly string[] { + return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter((entry) => pick(entry).length > 0) + .map((entry) => entry.pluginId) + .toSorted((left, right) => left.localeCompare(right)); +} + +export const BUNDLED_PROVIDER_PLUGIN_IDS = collectPluginIds((entry) => entry.providerIds); + +export const BUNDLED_SPEECH_PLUGIN_IDS = collectPluginIds((entry) => entry.speechProviderIds); + +export const BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS = collectPluginIds( + (entry) => entry.mediaUnderstandingProviderIds, +); + +export const BUNDLED_IMAGE_GENERATION_PLUGIN_IDS = collectPluginIds( + (entry) => entry.imageGenerationProviderIds, +); + +export const BUNDLED_RUNTIME_CONTRACT_PLUGIN_IDS = [ + ...new Set( + BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter( + (entry) => + entry.providerIds.length > 0 || + entry.speechProviderIds.length > 0 || + entry.mediaUnderstandingProviderIds.length > 0 || + entry.imageGenerationProviderIds.length > 0 || + entry.webSearchProviderIds.length > 0, + ).map((entry) => entry.pluginId), + ), +].toSorted((left, right) => left.localeCompare(right)); + +export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = collectPluginIds( + (entry) => entry.webSearchProviderIds, +); + +export const BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS = Object.fromEntries( + BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.flatMap((entry) => + entry.webSearchProviderIds.map((providerId) => [providerId, entry.pluginId] as const), + ).toSorted(([left], [right]) => left.localeCompare(right)), +) as Readonly>; diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts new file mode 100644 index 00000000000..a0e70df6cbc --- /dev/null +++ b/src/plugins/bundled-capability-runtime.ts @@ -0,0 +1,41 @@ +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + withBundledPluginEnablementCompat, + withBundledPluginVitestCompat, +} from "./bundled-compat.js"; +import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; + +const log = createSubsystemLogger("plugins"); + +export function buildBundledCapabilityRuntimeConfig( + pluginIds: readonly string[], + env?: PluginLoadOptions["env"], +): PluginLoadOptions["config"] { + const enablementCompat = withBundledPluginEnablementCompat({ + config: undefined, + pluginIds, + }); + return withBundledPluginVitestCompat({ + config: enablementCompat, + pluginIds, + env, + }); +} + +export function loadBundledCapabilityRuntimeRegistry(params: { + pluginIds: readonly string[]; + env?: PluginLoadOptions["env"]; +}) { + return loadOpenClawPlugins({ + config: buildBundledCapabilityRuntimeConfig(params.pluginIds, params.env), + env: params.env, + onlyPluginIds: [...params.pluginIds], + cache: false, + activate: false, + logger: { + info: (message) => log.info(message), + warn: (message) => log.warn(message), + error: (message) => log.error(message), + }, + }); +} diff --git a/src/plugins/bundled-plugin-metadata.generated.ts b/src/plugins/bundled-plugin-metadata.generated.ts index 6e01a1e929c..4760e699d03 100644 --- a/src/plugins/bundled-plugin-metadata.generated.ts +++ b/src/plugins/bundled-plugin-metadata.generated.ts @@ -170,6 +170,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ }, providers: ["anthropic"], mediaUnderstandingProviders: ["anthropic"], + cliBackends: ["claude-cli"], providerAuthEnvVars: { anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], }, @@ -208,6 +209,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ cliDescription: "Anthropic API key", }, ], + contracts: { + mediaUnderstandingProviders: ["anthropic"], + }, }, }, { @@ -306,6 +310,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ help: "Brave Search mode: web or llm-context.", }, }, + contracts: { + webSearchProviders: ["brave"], + }, }, }, { @@ -523,6 +530,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ properties: {}, }, mediaUnderstandingProviders: ["deepgram"], + contracts: { + mediaUnderstandingProviders: ["deepgram"], + }, }, }, { @@ -872,6 +882,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ help: "SafeSearch level for DuckDuckGo results.", }, }, + contracts: { + webSearchProviders: ["duckduckgo"], + }, }, }, { @@ -895,6 +908,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ properties: {}, }, speechProviders: ["elevenlabs"], + contracts: { + speechProviders: ["elevenlabs"], + }, }, }, { @@ -938,6 +954,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ placeholder: "exa-...", }, }, + contracts: { + webSearchProviders: ["exa"], + }, }, }, { @@ -981,6 +1000,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ cliDescription: "fal API key", }, ], + contracts: { + imageGenerationProviders: ["fal"], + }, }, }, { @@ -1077,6 +1099,10 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ help: "Firecrawl Search base URL override.", }, }, + contracts: { + webSearchProviders: ["firecrawl"], + tools: ["firecrawl_search", "firecrawl_scrape"], + }, }, }, { @@ -1153,6 +1179,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ providers: ["google", "google-gemini-cli"], mediaUnderstandingProviders: ["google"], imageGenerationProviders: ["google"], + cliBackends: ["google-gemini-cli"], providerAuthEnvVars: { google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], }, @@ -1193,6 +1220,11 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ help: "Gemini model override for web search grounding.", }, }, + contracts: { + mediaUnderstandingProviders: ["google"], + imageGenerationProviders: ["google"], + webSearchProviders: ["gemini"], + }, }, }, { @@ -1261,6 +1293,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ properties: {}, }, mediaUnderstandingProviders: ["groq"], + contracts: { + mediaUnderstandingProviders: ["groq"], + }, }, }, { @@ -1823,6 +1858,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ properties: {}, }, speechProviders: ["microsoft"], + contracts: { + speechProviders: ["microsoft"], + }, }, }, { @@ -1951,6 +1989,10 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ cliDescription: "MiniMax API key", }, ], + contracts: { + mediaUnderstandingProviders: ["minimax", "minimax-portal"], + imageGenerationProviders: ["minimax", "minimax-portal"], + }, }, }, { @@ -1993,6 +2035,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ cliDescription: "Mistral API key", }, ], + contracts: { + mediaUnderstandingProviders: ["mistral"], + }, }, }, { @@ -2163,6 +2208,10 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ help: "Kimi model override.", }, }, + contracts: { + mediaUnderstandingProviders: ["moonshot"], + webSearchProviders: ["kimi"], + }, }, }, { @@ -2411,6 +2460,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ speechProviders: ["openai"], mediaUnderstandingProviders: ["openai", "openai-codex"], imageGenerationProviders: ["openai"], + cliBackends: ["codex-cli"], providerAuthEnvVars: { openai: ["OPENAI_API_KEY"], }, @@ -2439,6 +2489,11 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ cliDescription: "OpenAI API key", }, ], + contracts: { + speechProviders: ["openai"], + mediaUnderstandingProviders: ["openai", "openai-codex"], + imageGenerationProviders: ["openai"], + }, }, }, { @@ -2733,6 +2788,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ help: "Optional Sonar/OpenRouter model override.", }, }, + contracts: { + webSearchProviders: ["perplexity"], + }, }, }, { @@ -3023,6 +3081,10 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ help: "Tavily API base URL override.", }, }, + contracts: { + webSearchProviders: ["tavily"], + tools: ["tavily_search", "tavily_extract"], + }, }, }, { @@ -4085,6 +4147,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ help: "Include inline markdown citations in Grok responses.", }, }, + contracts: { + webSearchProviders: ["grok"], + }, }, }, { @@ -4224,6 +4289,9 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ cliDescription: "Z.AI API key", }, ], + contracts: { + mediaUnderstandingProviders: ["zai"], + }, }, }, { diff --git a/src/plugins/bundled-web-search-ids.ts b/src/plugins/bundled-web-search-ids.ts index e73f543d637..bbde719bdf0 100644 --- a/src/plugins/bundled-web-search-ids.ts +++ b/src/plugins/bundled-web-search-ids.ts @@ -1,14 +1,6 @@ -export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [ - "brave", - "duckduckgo", - "exa", - "firecrawl", - "google", - "moonshot", - "perplexity", - "tavily", - "xai", -] as const; +import { BUNDLED_WEB_SEARCH_PLUGIN_IDS as BUNDLED_WEB_SEARCH_PLUGIN_IDS_FROM_METADATA } from "./bundled-capability-metadata.js"; + +export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = BUNDLED_WEB_SEARCH_PLUGIN_IDS_FROM_METADATA; export function listBundledWebSearchPluginIds(): string[] { return [...BUNDLED_WEB_SEARCH_PLUGIN_IDS]; diff --git a/src/plugins/bundled-web-search-provider-ids.ts b/src/plugins/bundled-web-search-provider-ids.ts index 198d7d47587..f6244624bab 100644 --- a/src/plugins/bundled-web-search-provider-ids.ts +++ b/src/plugins/bundled-web-search-provider-ids.ts @@ -1,13 +1,4 @@ -const BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS = { - brave: "brave", - exa: "exa", - firecrawl: "firecrawl", - gemini: "google", - grok: "xai", - kimi: "moonshot", - perplexity: "perplexity", - tavily: "tavily", -} as const satisfies Record; +import { BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS } from "./bundled-capability-metadata.js"; export function resolveBundledWebSearchPluginId( providerId: string | undefined, diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts index e6989c00134..35649c894b4 100644 --- a/src/plugins/bundled-web-search.test.ts +++ b/src/plugins/bundled-web-search.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; import type { OpenClawConfig } from "../config/config.js"; import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-web-search-ids.js"; import { resolveBundledWebSearchPluginId } from "./bundled-web-search-provider-ids.js"; @@ -85,14 +84,16 @@ describe("bundled web search metadata", () => { it("keeps bundled web search fast-path ids aligned with the registry", () => { expect([...BUNDLED_WEB_SEARCH_PLUGIN_IDS]).toEqual( - bundledWebSearchPluginRegistrations - .map(({ plugin }) => plugin.id) + listBundledWebSearchProviders() + .map(({ pluginId }) => pluginId) + .filter((value, index, values) => values.indexOf(value) === index) .toSorted((left, right) => left.localeCompare(right)), ); }); it("keeps bundled web search provider-to-plugin ids aligned with bundled contracts", () => { expect(resolveBundledWebSearchPluginId("brave")).toBe("brave"); + expect(resolveBundledWebSearchPluginId("duckduckgo")).toBe("duckduckgo"); expect(resolveBundledWebSearchPluginId("exa")).toBe("exa"); expect(resolveBundledWebSearchPluginId("firecrawl")).toBe("firecrawl"); expect(resolveBundledWebSearchPluginId("gemini")).toBe("google"); @@ -102,7 +103,7 @@ describe("bundled web search metadata", () => { expect(resolveBundledWebSearchPluginId("grok")).toBe("xai"); }); - it("keeps fast-path bundled provider metadata aligned with bundled plugin contracts", async () => { + it("keeps bundled provider metadata aligned with bundled plugin contracts", async () => { const fastPathProviders = listBundledWebSearchProviders(); expect( diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts index ca97551e349..9386685bdb9 100644 --- a/src/plugins/bundled-web-search.ts +++ b/src/plugins/bundled-web-search.ts @@ -1,50 +1,20 @@ -import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js"; -import { listBundledWebSearchPluginIds as listBundledWebSearchPluginIdsFromIds } from "./bundled-web-search-ids.js"; +import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-capability-metadata.js"; import { resolveBundledWebSearchPluginId as resolveBundledWebSearchPluginIdFromMap } from "./bundled-web-search-provider-ids.js"; -import { capturePluginRegistration } from "./captured-registration.js"; +import { webSearchProviderContractRegistry } from "./contracts/registry.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string }; -type BundledWebSearchPluginRegistration = (typeof bundledWebSearchPluginRegistrations)[number]; let bundledWebSearchProvidersCache: BundledWebSearchProviderEntry[] | null = null; -function resolveBundledWebSearchPlugin( - entry: BundledWebSearchPluginRegistration, -): BundledWebSearchPluginRegistration["plugin"] | null { - try { - return entry.plugin; - } catch { - return null; - } -} - -function listBundledWebSearchPluginRegistrations() { - return bundledWebSearchPluginRegistrations - .map((entry) => { - const plugin = resolveBundledWebSearchPlugin(entry); - return plugin ? { ...entry, plugin } : null; - }) - .filter( - ( - entry, - ): entry is BundledWebSearchPluginRegistration & { - plugin: BundledWebSearchPluginRegistration["plugin"]; - } => Boolean(entry), - ); -} - function loadBundledWebSearchProviders(): BundledWebSearchProviderEntry[] { if (!bundledWebSearchProvidersCache) { - bundledWebSearchProvidersCache = listBundledWebSearchPluginRegistrations().flatMap( - ({ plugin }) => - capturePluginRegistration(plugin).webSearchProviders.map((provider) => ({ - ...provider, - pluginId: plugin.id, - })), - ); + bundledWebSearchProvidersCache = webSearchProviderContractRegistry.map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })); } return bundledWebSearchProvidersCache; } @@ -54,20 +24,21 @@ export function resolveBundledWebSearchPluginIds(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; }): string[] { - const registry = loadPluginManifestRegistry({ + const bundledWebSearchPluginIdSet = new Set(BUNDLED_WEB_SEARCH_PLUGIN_IDS); + return loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, - }); - const bundledWebSearchPluginIdSet = new Set(listBundledWebSearchPluginIdsFromIds()); - return registry.plugins - .filter((plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id)) + }) + .plugins.filter( + (plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id), + ) .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); } export function listBundledWebSearchPluginIds(): string[] { - return listBundledWebSearchPluginIdsFromIds(); + return [...BUNDLED_WEB_SEARCH_PLUGIN_IDS]; } export function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] { diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 7f15fe700e8..216bfb2b003 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -7,23 +7,21 @@ import { pluginRegistrationContractRegistry, providerContractLoadError, providerContractPluginIds, - providerContractRegistry, speechProviderContractRegistry, - webSearchProviderContractRegistry, } from "./registry.js"; function findProviderIdsForPlugin(pluginId: string) { - return providerContractRegistry - .filter((entry) => entry.pluginId === pluginId) - .map((entry) => entry.provider.id) - .toSorted((left, right) => left.localeCompare(right)); + return ( + pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ?? + [] + ); } function findWebSearchIdsForPlugin(pluginId: string) { - return webSearchProviderContractRegistry - .filter((entry) => entry.pluginId === pluginId) - .map((entry) => entry.provider.id) - .toSorted((left, right) => left.localeCompare(right)); + return ( + pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId) + ?.webSearchProviderIds ?? [] + ); } function findSpeechProviderIdsForPlugin(pluginId: string) { @@ -85,6 +83,20 @@ function findRegistrationForPlugin(pluginId: string) { return entry; } +type BundledCapabilityContractKey = + | "speechProviders" + | "mediaUnderstandingProviders" + | "imageGenerationProviders"; + +function findBundledManifestPluginIdsForContract(key: BundledCapabilityContractKey) { + return loadPluginManifestRegistry({}) + .plugins.filter( + (plugin) => plugin.origin === "bundled" && (plugin.contracts?.[key]?.length ?? 0) > 0, + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + describe("plugin contract registry", () => { it("loads bundled non-provider capability registries without import-time failure", () => { expect(providerContractLoadError).toBeUndefined(); @@ -92,12 +104,12 @@ describe("plugin contract registry", () => { }); it("does not duplicate bundled provider ids", () => { - const ids = providerContractRegistry.map((entry) => entry.provider.id); + const ids = pluginRegistrationContractRegistry.flatMap((entry) => entry.providerIds); expect(ids).toEqual([...new Set(ids)]); }); it("does not duplicate bundled web search provider ids", () => { - const ids = webSearchProviderContractRegistry.map((entry) => entry.provider.id); + const ids = pluginRegistrationContractRegistry.flatMap((entry) => entry.webSearchProviderIds); expect(ids).toEqual([...new Set(ids)]); }); @@ -121,12 +133,7 @@ describe("plugin contract registry", () => { }); it("covers every bundled speech plugin discovered from manifests", () => { - const bundledSpeechPluginIds = loadPluginManifestRegistry({}) - .plugins.filter( - (plugin) => plugin.origin === "bundled" && (plugin.speechProviders?.length ?? 0) > 0, - ) - .map((plugin) => plugin.id) - .toSorted((left, right) => left.localeCompare(right)); + const bundledSpeechPluginIds = findBundledManifestPluginIdsForContract("speechProviders"); expect( [...new Set(speechProviderContractRegistry.map((entry) => entry.pluginId))].toSorted( @@ -136,13 +143,9 @@ describe("plugin contract registry", () => { }); it("covers every bundled media-understanding plugin discovered from manifests", () => { - const bundledMediaPluginIds = loadPluginManifestRegistry({}) - .plugins.filter( - (plugin) => - plugin.origin === "bundled" && (plugin.mediaUnderstandingProviders?.length ?? 0) > 0, - ) - .map((plugin) => plugin.id) - .toSorted((left, right) => left.localeCompare(right)); + const bundledMediaPluginIds = findBundledManifestPluginIdsForContract( + "mediaUnderstandingProviders", + ); expect( [ @@ -152,13 +155,9 @@ describe("plugin contract registry", () => { }); it("covers every bundled image-generation plugin discovered from manifests", () => { - const bundledImagePluginIds = loadPluginManifestRegistry({}) - .plugins.filter( - (plugin) => - plugin.origin === "bundled" && (plugin.imageGenerationProviders?.length ?? 0) > 0, - ) - .map((plugin) => plugin.id) - .toSorted((left, right) => left.localeCompare(right)); + const bundledImagePluginIds = findBundledManifestPluginIdsForContract( + "imageGenerationProviders", + ); expect( [...new Set(imageGenerationProviderContractRegistry.map((entry) => entry.pluginId))].toSorted( @@ -167,13 +166,28 @@ describe("plugin contract registry", () => { ).toEqual(bundledImagePluginIds); }); + it("keeps bundled legacy capability fields aligned with manifest contracts", () => { + for (const plugin of loadPluginManifestRegistry({}).plugins.filter( + (candidate) => candidate.origin === "bundled", + )) { + expect(plugin.speechProviders).toEqual(plugin.contracts?.speechProviders ?? []); + expect(plugin.mediaUnderstandingProviders).toEqual( + plugin.contracts?.mediaUnderstandingProviders ?? [], + ); + expect(plugin.imageGenerationProviders).toEqual( + plugin.contracts?.imageGenerationProviders ?? [], + ); + } + }); + it("covers every bundled web search plugin from the shared resolver", () => { const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({}); expect( - [...new Set(webSearchProviderContractRegistry.map((entry) => entry.pluginId))].toSorted( - (left, right) => left.localeCompare(right), - ), + pluginRegistrationContractRegistry + .filter((entry) => entry.webSearchProviderIds.length > 0) + .map((entry) => entry.pluginId) + .toSorted((left, right) => left.localeCompare(right)), ).toEqual(bundledWebSearchPluginIds); }); @@ -207,6 +221,7 @@ describe("plugin contract registry", () => { it("keeps bundled web search ownership explicit", () => { expect(findWebSearchIdsForPlugin("brave")).toEqual(["brave"]); + expect(findWebSearchIdsForPlugin("duckduckgo")).toEqual(["duckduckgo"]); expect(findWebSearchIdsForPlugin("exa")).toEqual(["exa"]); expect(findWebSearchIdsForPlugin("firecrawl")).toEqual(["firecrawl"]); expect(findWebSearchIdsForPlugin("google")).toEqual(["gemini"]); @@ -241,6 +256,10 @@ describe("plugin contract registry", () => { it("keeps bundled image-generation ownership explicit", () => { expect(findImageGenerationProviderIdsForPlugin("fal")).toEqual(["fal"]); expect(findImageGenerationProviderIdsForPlugin("google")).toEqual(["google"]); + expect(findImageGenerationProviderIdsForPlugin("minimax")).toEqual([ + "minimax", + "minimax-portal", + ]); expect(findImageGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]); }); @@ -306,6 +325,14 @@ describe("plugin contract registry", () => { mediaUnderstandingProviderIds: ["openai", "openai-codex"], imageGenerationProviderIds: ["openai"], }); + expect(findRegistrationForPlugin("minimax")).toMatchObject({ + cliBackendIds: [], + providerIds: ["minimax", "minimax-portal"], + speechProviderIds: [], + mediaUnderstandingProviderIds: ["minimax", "minimax-portal"], + imageGenerationProviderIds: ["minimax", "minimax-portal"], + webSearchProviderIds: [], + }); expect(findRegistrationForPlugin("elevenlabs")).toMatchObject({ cliBackendIds: [], providerIds: [], @@ -325,11 +352,15 @@ describe("plugin contract registry", () => { it("tracks every provider, speech, media, image, or web search plugin in the registration registry", () => { const expectedPluginIds = [ ...new Set([ - ...providerContractRegistry.map((entry) => entry.pluginId), + ...pluginRegistrationContractRegistry + .filter((entry) => entry.providerIds.length > 0) + .map((entry) => entry.pluginId), ...speechProviderContractRegistry.map((entry) => entry.pluginId), ...mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId), ...imageGenerationProviderContractRegistry.map((entry) => entry.pluginId), - ...webSearchProviderContractRegistry.map((entry) => entry.pluginId), + ...pluginRegistrationContractRegistry + .filter((entry) => entry.webSearchProviderIds.length > 0) + .map((entry) => entry.pluginId), ]), ].toSorted((left, right) => left.localeCompare(right)); @@ -371,6 +402,9 @@ describe("plugin contract registry", () => { expect(findImageGenerationProviderForPlugin("google").generateImage).toEqual( expect.any(Function), ); + expect(findImageGenerationProviderForPlugin("minimax").generateImage).toEqual( + expect.any(Function), + ); expect(findImageGenerationProviderForPlugin("openai").generateImage).toEqual( expect.any(Function), ); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 01fd7d4f91a..856e8ad8edd 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,7 +1,13 @@ -import { bundledWebSearchPluginRegistrations } from "../../bundled-web-search-registry.js"; -import { BUNDLED_PLUGIN_ENTRIES } from "../bundled-plugin-entries.js"; -import { createCapturedPluginRegistration } from "../captured-registration.js"; -import { loadPluginManifestRegistry } from "../manifest-registry.js"; +import { + BUNDLED_IMAGE_GENERATION_PLUGIN_IDS, + BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS, + BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS, + BUNDLED_PROVIDER_PLUGIN_IDS, + BUNDLED_SPEECH_PLUGIN_IDS, + BUNDLED_WEB_SEARCH_PLUGIN_IDS, +} from "../bundled-capability-metadata.js"; +import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js"; +import { resolvePluginProviders } from "../providers.runtime.js"; import type { ImageGenerationProviderPlugin, MediaUnderstandingProviderPlugin, @@ -10,11 +16,6 @@ import type { WebSearchProviderPlugin, } from "../types.js"; -type RegistrablePlugin = { - id: string; - register: (api: ReturnType["api"]) => void; -}; - type CapabilityContractEntry = { pluginId: string; provider: T; @@ -42,42 +43,140 @@ type PluginRegistrationContractEntry = { toolNames: string[]; }; -const bundledWebSearchPlugins: Array = - bundledWebSearchPluginRegistrations.map(({ plugin, credentialValue }) => ({ - ...plugin, - credentialValue, - })); - -function captureRegistrations(plugin: RegistrablePlugin) { - const captured = createCapturedPluginRegistration(); - plugin.register(captured.api); - return captured; +function uniqueStrings(values: readonly string[]): string[] { + const result: string[] = []; + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; } -function buildCapabilityContractRegistry(params: { - plugins: RegistrablePlugin[]; - select: (captured: ReturnType) => T[]; -}): CapabilityContractEntry[] { - return params.plugins.flatMap((plugin) => { - const captured = captureRegistrations(plugin); - return params.select(captured).map((provider) => ({ - pluginId: plugin.id, - provider, - })); - }); +let providerContractRegistryCache: ProviderContractEntry[] | null = null; +let webSearchProviderContractRegistryCache: WebSearchProviderContractEntry[] | null = null; +let speechProviderContractRegistryCache: SpeechProviderContractEntry[] | null = null; +let mediaUnderstandingProviderContractRegistryCache: + | MediaUnderstandingProviderContractEntry[] + | null = null; +let imageGenerationProviderContractRegistryCache: ImageGenerationProviderContractEntry[] | null = + null; + +export let providerContractLoadError: Error | undefined; + +function loadProviderContractRegistry(): ProviderContractEntry[] { + if (!providerContractRegistryCache) { + try { + providerContractLoadError = undefined; + providerContractRegistryCache = resolvePluginProviders({ + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + onlyPluginIds: [...BUNDLED_PROVIDER_PLUGIN_IDS], + cache: false, + activate: false, + }).map((provider) => ({ + pluginId: provider.pluginId ?? "", + provider, + })); + } catch (error) { + providerContractLoadError = error instanceof Error ? error : new Error(String(error)); + providerContractRegistryCache = []; + } + } + return providerContractRegistryCache; } -function dedupePlugins( - plugins: ReadonlyArray, -): T[] { +function loadUniqueProviderContractProviders(): ProviderPlugin[] { return [ ...new Map( - plugins.filter((plugin): plugin is T => Boolean(plugin)).map((plugin) => [plugin.id, plugin]), + loadProviderContractRegistry().map((entry) => [entry.provider.id, entry.provider]), ).values(), ]; } -export let providerContractLoadError: Error | undefined; +function loadProviderContractPluginIds(): string[] { + return [...BUNDLED_PROVIDER_PLUGIN_IDS]; +} + +function loadProviderContractCompatPluginIds(): string[] { + return loadProviderContractPluginIds().map((pluginId) => + pluginId === "kimi-coding" ? "kimi" : pluginId, + ); +} + +function resolveWebSearchCredentialValue(provider: WebSearchProviderPlugin): unknown { + if (provider.requiresCredential === false) { + return `${provider.id}-no-key-needed`; + } + const envVar = provider.envVars.find((entry) => entry.trim().length > 0); + if (!envVar) { + return `${provider.id}-test`; + } + if (envVar === "OPENROUTER_API_KEY") { + return "openrouter-test"; + } + return envVar.toLowerCase().includes("api_key") ? `${provider.id}-test` : "sk-test"; +} + +function loadWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { + if (!webSearchProviderContractRegistryCache) { + const registry = loadBundledCapabilityRuntimeRegistry({ + pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS, + }); + webSearchProviderContractRegistryCache = registry.webSearchProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + credentialValue: resolveWebSearchCredentialValue(entry.provider), + })); + } + return webSearchProviderContractRegistryCache; +} + +function loadSpeechProviderContractRegistry(): SpeechProviderContractEntry[] { + if (!speechProviderContractRegistryCache) { + const registry = loadBundledCapabilityRuntimeRegistry({ + pluginIds: BUNDLED_SPEECH_PLUGIN_IDS, + }); + speechProviderContractRegistryCache = registry.speechProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); + } + return speechProviderContractRegistryCache; +} + +function loadMediaUnderstandingProviderContractRegistry(): MediaUnderstandingProviderContractEntry[] { + if (!mediaUnderstandingProviderContractRegistryCache) { + const registry = loadBundledCapabilityRuntimeRegistry({ + pluginIds: BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS, + }); + mediaUnderstandingProviderContractRegistryCache = registry.mediaUnderstandingProviders.map( + (entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + }), + ); + } + return mediaUnderstandingProviderContractRegistryCache; +} + +function loadImageGenerationProviderContractRegistry(): ImageGenerationProviderContractEntry[] { + if (!imageGenerationProviderContractRegistryCache) { + const registry = loadBundledCapabilityRuntimeRegistry({ + pluginIds: BUNDLED_IMAGE_GENERATION_PLUGIN_IDS, + }); + imageGenerationProviderContractRegistryCache = registry.imageGenerationProviders.map( + (entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + }), + ); + } + return imageGenerationProviderContractRegistryCache; +} function createLazyArrayView(load: () => T[]): T[] { return new Proxy([] as T[], { @@ -111,55 +210,6 @@ function createLazyArrayView(load: () => T[]): T[] { }); } -let providerContractRegistryCache: ProviderContractEntry[] | null = null; -let webSearchProviderContractRegistryCache: WebSearchProviderContractEntry[] | null = null; -let speechProviderContractRegistryCache: SpeechProviderContractEntry[] | null = null; -let mediaUnderstandingProviderContractRegistryCache: - | MediaUnderstandingProviderContractEntry[] - | null = null; -let imageGenerationProviderContractRegistryCache: ImageGenerationProviderContractEntry[] | null = - null; -let pluginRegistrationContractRegistryCache: PluginRegistrationContractEntry[] | null = null; - -function loadProviderContractRegistry(): ProviderContractEntry[] { - if (!providerContractRegistryCache) { - try { - providerContractLoadError = undefined; - providerContractRegistryCache = buildCapabilityContractRegistry({ - plugins: bundledProviderPlugins, - select: (captured) => captured.providers, - }).map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); - } catch (error) { - providerContractLoadError = error instanceof Error ? error : new Error(String(error)); - providerContractRegistryCache = []; - } - } - return providerContractRegistryCache; -} - -function loadUniqueProviderContractProviders(): ProviderPlugin[] { - return [ - ...new Map( - loadProviderContractRegistry().map((entry) => [entry.provider.id, entry.provider]), - ).values(), - ]; -} - -function loadProviderContractPluginIds(): string[] { - return [...new Set(loadProviderContractRegistry().map((entry) => entry.pluginId))].toSorted( - (left, right) => left.localeCompare(right), - ); -} - -function loadProviderContractCompatPluginIds(): string[] { - return loadProviderContractPluginIds().map((pluginId) => - pluginId === "kimi-coding" ? "kimi" : pluginId, - ); -} - export const providerContractRegistry: ProviderContractEntry[] = createLazyArrayView( loadProviderContractRegistry, ); @@ -179,9 +229,6 @@ export const providerContractCompatPluginIds: string[] = createLazyArrayView( export function requireProviderContractProvider(providerId: string): ProviderPlugin { const provider = uniqueProviderContractProviders.find((entry) => entry.id === providerId); if (!provider) { - if (!providerContractLoadError) { - loadProviderContractRegistry(); - } if (providerContractLoadError) { throw new Error( `provider contract entry missing for ${providerId}; bundled provider registry failed to load: ${providerContractLoadError.message}`, @@ -218,50 +265,6 @@ export function resolveProviderContractProvidersForPluginIds( ]; } -function loadWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { - if (!webSearchProviderContractRegistryCache) { - webSearchProviderContractRegistryCache = bundledWebSearchPlugins.flatMap((plugin) => { - const captured = captureRegistrations(plugin); - return captured.webSearchProviders.map((provider) => ({ - pluginId: plugin.id, - provider, - credentialValue: plugin.credentialValue, - })); - }); - } - return webSearchProviderContractRegistryCache; -} - -function loadSpeechProviderContractRegistry(): SpeechProviderContractEntry[] { - if (!speechProviderContractRegistryCache) { - speechProviderContractRegistryCache = buildCapabilityContractRegistry({ - plugins: bundledSpeechPlugins, - select: (captured) => captured.speechProviders, - }); - } - return speechProviderContractRegistryCache; -} - -function loadMediaUnderstandingProviderContractRegistry(): MediaUnderstandingProviderContractEntry[] { - if (!mediaUnderstandingProviderContractRegistryCache) { - mediaUnderstandingProviderContractRegistryCache = buildCapabilityContractRegistry({ - plugins: bundledMediaUnderstandingPlugins, - select: (captured) => captured.mediaUnderstandingProviders, - }); - } - return mediaUnderstandingProviderContractRegistryCache; -} - -function loadImageGenerationProviderContractRegistry(): ImageGenerationProviderContractEntry[] { - if (!imageGenerationProviderContractRegistryCache) { - imageGenerationProviderContractRegistryCache = buildCapabilityContractRegistry({ - plugins: bundledImageGenerationPlugins, - select: (captured) => captured.imageGenerationProviders, - }); - } - return imageGenerationProviderContractRegistryCache; -} - export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] = createLazyArrayView(loadWebSearchProviderContractRegistry); @@ -275,107 +278,17 @@ export const mediaUnderstandingProviderContractRegistry: MediaUnderstandingProvi export const imageGenerationProviderContractRegistry: ImageGenerationProviderContractEntry[] = createLazyArrayView(loadImageGenerationProviderContractRegistry); -const bundledRegistrablePluginsById = new Map( - dedupePlugins([...BUNDLED_PLUGIN_ENTRIES, ...bundledWebSearchPlugins]).map((plugin) => [ - plugin.id, - plugin, - ]), -); - -function resolveBundledManifestPluginIds( - predicate: (plugin: ReturnType["plugins"][number]) => boolean, -): string[] { - return loadPluginManifestRegistry({}) - .plugins.filter((plugin) => plugin.origin === "bundled" && predicate(plugin)) - .map((plugin) => plugin.id) - .toSorted((left, right) => left.localeCompare(right)); -} - -function resolveBundledRegistrablePlugins( - predicate: (plugin: ReturnType["plugins"][number]) => boolean, -): RegistrablePlugin[] { - return resolveBundledManifestPluginIds(predicate).flatMap((pluginId) => { - const plugin = bundledRegistrablePluginsById.get(pluginId); - return plugin ? [plugin] : []; - }); -} - -const bundledProviderPlugins = resolveBundledRegistrablePlugins( - (plugin) => plugin.providers.length > 0, -); -const bundledSpeechPlugins = resolveBundledRegistrablePlugins( - (plugin) => (plugin.speechProviders?.length ?? 0) > 0, -); -const bundledMediaUnderstandingPlugins = resolveBundledRegistrablePlugins( - (plugin) => (plugin.mediaUnderstandingProviders?.length ?? 0) > 0, -); -const bundledImageGenerationPlugins = resolveBundledRegistrablePlugins( - (plugin) => (plugin.imageGenerationProviders?.length ?? 0) > 0, -); - -const bundledPluginRegistrationList = dedupePlugins([ - ...bundledProviderPlugins, - ...bundledSpeechPlugins, - ...bundledMediaUnderstandingPlugins, - ...bundledImageGenerationPlugins, - ...bundledWebSearchPlugins, -]); - -function upsertPluginRegistrationContractEntry( - entries: PluginRegistrationContractEntry[], - next: PluginRegistrationContractEntry, -): void { - const existing = entries.find((entry) => entry.pluginId === next.pluginId); - if (!existing) { - entries.push(next); - return; - } - existing.cliBackendIds = [ - ...new Set([...existing.cliBackendIds, ...next.cliBackendIds]), - ].toSorted((left, right) => left.localeCompare(right)); - existing.providerIds = [...new Set([...existing.providerIds, ...next.providerIds])].toSorted( - (left, right) => left.localeCompare(right), - ); - existing.speechProviderIds = [ - ...new Set([...existing.speechProviderIds, ...next.speechProviderIds]), - ].toSorted((left, right) => left.localeCompare(right)); - existing.mediaUnderstandingProviderIds = [ - ...new Set([...existing.mediaUnderstandingProviderIds, ...next.mediaUnderstandingProviderIds]), - ].toSorted((left, right) => left.localeCompare(right)); - existing.imageGenerationProviderIds = [ - ...new Set([...existing.imageGenerationProviderIds, ...next.imageGenerationProviderIds]), - ].toSorted((left, right) => left.localeCompare(right)); - existing.webSearchProviderIds = [ - ...new Set([...existing.webSearchProviderIds, ...next.webSearchProviderIds]), - ].toSorted((left, right) => left.localeCompare(right)); - existing.toolNames = [...new Set([...existing.toolNames, ...next.toolNames])].toSorted( - (left, right) => left.localeCompare(right), - ); -} - function loadPluginRegistrationContractRegistry(): PluginRegistrationContractEntry[] { - if (!pluginRegistrationContractRegistryCache) { - const entries: PluginRegistrationContractEntry[] = []; - for (const plugin of bundledPluginRegistrationList) { - const captured = captureRegistrations(plugin); - upsertPluginRegistrationContractEntry(entries, { - pluginId: plugin.id, - cliBackendIds: captured.cliBackends.map((backend) => backend.id), - providerIds: captured.providers.map((provider) => provider.id), - speechProviderIds: captured.speechProviders.map((provider) => provider.id), - mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( - (provider) => provider.id, - ), - imageGenerationProviderIds: captured.imageGenerationProviders.map( - (provider) => provider.id, - ), - webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), - toolNames: captured.tools.map((tool) => tool.name), - }); - } - pluginRegistrationContractRegistryCache = entries; - } - return pluginRegistrationContractRegistryCache; + return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.map((entry) => ({ + pluginId: entry.pluginId, + cliBackendIds: uniqueStrings(entry.cliBackendIds), + providerIds: uniqueStrings(entry.providerIds), + speechProviderIds: uniqueStrings(entry.speechProviderIds), + mediaUnderstandingProviderIds: uniqueStrings(entry.mediaUnderstandingProviderIds), + imageGenerationProviderIds: uniqueStrings(entry.imageGenerationProviderIds), + webSearchProviderIds: uniqueStrings(entry.webSearchProviderIds), + toolNames: uniqueStrings(entry.toolNames), + })); } export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index 34b94732f5b..3d356dc3ac6 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -2,11 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import openAIPlugin from "../../../extensions/openai/index.js"; import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; import type { ProviderPlugin, ProviderRuntimeModel } from "../types.js"; import { requireProviderContractProvider as requireBundledProviderContractProvider } from "./registry.js"; -import { registerProviders, requireProvider } from "./testkit.js"; const CONTRACT_SETUP_TIMEOUT_MS = 300_000; @@ -46,9 +44,6 @@ function createModel(overrides: Partial & Pick; configUiHints?: Record; + contracts?: PluginManifestContracts; channelCatalogMeta?: { id: string; preferOver?: string[]; @@ -195,6 +200,7 @@ function buildRecord(params: { schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, configUiHints: params.manifest.uiHints, + contracts: params.manifest.contracts, ...(params.candidate.packageManifest?.channel?.id ? { channelCatalogMeta: { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 50ec2d0aca0..d43622f9989 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -32,6 +32,19 @@ export type PluginManifest = { description?: string; version?: string; uiHints?: Record; + /** + * Static capability ownership snapshot used for manifest-driven discovery, + * compat wiring, and contract coverage without importing plugin runtime. + */ + contracts?: PluginManifestContracts; +}; + +export type PluginManifestContracts = { + speechProviders?: string[]; + mediaUnderstandingProviders?: string[]; + imageGenerationProviders?: string[]; + webSearchProviders?: string[]; + tools?: string[]; }; export type PluginManifestProviderAuthChoice = { @@ -92,6 +105,30 @@ function normalizeStringListRecord(value: unknown): Record | u return Object.keys(normalized).length > 0 ? normalized : undefined; } +function normalizeManifestContracts(value: unknown): PluginManifestContracts | undefined { + if (!isRecord(value)) { + return undefined; + } + + const contracts = { + ...(normalizeStringList(value.speechProviders) + ? { speechProviders: normalizeStringList(value.speechProviders) } + : {}), + ...(normalizeStringList(value.mediaUnderstandingProviders) + ? { mediaUnderstandingProviders: normalizeStringList(value.mediaUnderstandingProviders) } + : {}), + ...(normalizeStringList(value.imageGenerationProviders) + ? { imageGenerationProviders: normalizeStringList(value.imageGenerationProviders) } + : {}), + ...(normalizeStringList(value.webSearchProviders) + ? { webSearchProviders: normalizeStringList(value.webSearchProviders) } + : {}), + ...(normalizeStringList(value.tools) ? { tools: normalizeStringList(value.tools) } : {}), + } satisfies PluginManifestContracts; + + return Object.keys(contracts).length > 0 ? contracts : undefined; +} + function normalizeProviderAuthChoices( value: unknown, ): PluginManifestProviderAuthChoice[] | undefined { @@ -215,6 +252,7 @@ export function loadPluginManifest( const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices); const skills = normalizeStringList(raw.skills); + const contracts = normalizeManifestContracts(raw.contracts); let uiHints: Record | undefined; if (isRecord(raw.uiHints)) { @@ -241,6 +279,7 @@ export function loadPluginManifest( description, version, uiHints, + contracts, }, manifestPath, }; diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts index 631437f008d..8234e3265f8 100644 --- a/src/plugins/web-search-providers.runtime.ts +++ b/src/plugins/web-search-providers.runtime.ts @@ -41,12 +41,16 @@ function buildWebSearchSnapshotCacheKey(params: { config?: OpenClawConfig; workspaceDir?: string; bundledAllowlistCompat?: boolean; + onlyPluginIds?: readonly string[]; env: NodeJS.ProcessEnv; }): string { const effectiveVitest = params.env.VITEST ?? process.env.VITEST ?? ""; return JSON.stringify({ workspaceDir: params.workspaceDir ?? "", bundledAllowlistCompat: params.bundledAllowlistCompat === true, + onlyPluginIds: [...new Set(params.onlyPluginIds ?? [])].toSorted((left, right) => + left.localeCompare(right), + ), config: params.config ?? null, env: buildPluginSnapshotCacheEnvKey(params.env, { includeProcessVitestFallback: effectiveVitest !== (params.env.VITEST ?? ""), @@ -55,6 +59,9 @@ function buildWebSearchSnapshotCacheKey(params: { } function pluginManifestDeclaresWebSearch(record: PluginManifestRecord): boolean { + if ((record.contracts?.webSearchProviders?.length ?? 0) > 0) { + return true; + } const configUiHintKeys = Object.keys(record.configUiHints ?? {}); if (configUiHintKeys.some((key) => key === "webSearch" || key.startsWith("webSearch."))) { return true; @@ -70,14 +77,21 @@ function resolveWebSearchCandidatePluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; + onlyPluginIds?: readonly string[]; }): string[] | undefined { const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); + const onlyPluginIdSet = + params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null; const ids = registry.plugins - .filter(pluginManifestDeclaresWebSearch) + .filter( + (plugin) => + pluginManifestDeclaresWebSearch(plugin) && + (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)), + ) .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); return ids.length > 0 ? ids : undefined; @@ -88,6 +102,7 @@ export function resolvePluginWebSearchProviders(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; bundledAllowlistCompat?: boolean; + onlyPluginIds?: readonly string[]; activate?: boolean; cache?: boolean; }): PluginWebSearchProviderEntry[] { @@ -99,6 +114,7 @@ export function resolvePluginWebSearchProviders(params: { config: cacheOwnerConfig, workspaceDir: params.workspaceDir, bundledAllowlistCompat: params.bundledAllowlistCompat, + onlyPluginIds: params.onlyPluginIds, env, }); if (cacheOwnerConfig && shouldMemoizeSnapshot) { @@ -117,6 +133,7 @@ export function resolvePluginWebSearchProviders(params: { config, workspaceDir: params.workspaceDir, env, + onlyPluginIds: params.onlyPluginIds, }); const registry = loadOpenClawPlugins({ config, @@ -162,14 +179,19 @@ export function resolveRuntimeWebSearchProviders(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; bundledAllowlistCompat?: boolean; + onlyPluginIds?: readonly string[]; }): PluginWebSearchProviderEntry[] { const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? []; + const onlyPluginIdSet = + params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null; if (runtimeProviders.length > 0) { return sortWebSearchProviders( - runtimeProviders.map((entry) => ({ - ...entry.provider, - pluginId: entry.pluginId, - })), + runtimeProviders + .filter((entry) => !onlyPluginIdSet || onlyPluginIdSet.has(entry.pluginId)) + .map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })), ); } return resolvePluginWebSearchProviders(params); diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 0fde3bff8e2..ae5bf04846e 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -96,7 +96,7 @@ describe("resolveBundledPluginWebSearchProviders", () => { expect(providers).toEqual([]); }); - it("can resolve bundled providers without the plugin loader", () => { + it("can resolve bundled providers through the manifest-scoped loader path", () => { const providers = resolveBundledPluginWebSearchProviders({ bundledAllowlistCompat: true, });