refactor: derive bundled contracts from extension manifests

This commit is contained in:
Peter Steinberger
2026-03-27 01:16:11 +00:00
parent b75be09144
commit ba7804df50
36 changed files with 640 additions and 422 deletions

View File

@@ -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<string, string[]>` | 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:

View File

@@ -41,6 +41,9 @@
"cliDescription": "Anthropic API key"
}
],
"contracts": {
"mediaUnderstandingProviders": ["anthropic"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -15,6 +15,9 @@
"help": "Brave Search mode: web or llm-context."
}
},
"contracts": {
"webSearchProviders": ["brave"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,9 @@
{
"id": "deepgram",
"mediaUnderstandingProviders": ["deepgram"],
"contracts": {
"mediaUnderstandingProviders": ["deepgram"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -10,6 +10,9 @@
"help": "SafeSearch level for DuckDuckGo results."
}
},
"contracts": {
"webSearchProviders": ["duckduckgo"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,9 @@
{
"id": "elevenlabs",
"speechProviders": ["elevenlabs"],
"contracts": {
"speechProviders": ["elevenlabs"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -11,6 +11,9 @@
"placeholder": "exa-..."
}
},
"contracts": {
"webSearchProviders": ["exa"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -21,6 +21,9 @@
"cliDescription": "fal API key"
}
],
"contracts": {
"imageGenerationProviders": ["fal"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -15,6 +15,10 @@
"help": "Firecrawl Search base URL override."
}
},
"contracts": {
"webSearchProviders": ["firecrawl"],
"tools": ["firecrawl_search", "firecrawl_scrape"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -44,6 +44,11 @@
"help": "Gemini model override for web search grounding."
}
},
"contracts": {
"mediaUnderstandingProviders": ["google"],
"imageGenerationProviders": ["google"],
"webSearchProviders": ["gemini"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,9 @@
{
"id": "groq",
"mediaUnderstandingProviders": ["groq"],
"contracts": {
"mediaUnderstandingProviders": ["groq"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,6 +1,9 @@
{
"id": "microsoft",
"speechProviders": ["microsoft"],
"contracts": {
"speechProviders": ["microsoft"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -57,6 +57,10 @@
"cliDescription": "MiniMax API key"
}
],
"contracts": {
"mediaUnderstandingProviders": ["minimax", "minimax-portal"],
"imageGenerationProviders": ["minimax", "minimax-portal"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -20,6 +20,9 @@
"cliDescription": "Mistral API key"
}
],
"contracts": {
"mediaUnderstandingProviders": ["mistral"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -48,6 +48,10 @@
"help": "Kimi model override."
}
},
"contracts": {
"mediaUnderstandingProviders": ["moonshot"],
"webSearchProviders": ["kimi"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -33,6 +33,11 @@
"cliDescription": "OpenAI API key"
}
],
"contracts": {
"speechProviders": ["openai"],
"mediaUnderstandingProviders": ["openai", "openai-codex"],
"imageGenerationProviders": ["openai"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -19,6 +19,9 @@
"help": "Optional Sonar/OpenRouter model override."
}
},
"contracts": {
"webSearchProviders": ["perplexity"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -16,6 +16,10 @@
"help": "Tavily API base URL override."
}
},
"contracts": {
"webSearchProviders": ["tavily"],
"tools": ["tavily_search", "tavily_extract"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -34,6 +34,9 @@
"help": "Include inline markdown citations in Grok responses."
}
},
"contracts": {
"webSearchProviders": ["grok"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -76,6 +76,9 @@
"cliDescription": "Z.AI API key"
}
],
"contracts": {
"mediaUnderstandingProviders": ["zai"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -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) }
: {}),
};
}

View File

@@ -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",
},
];

View File

@@ -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<string>();
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<Record<string, string>>;

View File

@@ -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),
},
});
}

View File

@@ -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"],
},
},
},
{

View File

@@ -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];

View File

@@ -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<string, string>;
import { BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS } from "./bundled-capability-metadata.js";
export function resolveBundledWebSearchPluginId(
providerId: string | undefined,

View File

@@ -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(

View File

@@ -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<string>(BUNDLED_WEB_SEARCH_PLUGIN_IDS);
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const bundledWebSearchPluginIdSet = new Set<string>(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[] {

View File

@@ -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),
);

View File

@@ -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<typeof createCapturedPluginRegistration>["api"]) => void;
};
type CapabilityContractEntry<T> = {
pluginId: string;
provider: T;
@@ -42,42 +43,140 @@ type PluginRegistrationContractEntry = {
toolNames: string[];
};
const bundledWebSearchPlugins: Array<RegistrablePlugin & { credentialValue: unknown }> =
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<string>();
for (const value of values) {
if (seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
return result;
}
function buildCapabilityContractRegistry<T>(params: {
plugins: RegistrablePlugin[];
select: (captured: ReturnType<typeof createCapturedPluginRegistration>) => T[];
}): CapabilityContractEntry<T>[] {
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<T extends RegistrablePlugin>(
plugins: ReadonlyArray<T | undefined | null>,
): 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<T>(load: () => T[]): T[] {
return new Proxy([] as T[], {
@@ -111,55 +210,6 @@ function createLazyArrayView<T>(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<typeof loadPluginManifestRegistry>["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<typeof loadPluginManifestRegistry>["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[] =

View File

@@ -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<ProviderRuntimeModel> & Pick<ProviderRun
}
function requireProviderContractProvider(providerId: string): ProviderPlugin {
if (providerId === "openai-codex") {
return requireProvider(registerProviders(openAIPlugin), providerId);
}
return requireBundledProviderContractProvider(providerId);
}

View File

@@ -6,7 +6,11 @@ import { resolveRuntimeServiceVersion } from "../version.js";
import { loadBundleManifest } from "./bundle-manifest.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
import {
loadPluginManifest,
type PluginManifest,
type PluginManifestContracts,
} from "./manifest.js";
import { checkMinHostVersion } from "./min-host-version.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
import { resolvePluginCacheInputs } from "./roots.js";
@@ -64,6 +68,7 @@ export type PluginManifestRecord = {
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
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: {

View File

@@ -32,6 +32,19 @@ export type PluginManifest = {
description?: string;
version?: string;
uiHints?: Record<string, PluginConfigUiHint>;
/**
* 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<string, string[]> | 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<string, PluginConfigUiHint> | undefined;
if (isRecord(raw.uiHints)) {
@@ -241,6 +279,7 @@ export function loadPluginManifest(
description,
version,
uiHints,
contracts,
},
manifestPath,
};

View File

@@ -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);

View File

@@ -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,
});