diff --git a/src/plugins/bundled-runtime-deps-selection.ts b/src/plugins/bundled-runtime-deps-selection.ts index a3ec086c535..a72b376602e 100644 --- a/src/plugins/bundled-runtime-deps-selection.ts +++ b/src/plugins/bundled-runtime-deps-selection.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; @@ -29,11 +30,17 @@ export type BundledPluginRuntimeDepsManifest = { enabledByDefault: boolean; id?: string; legacyPluginIds: string[]; + modelSupport?: BundledPluginRuntimeDepsModelSupport; providers: string[]; }; export type BundledPluginRuntimeDepsManifestCache = Map; +type BundledPluginRuntimeDepsModelSupport = { + modelPatterns: string[]; + modelPrefixes: string[]; +}; + function collectDeclaredMirroredRootRuntimeDepNames(packageJson: JsonObject): string[] { const openclaw = packageJson.openclaw; const bundle = @@ -103,6 +110,7 @@ function readBundledPluginRuntimeDepsManifest( const manifest = readRuntimeDepsJsonObject(path.join(pluginDir, "openclaw.plugin.json")); const channels = manifest?.channels; const legacyPluginIds = manifest?.legacyPluginIds; + const modelSupport = readBundledPluginRuntimeDepsModelSupport(manifest?.modelSupport); const providers = manifest?.providers; const runtimeDepsManifest = { channels: Array.isArray(channels) @@ -115,6 +123,7 @@ function readBundledPluginRuntimeDepsManifest( (entry): entry is string => typeof entry === "string" && entry !== "", ) : [], + ...(modelSupport ? { modelSupport } : {}), providers: Array.isArray(providers) ? providers.filter((entry): entry is string => typeof entry === "string" && entry !== "") : [], @@ -123,6 +132,27 @@ function readBundledPluginRuntimeDepsManifest( return runtimeDepsManifest; } +function readBundledPluginRuntimeDepsModelSupport( + value: unknown, +): BundledPluginRuntimeDepsModelSupport | undefined { + if (!isRecord(value)) { + return undefined; + } + const modelPatterns = readRuntimeDepsManifestStringList(value.modelPatterns); + const modelPrefixes = readRuntimeDepsManifestStringList(value.modelPrefixes); + if (modelPatterns.length === 0 && modelPrefixes.length === 0) { + return undefined; + } + return { modelPatterns, modelPrefixes }; +} + +function readRuntimeDepsManifestStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((entry): entry is string => typeof entry === "string" && entry !== ""); +} + const BUILT_IN_RUNTIME_DEPS_PLUGIN_ALIAS_FALLBACKS: ReadonlyArray< readonly [alias: string, pluginId: string] > = [ @@ -212,69 +242,176 @@ function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function addConfiguredProviderId(providerIds: Set, value: unknown): void { +type ConfiguredRuntimeDepsTargets = { + modelRefs: Set; + providerIds: Set; +}; + +function createConfiguredRuntimeDepsTargets(): ConfiguredRuntimeDepsTargets { + return { + modelRefs: new Set(), + providerIds: new Set(), + }; +} + +function addConfiguredProviderId(targets: ConfiguredRuntimeDepsTargets, value: unknown): void { if (typeof value !== "string") { return; } const normalized = normalizeProviderId(value); if (normalized) { - providerIds.add(normalized); + targets.providerIds.add(normalized); } } -function addConfiguredProviderFromModelRef(providerIds: Set, value: unknown): void { +function addConfiguredModelRef(targets: ConfiguredRuntimeDepsTargets, value: unknown): void { if (typeof value !== "string") { return; } - const providerId = value.split("/", 1)[0]?.trim(); - addConfiguredProviderId(providerIds, providerId); + const parsed = parseConfiguredModelRef(value); + if (!parsed) { + return; + } + if (parsed.providerId) { + targets.providerIds.add(parsed.providerId); + } else { + targets.modelRefs.add(parsed.modelId); + } } -function addConfiguredProvidersFromModelConfig(providerIds: Set, value: unknown): void { +function parseConfiguredModelRef( + value: string, +): { modelId: string; providerId?: string } | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const slash = trimmed.indexOf("/"); + if (slash < 0) { + const modelId = splitTrailingAuthProfile(trimmed).model.trim(); + return modelId ? { modelId } : undefined; + } + const providerId = normalizeProviderId(trimmed.slice(0, slash)); + const modelId = splitTrailingAuthProfile(trimmed.slice(slash + 1)).model.trim(); + if (!providerId || !modelId) { + return undefined; + } + return { providerId, modelId }; +} + +function addConfiguredModelsFromModelConfig( + targets: ConfiguredRuntimeDepsTargets, + value: unknown, +): void { if (typeof value === "string") { - addConfiguredProviderFromModelRef(providerIds, value); + addConfiguredModelRef(targets, value); return; } if (!isRecord(value)) { return; } - addConfiguredProviderFromModelRef(providerIds, value.primary); + addConfiguredModelRef(targets, value.primary); if (Array.isArray(value.fallbacks)) { for (const fallback of value.fallbacks) { - addConfiguredProviderFromModelRef(providerIds, fallback); + addConfiguredModelRef(targets, fallback); } } } -function collectConfiguredProviderIds(config: OpenClawConfig): Set { - const providerIds = new Set(); +function collectConfiguredRuntimeDepsTargets(config: OpenClawConfig): ConfiguredRuntimeDepsTargets { + const targets = createConfiguredRuntimeDepsTargets(); for (const providerId of Object.keys(config.models?.providers ?? {})) { - addConfiguredProviderId(providerIds, providerId); + addConfiguredProviderId(targets, providerId); } for (const profile of Object.values(config.auth?.profiles ?? {})) { - addConfiguredProviderId(providerIds, profile.provider); + addConfiguredProviderId(targets, profile.provider); } for (const providerId of Object.keys(config.auth?.order ?? {})) { - addConfiguredProviderId(providerIds, providerId); + addConfiguredProviderId(targets, providerId); } const defaults = config.agents?.defaults; - addConfiguredProvidersFromModelConfig(providerIds, defaults?.model); - addConfiguredProvidersFromModelConfig(providerIds, defaults?.imageModel); - addConfiguredProvidersFromModelConfig(providerIds, defaults?.imageGenerationModel); - addConfiguredProvidersFromModelConfig(providerIds, defaults?.videoGenerationModel); - addConfiguredProvidersFromModelConfig(providerIds, defaults?.musicGenerationModel); - addConfiguredProvidersFromModelConfig(providerIds, defaults?.pdfModel); - addConfiguredProvidersFromModelConfig(providerIds, defaults?.subagents?.model); + addConfiguredModelsFromModelConfig(targets, defaults?.model); + addConfiguredModelsFromModelConfig(targets, defaults?.imageModel); + addConfiguredModelsFromModelConfig(targets, defaults?.imageGenerationModel); + addConfiguredModelsFromModelConfig(targets, defaults?.videoGenerationModel); + addConfiguredModelsFromModelConfig(targets, defaults?.musicGenerationModel); + addConfiguredModelsFromModelConfig(targets, defaults?.pdfModel); + addConfiguredModelsFromModelConfig(targets, defaults?.subagents?.model); for (const providerId of Object.keys(defaults?.models ?? {})) { - addConfiguredProviderFromModelRef(providerIds, providerId); + addConfiguredModelRef(targets, providerId); } for (const agent of config.agents?.list ?? []) { - addConfiguredProvidersFromModelConfig(providerIds, agent.model); - addConfiguredProvidersFromModelConfig(providerIds, agent.subagents?.model); + addConfiguredModelsFromModelConfig(targets, agent.model); + addConfiguredModelsFromModelConfig(targets, agent.subagents?.model); } - return providerIds; + return targets; +} + +function collectConfiguredProviderIds(config: OpenClawConfig): Set { + return collectConfiguredRuntimeDepsTargets(config).providerIds; +} + +function matchesBundledRuntimeDepsModelSupport( + manifest: BundledPluginRuntimeDepsManifest, + modelId: string, + kind: "pattern" | "prefix", +): boolean { + if (kind === "pattern") { + for (const patternSource of manifest.modelSupport?.modelPatterns ?? []) { + try { + if (new RegExp(patternSource, "u").test(modelId)) { + return true; + } + } catch { + continue; + } + } + return false; + } + return (manifest.modelSupport?.modelPrefixes ?? []).some((prefix) => modelId.startsWith(prefix)); +} + +export function resolveBundledRuntimeDepsConfiguredModelOwnerPluginIds(params: { + config: OpenClawConfig; + extensionsDir: string; + manifestCache?: BundledPluginRuntimeDepsManifestCache; +}): ReadonlySet { + const targets = collectConfiguredRuntimeDepsTargets(params.config); + if (targets.modelRefs.size === 0 || !fs.existsSync(params.extensionsDir)) { + return new Set(); + } + const plugins = fs + .readdirSync(params.extensionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const pluginDir = path.join(params.extensionsDir, entry.name); + return { + pluginId: entry.name, + manifest: readBundledPluginRuntimeDepsManifest(pluginDir, params.manifestCache), + }; + }); + const pluginIds = new Set(); + for (const modelId of targets.modelRefs) { + const patternMatches = plugins.filter(({ manifest }) => + matchesBundledRuntimeDepsModelSupport(manifest, modelId, "pattern"), + ); + if (patternMatches.length === 1) { + pluginIds.add(patternMatches[0].pluginId); + continue; + } + if (patternMatches.length > 1) { + continue; + } + const prefixMatches = plugins.filter(({ manifest }) => + matchesBundledRuntimeDepsModelSupport(manifest, modelId, "prefix"), + ); + if (prefixMatches.length === 1) { + pluginIds.add(prefixMatches[0].pluginId); + } + } + return pluginIds; } function isBundledProviderConfiguredForRuntimeDeps(params: { @@ -295,6 +432,7 @@ export function isBundledPluginConfiguredForRuntimeDeps(params: { plugins: NormalizedPluginsConfig; pluginId: string; pluginDir: string; + configuredModelOwnerPluginIds?: ReadonlySet; includeConfiguredChannels?: boolean; manifestCache?: BundledPluginRuntimeDepsManifestCache; }): boolean { @@ -363,6 +501,9 @@ export function isBundledPluginConfiguredForRuntimeDeps(params: { if (hasConfiguredChannel) { return true; } + if (params.configuredModelOwnerPluginIds?.has(params.pluginId)) { + return true; + } if ( isBundledProviderConfiguredForRuntimeDeps({ config: params.config, @@ -409,6 +550,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: { selectedPluginIds?: ReadonlySet; pluginId: string; pluginDir: string; + configuredModelOwnerPluginIds?: ReadonlySet; includeConfiguredChannels?: boolean; manifestCache?: BundledPluginRuntimeDepsManifestCache; }): boolean { @@ -458,6 +600,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: { plugins: params.plugins, pluginId: params.pluginId, pluginDir: params.pluginDir, + configuredModelOwnerPluginIds: params.configuredModelOwnerPluginIds, includeConfiguredChannels: params.includeConfiguredChannels, manifestCache: params.manifestCache, }); @@ -490,6 +633,14 @@ export function collectBundledPluginRuntimeDeps(params: { const plugins = params.config ? normalizePluginsConfigWithResolver(params.config.plugins, normalizePluginId) : undefined; + const configuredModelOwnerPluginIds = + params.config && plugins + ? resolveBundledRuntimeDepsConfiguredModelOwnerPluginIds({ + config: params.config, + extensionsDir: params.extensionsDir, + manifestCache, + }) + : undefined; const includedPluginIds = new Set(); for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) { @@ -506,6 +657,7 @@ export function collectBundledPluginRuntimeDeps(params: { selectedPluginIds: params.selectedPluginIds, pluginId, pluginDir, + configuredModelOwnerPluginIds, includeConfiguredChannels: params.includeConfiguredChannels, manifestCache, }) diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index ca82c7a5913..73741a6c2f7 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -1072,6 +1072,20 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { enabledByDefault: true, providers: ["amazon-bedrock"], }); + writeBundledPluginPackage({ + packageRoot, + pluginId: "anthropic", + deps: { "anthropic-runtime": "4.0.0" }, + modelSupport: { modelPrefixes: ["claude-"] }, + providers: ["anthropic"], + }); + writeBundledPluginPackage({ + packageRoot, + pluginId: "openai", + deps: { "openai-runtime": "5.0.0" }, + modelSupport: { modelPrefixes: ["gpt-", "o1", "o3", "o4"] }, + providers: ["openai", "openai-codex"], + }); return packageRoot; } @@ -1175,6 +1189,28 @@ describe("scanBundledPluginRuntimeDeps config policy", () => { includeConfiguredChannels: false, expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"], }, + { + name: "includes configured bare model owner deps from model support", + config: { agents: { defaults: { model: "gpt-5.5" } } }, + includeConfiguredChannels: false, + expectedDeps: ["alpha-runtime@1.0.0", "openai-runtime@5.0.0"], + }, + { + name: "includes configured bare fallback model owner deps from model support", + config: { + agents: { + defaults: { model: { primary: "unknown-model", fallbacks: ["claude-sonnet-4-6"] } }, + }, + }, + includeConfiguredChannels: false, + expectedDeps: ["alpha-runtime@1.0.0", "anthropic-runtime@4.0.0"], + }, + { + name: "includes configured model provider deps from manifest provider aliases", + config: { agents: { defaults: { model: "openai-codex/gpt-5.5" } } }, + includeConfiguredChannels: false, + expectedDeps: ["alpha-runtime@1.0.0", "openai-runtime@5.0.0"], + }, { name: "includes configured model provider deps from aliases", config: { models: { providers: { "aws-bedrock": { baseUrl: "", models: [] } } } }, diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 7b0ca4f2649..c3c15c74b9d 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -49,6 +49,7 @@ import { createBundledRuntimeDepsPluginIdNormalizer, isBundledPluginConfiguredForRuntimeDeps, normalizePluginIdSet, + resolveBundledRuntimeDepsConfiguredModelOwnerPluginIds, type BundledPluginRuntimeDepsManifestCache, type RuntimeDepConflict, } from "./bundled-runtime-deps-selection.js"; @@ -309,6 +310,11 @@ export function ensureBundledPluginRuntimeDeps(params: { plugins, pluginId: params.pluginId, pluginDir: params.pluginRoot, + configuredModelOwnerPluginIds: resolveBundledRuntimeDepsConfiguredModelOwnerPluginIds({ + config: params.config, + extensionsDir, + manifestCache, + }), manifestCache, }) ) { diff --git a/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts b/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts index 1e926d1d675..98f03ce196d 100644 --- a/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts +++ b/src/plugins/test-helpers/bundled-runtime-deps-fixtures.ts @@ -46,6 +46,7 @@ export function writeBundledPluginRuntimeDepsPackage(params: { deps: Record; enabledByDefault?: boolean; channels?: string[]; + modelSupport?: { modelPatterns?: string[]; modelPrefixes?: string[] }; providers?: string[]; }): string { const pluginRoot = path.join(params.packageRoot, "dist", "extensions", params.pluginId); @@ -60,6 +61,7 @@ export function writeBundledPluginRuntimeDepsPackage(params: { id: params.pluginId, enabledByDefault: params.enabledByDefault === true, ...(params.channels ? { channels: params.channels } : {}), + ...(params.modelSupport ? { modelSupport: params.modelSupport } : {}), ...(params.providers ? { providers: params.providers } : {}), }), );