mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 03:20:20 +00:00
fix(ci): stabilize bundled capability contract loading
This commit is contained in:
79
src/agents/model-allowlist-ref.ts
Normal file
79
src/agents/model-allowlist-ref.ts
Normal 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);
|
||||
}
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1
src/plugins/capability-runtime-vitest-shims/llm-task.ts
Normal file
1
src/plugins/capability-runtime-vitest-shims/llm-task.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { isVoiceCompatibleAudio } from "../../media/audio.js";
|
||||
81
src/plugins/capability-runtime-vitest-shims/speech-core.ts
Normal file
81
src/plugins/capability-runtime-vitest-shims/speech-core.ts
Normal 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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
236
src/plugins/contracts/speech-vitest-registry.ts
Normal file
236
src/plugins/contracts/speech-vitest-registry.ts
Normal 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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user