fix(ci): stabilize bundled capability contract loading

This commit is contained in:
Peter Steinberger
2026-03-28 07:26:19 +00:00
parent f36354e401
commit 71a3ad153a
15 changed files with 873 additions and 95 deletions

View File

@@ -0,0 +1,79 @@
import { normalizeProviderId } from "./provider-id.js";
function normalizeAnthropicModelId(model: string): string {
const trimmed = model.trim();
if (!trimmed) {
return trimmed;
}
switch (trimmed.toLowerCase()) {
case "opus-4.6":
return "claude-opus-4-6";
case "opus-4.5":
return "claude-opus-4-5";
case "sonnet-4.6":
return "claude-sonnet-4-6";
case "sonnet-4.5":
return "claude-sonnet-4-5";
default:
return trimmed;
}
}
function normalizeStaticProviderModelId(provider: string, model: string): string {
if (provider === "anthropic") {
return normalizeAnthropicModelId(model);
}
if (provider === "vercel-ai-gateway" && !model.includes("/")) {
const normalizedAnthropicModel = normalizeAnthropicModelId(model);
if (normalizedAnthropicModel.startsWith("claude-")) {
return `anthropic/${normalizedAnthropicModel}`;
}
}
if (provider === "openrouter" && !model.includes("/")) {
return `openrouter/${model}`;
}
return model;
}
function modelKey(provider: string, model: string): string {
const providerId = provider.trim();
const modelId = model.trim();
if (!providerId) {
return modelId;
}
if (!modelId) {
return providerId;
}
return modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`)
? modelId
: `${providerId}/${modelId}`;
}
function parseStaticModelRef(
raw: string,
defaultProvider: string,
): { provider: string; model: string } | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const slash = trimmed.indexOf("/");
const providerRaw = slash === -1 ? defaultProvider : trimmed.slice(0, slash).trim();
const modelRaw = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
if (!providerRaw || !modelRaw) {
return null;
}
const provider = normalizeProviderId(providerRaw);
return {
provider,
model: normalizeStaticProviderModelId(provider, modelRaw),
};
}
export function resolveAllowlistModelKey(raw: string, defaultProvider: string): string | null {
const parsed = parseStaticModelRef(raw, defaultProvider);
if (!parsed) {
return null;
}
return modelKey(parsed.provider, parsed.model);
}

View File

@@ -13,22 +13,36 @@ type GeneratedBundledChannelEntry = {
};
};
function coerceGeneratedBundledChannelEntries(
value: unknown,
): readonly GeneratedBundledChannelEntry[] {
return Array.isArray(value) ? (value as readonly GeneratedBundledChannelEntry[]) : [];
function isGeneratedBundledChannelEntry(value: unknown): value is GeneratedBundledChannelEntry {
if (!value || typeof value !== "object") {
return false;
}
const record = value as {
id?: unknown;
entry?: {
channelPlugin?: { id?: unknown };
setChannelRuntime?: unknown;
};
setupEntry?: { plugin?: { id?: unknown } };
};
return typeof record.id === "string" && typeof record.entry?.channelPlugin?.id === "string";
}
const generatedBundledChannelEntries = coerceGeneratedBundledChannelEntries(
GENERATED_BUNDLED_CHANNEL_ENTRIES,
);
const generatedBundledChannelEntries = (
Array.isArray(GENERATED_BUNDLED_CHANNEL_ENTRIES)
? GENERATED_BUNDLED_CHANNEL_ENTRIES.filter(isGeneratedBundledChannelEntry)
: []
) as readonly GeneratedBundledChannelEntry[];
export const bundledChannelPlugins = generatedBundledChannelEntries.map(
({ entry }) => entry.channelPlugin,
);
export const bundledChannelSetupPlugins = generatedBundledChannelEntries.flatMap(({ setupEntry }) =>
setupEntry ? [setupEntry.plugin] : [],
export const bundledChannelSetupPlugins = generatedBundledChannelEntries.flatMap(
({ setupEntry }) => {
const plugin = setupEntry?.plugin;
return plugin ? [plugin] : [];
},
);
function buildBundledChannelPluginsById(plugins: readonly ChannelPlugin[]) {

View File

@@ -1,21 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
vi.doUnmock("../channels/plugins/bundled.js");
vi.resetModules();
});
describe("bundled channel config runtime", () => {
it("falls back to static channel schemas when bundled plugin mocks omit the plugin list", async () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("../channels/plugins/bundled.js");
});
it("tolerates an unavailable bundled channel list during import", async () => {
vi.doMock("../channels/plugins/bundled.js", () => ({
bundledChannelPlugins: undefined,
get bundledChannelPlugins() {
return undefined;
},
}));
const runtime = await import("./bundled-channel-config-runtime.js");
const configSchemaMap = runtime.getBundledChannelConfigSchemaMap();
const runtimeModule = await import("./bundled-channel-config-runtime.js");
expect(configSchemaMap.has("msteams")).toBe(true);
expect(configSchemaMap.has("whatsapp")).toBe(true);
expect(runtimeModule.getBundledChannelConfigSchemaMap().get("msteams")).toBeDefined();
expect(runtimeModule.getBundledChannelRuntimeMap().get("msteams")).toBeDefined();
});
});

View File

@@ -10,55 +10,91 @@ import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
type BundledChannelRuntimeMap = ReadonlyMap<string, ChannelConfigRuntimeSchema>;
type BundledChannelConfigSchemaMap = ReadonlyMap<string, ChannelConfigSchema>;
type BundledChannelPluginShape = {
id: string;
configSchema?: ChannelConfigSchema;
};
type BundledChannelMaps = {
runtimeMap: Map<string, ChannelConfigRuntimeSchema>;
configSchemaMap: Map<string, ChannelConfigSchema>;
};
const bundledChannelRuntimeMap = new Map<string, ChannelConfigRuntimeSchema>();
const bundledChannelConfigSchemaMap = new Map<string, ChannelConfigSchema>();
const staticBundledChannelSchemas = new Map<string, ChannelConfigSchema>([
["msteams", buildChannelConfigSchema(MSTeamsConfigSchema)],
["whatsapp", buildChannelConfigSchema(WhatsAppConfigSchema)],
]);
const configuredBundledChannelPlugins = Array.isArray(bundledChannelModule.bundledChannelPlugins)
? bundledChannelModule.bundledChannelPlugins
: [];
let cachedBundledChannelMaps: BundledChannelMaps | undefined;
for (const plugin of configuredBundledChannelPlugins) {
const channelSchema = plugin.configSchema;
if (!channelSchema) {
continue;
}
bundledChannelConfigSchemaMap.set(plugin.id, channelSchema);
if (channelSchema.runtime) {
bundledChannelRuntimeMap.set(plugin.id, channelSchema.runtime);
}
}
for (const entry of BUNDLED_PLUGIN_METADATA) {
const channelConfigs = entry.manifest.channelConfigs;
if (!channelConfigs) {
continue;
}
for (const [channelId, channelConfig] of Object.entries(channelConfigs)) {
const channelSchema = channelConfig?.schema as Record<string, unknown> | undefined;
function buildBundledChannelMaps(
plugins: readonly BundledChannelPluginShape[],
): BundledChannelMaps {
const runtimeMap = new Map<string, ChannelConfigRuntimeSchema>();
const configSchemaMap = new Map<string, ChannelConfigSchema>();
for (const plugin of plugins) {
const channelSchema = plugin.configSchema;
if (!channelSchema) {
continue;
}
if (!bundledChannelConfigSchemaMap.has(channelId)) {
bundledChannelConfigSchemaMap.set(channelId, { schema: channelSchema });
configSchemaMap.set(plugin.id, channelSchema);
if (channelSchema.runtime) {
runtimeMap.set(plugin.id, channelSchema.runtime);
}
}
for (const entry of BUNDLED_PLUGIN_METADATA) {
const channelConfigs = entry.manifest.channelConfigs;
if (!channelConfigs) {
continue;
}
for (const [channelId, channelConfig] of Object.entries(channelConfigs)) {
const channelSchema = channelConfig?.schema as Record<string, unknown> | undefined;
if (!channelSchema) {
continue;
}
if (!configSchemaMap.has(channelId)) {
configSchemaMap.set(channelId, { schema: channelSchema });
}
}
}
for (const [channelId, channelSchema] of staticBundledChannelSchemas) {
if (!configSchemaMap.has(channelId)) {
configSchemaMap.set(channelId, channelSchema);
}
if (channelSchema.runtime && !runtimeMap.has(channelId)) {
runtimeMap.set(channelId, channelSchema.runtime);
}
}
return { runtimeMap, configSchemaMap };
}
for (const [channelId, channelSchema] of staticBundledChannelSchemas) {
if (!bundledChannelConfigSchemaMap.has(channelId)) {
bundledChannelConfigSchemaMap.set(channelId, channelSchema);
function readBundledChannelPlugins(): readonly BundledChannelPluginShape[] | undefined {
return Array.isArray(bundledChannelModule.bundledChannelPlugins)
? (bundledChannelModule.bundledChannelPlugins as readonly BundledChannelPluginShape[])
: undefined;
}
function getBundledChannelMaps(): BundledChannelMaps {
const plugins = readBundledChannelPlugins();
if (plugins && cachedBundledChannelMaps) {
return cachedBundledChannelMaps;
}
if (channelSchema.runtime && !bundledChannelRuntimeMap.has(channelId)) {
bundledChannelRuntimeMap.set(channelId, channelSchema.runtime);
const maps = buildBundledChannelMaps(plugins ?? []);
// Tests and some import cycles can temporarily expose an incomplete bundled list.
// Only cache once the exported plugin array is actually available.
if (plugins) {
cachedBundledChannelMaps = maps;
}
return maps;
}
export function getBundledChannelRuntimeMap(): BundledChannelRuntimeMap {
return bundledChannelRuntimeMap;
return getBundledChannelMaps().runtimeMap;
}
export function getBundledChannelConfigSchemaMap(): BundledChannelConfigSchemaMap {
return bundledChannelConfigSchemaMap;
return getBundledChannelMaps().configSchemaMap;
}

View File

@@ -1,12 +1,58 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { createCapturedPluginRegistration } from "./captured-registration.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import type { PluginRecord, PluginRegistry } from "./registry.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
shouldPreferNativeJiti,
type PluginSdkResolutionPreference,
} from "./sdk-alias.js";
import type { OpenClawPluginDefinition, OpenClawPluginModule } from "./types.js";
const log = createSubsystemLogger("plugins");
function applyVitestCapabilityAliasOverrides(params: {
aliasMap: Record<string, string>;
pluginSdkResolution?: PluginSdkResolutionPreference;
env?: PluginLoadOptions["env"];
}): Record<string, string> {
if (!params.env?.VITEST || params.pluginSdkResolution !== "dist") {
return params.aliasMap;
}
const { ["openclaw/plugin-sdk"]: _ignoredRootAlias, ...scopedAliasMap } = params.aliasMap;
return {
...scopedAliasMap,
// Capability contract loads only need a narrow SDK slice. Keep those
// helpers on a tiny source graph so Vitest does not pull the dist chunk
// bundle that also drags Matrix/WhatsApp code into these tests.
"openclaw/plugin-sdk/llm-task": fileURLToPath(
new URL("./capability-runtime-vitest-shims/llm-task.ts", import.meta.url),
),
"openclaw/plugin-sdk/media-runtime": fileURLToPath(
new URL("./capability-runtime-vitest-shims/media-runtime.ts", import.meta.url),
),
"openclaw/plugin-sdk/provider-onboard": fileURLToPath(
new URL("../plugin-sdk/provider-onboard.ts", import.meta.url),
),
"openclaw/plugin-sdk/speech-core": fileURLToPath(
new URL("./capability-runtime-vitest-shims/speech-core.ts", import.meta.url),
),
};
}
export function buildBundledCapabilityRuntimeConfig(
pluginIds: readonly string[],
env?: PluginLoadOptions["env"],
@@ -22,22 +68,285 @@ export function buildBundledCapabilityRuntimeConfig(
});
}
function resolvePluginModuleExport(moduleExport: unknown): {
definition?: OpenClawPluginDefinition;
register?: OpenClawPluginDefinition["register"];
} {
const resolved =
moduleExport &&
typeof moduleExport === "object" &&
"default" in (moduleExport as Record<string, unknown>)
? (moduleExport as { default: unknown }).default
: moduleExport;
if (typeof resolved === "function") {
return {
register: resolved as OpenClawPluginDefinition["register"],
};
}
if (resolved && typeof resolved === "object") {
const definition = resolved as OpenClawPluginDefinition;
return {
definition,
register: definition.register ?? definition.activate,
};
}
return {};
}
function createCapabilityPluginRecord(params: {
id: string;
name?: string;
description?: string;
version?: string;
source: string;
rootDir?: string;
workspaceDir?: string;
}): PluginRecord {
return {
id: params.id,
name: params.name ?? params.id,
version: params.version,
description: params.description,
source: params.source,
rootDir: params.rootDir,
origin: "bundled",
workspaceDir: params.workspaceDir,
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
cliBackendIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: true,
};
}
function recordCapabilityLoadError(
registry: PluginRegistry,
record: PluginRecord,
message: string,
): void {
record.status = "error";
record.error = message;
registry.plugins.push(record);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: `failed to load plugin: ${message}`,
});
log.error(`[plugins] ${record.id} failed to load from ${record.source}: ${message}`);
}
export function loadBundledCapabilityRuntimeRegistry(params: {
pluginIds: readonly string[];
env?: PluginLoadOptions["env"];
pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"];
pluginSdkResolution?: PluginSdkResolutionPreference;
}) {
return loadOpenClawPlugins({
config: buildBundledCapabilityRuntimeConfig(params.pluginIds, params.env),
env: params.env,
onlyPluginIds: [...params.pluginIds],
pluginSdkResolution: params.pluginSdkResolution,
const env = params.env ?? process.env;
const pluginIds = new Set(params.pluginIds);
const registry = createEmptyPluginRegistry();
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
const getJiti = (modulePath: string) => {
const tryNative =
shouldPreferNativeJiti(modulePath) && !(env?.VITEST && params.pluginSdkResolution === "dist");
const aliasMap = applyVitestCapabilityAliasOverrides({
aliasMap: buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
params.pluginSdkResolution,
),
pluginSdkResolution: params.pluginSdkResolution,
env,
});
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
};
const discovery = discoverOpenClawPlugins({
cache: false,
activate: false,
logger: {
info: (message) => log.info(message),
warn: (message) => log.warn(message),
error: (message) => log.error(message),
},
env,
});
const manifestRegistry = loadPluginManifestRegistry({
config: buildBundledCapabilityRuntimeConfig(params.pluginIds, env),
cache: false,
env,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
registry.diagnostics.push(...manifestRegistry.diagnostics);
const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
);
const seenPluginIds = new Set<string>();
for (const candidate of discovery.candidates) {
const manifest = manifestByRoot.get(candidate.rootDir);
if (!manifest || manifest.origin !== "bundled" || !pluginIds.has(manifest.id)) {
continue;
}
if (seenPluginIds.has(manifest.id)) {
continue;
}
seenPluginIds.add(manifest.id);
const record = createCapabilityPluginRecord({
id: manifest.id,
name: manifest.name,
description: manifest.description,
version: manifest.version,
source: candidate.source,
rootDir: candidate.rootDir,
workspaceDir: candidate.workspaceDir,
});
const opened = openBoundaryFileSync({
absolutePath: candidate.source,
rootPath: candidate.rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: false,
skipLexicalRootCheck: true,
});
if (!opened.ok) {
recordCapabilityLoadError(
registry,
record,
"plugin entry path escapes plugin root or fails alias checks",
);
continue;
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
let mod: OpenClawPluginModule | null = null;
try {
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
} catch (error) {
recordCapabilityLoadError(registry, record, String(error));
continue;
}
const resolved = resolvePluginModuleExport(mod);
const register = resolved.register;
if (typeof register !== "function") {
record.status = "disabled";
record.error = "plugin export missing register(api)";
registry.plugins.push(record);
continue;
}
try {
const captured = createCapturedPluginRegistration();
void register(captured.api);
record.cliBackendIds.push(...captured.cliBackends.map((entry) => entry.id));
record.providerIds.push(...captured.providers.map((entry) => entry.id));
record.speechProviderIds.push(...captured.speechProviders.map((entry) => entry.id));
record.mediaUnderstandingProviderIds.push(
...captured.mediaUnderstandingProviders.map((entry) => entry.id),
);
record.imageGenerationProviderIds.push(
...captured.imageGenerationProviders.map((entry) => entry.id),
);
record.webSearchProviderIds.push(...captured.webSearchProviders.map((entry) => entry.id));
record.toolNames.push(...captured.tools.map((entry) => entry.name));
registry.cliBackends?.push(
...captured.cliBackends.map((backend) => ({
pluginId: record.id,
pluginName: record.name,
backend,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.providers.push(
...captured.providers.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.speechProviders.push(
...captured.speechProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.mediaUnderstandingProviders.push(
...captured.mediaUnderstandingProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.imageGenerationProviders.push(
...captured.imageGenerationProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.webSearchProviders.push(
...captured.webSearchProviders.map((provider) => ({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.tools.push(
...captured.tools.map((tool) => ({
pluginId: record.id,
pluginName: record.name,
factory: () => tool,
names: [tool.name],
optional: false,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.plugins.push(record);
} catch (error) {
recordCapabilityLoadError(registry, record, String(error));
}
}
return registry;
}

View File

@@ -0,0 +1 @@
export { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";

View File

@@ -0,0 +1 @@
export { isVoiceCompatibleAudio } from "../../media/audio.js";

View File

@@ -0,0 +1,81 @@
import { rmSync } from "node:fs";
export type { SpeechProviderPlugin } from "../../plugins/types.js";
export type {
SpeechDirectiveTokenParseContext,
SpeechDirectiveTokenParseResult,
SpeechListVoicesRequest,
SpeechModelOverridePolicy,
SpeechProviderConfig,
SpeechProviderConfiguredContext,
SpeechProviderResolveConfigContext,
SpeechProviderResolveTalkConfigContext,
SpeechProviderResolveTalkOverridesContext,
SpeechProviderOverrides,
SpeechSynthesisRequest,
SpeechTelephonySynthesisRequest,
SpeechVoiceOption,
TtsDirectiveOverrides,
TtsDirectiveParseResult,
} from "../../tts/provider-types.js";
const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000;
export function requireInRange(value: number, min: number, max: number, label: string): void {
if (!Number.isFinite(value) || value < min || value > max) {
throw new Error(`${label} must be between ${min} and ${max}`);
}
}
export function normalizeLanguageCode(code?: string): string | undefined {
const trimmed = code?.trim();
if (!trimmed) {
return undefined;
}
const normalized = trimmed.toLowerCase();
if (!/^[a-z]{2}$/.test(normalized)) {
throw new Error("languageCode must be a 2-letter ISO 639-1 code (e.g. en, de, fr)");
}
return normalized;
}
export function normalizeApplyTextNormalization(mode?: string): "auto" | "on" | "off" | undefined {
const trimmed = mode?.trim();
if (!trimmed) {
return undefined;
}
const normalized = trimmed.toLowerCase();
if (normalized === "auto" || normalized === "on" || normalized === "off") {
return normalized;
}
throw new Error("applyTextNormalization must be one of: auto, on, off");
}
export function normalizeSeed(seed?: number): number | undefined {
if (seed == null) {
return undefined;
}
const next = Math.floor(seed);
if (!Number.isFinite(next) || next < 0 || next > 4_294_967_295) {
throw new Error("seed must be between 0 and 4294967295");
}
return next;
}
export function scheduleCleanup(
tempDir: string,
delayMs: number = TEMP_FILE_CLEANUP_DELAY_MS,
): void {
const timer = setTimeout(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}, delayMs);
timer.unref();
}
export async function summarizeText(): Promise<never> {
throw new Error("summarizeText is unavailable in the Vitest capability contract shim");
}

View File

@@ -15,6 +15,11 @@ import type {
SpeechProviderPlugin,
WebSearchProviderPlugin,
} from "../types.js";
import {
loadVitestImageGenerationProviderContractRegistry,
loadVitestMediaUnderstandingProviderContractRegistry,
loadVitestSpeechProviderContractRegistry,
} from "./speech-vitest-registry.js";
type CapabilityContractEntry<T> = {
pluginId: string;
@@ -172,46 +177,45 @@ function loadWebSearchProviderContractRegistry(): WebSearchProviderContractEntry
function loadSpeechProviderContractRegistry(): SpeechProviderContractEntry[] {
if (!speechProviderContractRegistryCache) {
const registry = loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_SPEECH_PLUGIN_IDS,
pluginSdkResolution: "dist",
});
speechProviderContractRegistryCache = registry.speechProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
speechProviderContractRegistryCache = process.env.VITEST
? loadVitestSpeechProviderContractRegistry()
: loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_SPEECH_PLUGIN_IDS,
pluginSdkResolution: "dist",
}).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,
pluginSdkResolution: "dist",
});
mediaUnderstandingProviderContractRegistryCache = registry.mediaUnderstandingProviders.map(
(entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}),
);
mediaUnderstandingProviderContractRegistryCache = process.env.VITEST
? loadVitestMediaUnderstandingProviderContractRegistry()
: loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS,
pluginSdkResolution: "dist",
}).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,
pluginSdkResolution: "dist",
});
imageGenerationProviderContractRegistryCache = registry.imageGenerationProviders.map(
(entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}),
);
imageGenerationProviderContractRegistryCache = process.env.VITEST
? loadVitestImageGenerationProviderContractRegistry()
: loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_IMAGE_GENERATION_PLUGIN_IDS,
pluginSdkResolution: "dist",
}).imageGenerationProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
}
return imageGenerationProviderContractRegistryCache;
}

View File

@@ -0,0 +1,236 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import {
BUNDLED_IMAGE_GENERATION_PLUGIN_IDS,
BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS,
BUNDLED_SPEECH_PLUGIN_IDS,
} from "../bundled-capability-metadata.js";
import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js";
import { loadPluginManifestRegistry } from "../manifest-registry.js";
import { buildPluginLoaderAliasMap, buildPluginLoaderJitiOptions } from "../sdk-alias.js";
import type {
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
SpeechProviderPlugin,
} from "../types.js";
export type SpeechProviderContractEntry = {
pluginId: string;
provider: SpeechProviderPlugin;
};
export type MediaUnderstandingProviderContractEntry = {
pluginId: string;
provider: MediaUnderstandingProviderPlugin;
};
export type ImageGenerationProviderContractEntry = {
pluginId: string;
provider: ImageGenerationProviderPlugin;
};
function buildVitestCapabilityAliasMap(modulePath: string): Record<string, string> {
const { ["openclaw/plugin-sdk"]: _ignoredRootAlias, ...scopedAliasMap } =
buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url, "dist");
return {
...scopedAliasMap,
"openclaw/plugin-sdk/llm-task": fileURLToPath(
new URL("../capability-runtime-vitest-shims/llm-task.ts", import.meta.url),
),
"openclaw/plugin-sdk/media-runtime": fileURLToPath(
new URL("../capability-runtime-vitest-shims/media-runtime.ts", import.meta.url),
),
"openclaw/plugin-sdk/provider-onboard": fileURLToPath(
new URL("../../plugin-sdk/provider-onboard.ts", import.meta.url),
),
"openclaw/plugin-sdk/speech-core": fileURLToPath(
new URL("../capability-runtime-vitest-shims/speech-core.ts", import.meta.url),
),
};
}
function resolveNamedBuilder<T>(moduleExport: unknown, pattern: RegExp): (() => T) | undefined {
if (!moduleExport || typeof moduleExport !== "object") {
return undefined;
}
for (const [key, value] of Object.entries(moduleExport as Record<string, unknown>)) {
if (pattern.test(key) && typeof value === "function") {
return value as () => T;
}
}
return undefined;
}
function resolveNamedValues<T>(
moduleExport: unknown,
pattern: RegExp,
isMatch: (value: unknown) => value is T,
): T[] {
if (!moduleExport || typeof moduleExport !== "object") {
return [];
}
const matches: T[] = [];
for (const [key, value] of Object.entries(moduleExport as Record<string, unknown>)) {
if (pattern.test(key) && isMatch(value)) {
matches.push(value);
}
}
return matches;
}
function resolveTestApiModuleRecords(pluginIds: readonly string[]) {
const unresolvedPluginIds = new Set(pluginIds);
const manifests = loadPluginManifestRegistry({}).plugins.filter(
(plugin) => plugin.origin === "bundled" && unresolvedPluginIds.has(plugin.id),
);
return { manifests, unresolvedPluginIds };
}
function createVitestCapabilityLoader(modulePath: string) {
return createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(buildVitestCapabilityAliasMap(modulePath)),
tryNative: false,
});
}
function isMediaUnderstandingProvider(value: unknown): value is MediaUnderstandingProviderPlugin {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { id?: unknown }).id === "string" &&
typeof (value as { describeImage?: unknown }).describeImage === "function"
);
}
export function loadVitestSpeechProviderContractRegistry(): SpeechProviderContractEntry[] {
const registrations: SpeechProviderContractEntry[] = [];
const { manifests, unresolvedPluginIds } = resolveTestApiModuleRecords(BUNDLED_SPEECH_PLUGIN_IDS);
for (const plugin of manifests) {
if (!plugin.rootDir) {
continue;
}
const testApiPath = path.join(plugin.rootDir, "test-api.ts");
if (!fs.existsSync(testApiPath)) {
continue;
}
const builder = resolveNamedBuilder<SpeechProviderPlugin>(
createVitestCapabilityLoader(testApiPath)(testApiPath),
/^build.+SpeechProvider$/u,
);
if (!builder) {
continue;
}
registrations.push({
pluginId: plugin.id,
provider: builder(),
});
unresolvedPluginIds.delete(plugin.id);
}
if (unresolvedPluginIds.size === 0) {
return registrations;
}
const runtimeRegistry = loadBundledCapabilityRuntimeRegistry({
pluginIds: [...unresolvedPluginIds],
pluginSdkResolution: "dist",
});
registrations.push(
...runtimeRegistry.speechProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
})),
);
return registrations;
}
export function loadVitestMediaUnderstandingProviderContractRegistry(): MediaUnderstandingProviderContractEntry[] {
const registrations: MediaUnderstandingProviderContractEntry[] = [];
const { manifests, unresolvedPluginIds } = resolveTestApiModuleRecords(
BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS,
);
for (const plugin of manifests) {
if (!plugin.rootDir) {
continue;
}
const testApiPath = path.join(plugin.rootDir, "test-api.ts");
if (!fs.existsSync(testApiPath)) {
continue;
}
const providers = resolveNamedValues<MediaUnderstandingProviderPlugin>(
createVitestCapabilityLoader(testApiPath)(testApiPath),
/MediaUnderstandingProvider$/u,
isMediaUnderstandingProvider,
);
if (providers.length === 0) {
continue;
}
registrations.push(...providers.map((provider) => ({ pluginId: plugin.id, provider })));
unresolvedPluginIds.delete(plugin.id);
}
if (unresolvedPluginIds.size === 0) {
return registrations;
}
const runtimeRegistry = loadBundledCapabilityRuntimeRegistry({
pluginIds: [...unresolvedPluginIds],
pluginSdkResolution: "dist",
});
registrations.push(
...runtimeRegistry.mediaUnderstandingProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
})),
);
return registrations;
}
export function loadVitestImageGenerationProviderContractRegistry(): ImageGenerationProviderContractEntry[] {
const registrations: ImageGenerationProviderContractEntry[] = [];
const { manifests, unresolvedPluginIds } = resolveTestApiModuleRecords(
BUNDLED_IMAGE_GENERATION_PLUGIN_IDS,
);
for (const plugin of manifests) {
if (!plugin.rootDir) {
continue;
}
const testApiPath = path.join(plugin.rootDir, "test-api.ts");
if (!fs.existsSync(testApiPath)) {
continue;
}
const builder = resolveNamedBuilder<ImageGenerationProviderPlugin>(
createVitestCapabilityLoader(testApiPath)(testApiPath),
/^build.+ImageGenerationProvider$/u,
);
if (!builder) {
continue;
}
registrations.push({
pluginId: plugin.id,
provider: builder(),
});
unresolvedPluginIds.delete(plugin.id);
}
if (unresolvedPluginIds.size === 0) {
return registrations;
}
const runtimeRegistry = loadBundledCapabilityRuntimeRegistry({
pluginIds: [...unresolvedPluginIds],
pluginSdkResolution: "dist",
});
registrations.push(
...runtimeRegistry.imageGenerationProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
})),
);
return registrations;
}

View File

@@ -1,5 +1,5 @@
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveAllowlistModelKey } from "../agents/model-selection.js";
import { resolveAllowlistModelKey } from "../agents/model-allowlist-ref.js";
import type { OpenClawConfig } from "../config/config.js";
export function ensureModelAllowlistEntry(params: {