From 0eb06caae3807679b23d2c20a5b464fa46fdc556 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 05:03:34 +0100 Subject: [PATCH] fix: start configured generation providers --- CHANGELOG.md | 1 + src/plugins/channel-plugin-ids.test.ts | 56 +++++++++ src/plugins/gateway-startup-plugin-ids.ts | 137 ++++++++++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84177388258..cce58aecac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai ### Fixes - WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc. +- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc. - Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91. - Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077. - Gateway/diagnostics: make stuck-session recovery outcome-driven and generation-guarded, add `diagnostics.stuckSessionAbortMs`, and emit structured recovery requested/completed events so stale or skipped recovery no longer looks like a successful abort. diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 83cd27f743e..f6de3a83069 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -164,6 +164,10 @@ function createManifestRegistryFixture(): PluginManifestRegistry { enabledByDefault: true, providers: ["openai", "openai-codex"], cliBackends: ["codex-cli"], + contracts: { + imageGenerationProviders: ["openai"], + videoGenerationProviders: ["openai"], + }, }, { id: "google", @@ -172,6 +176,11 @@ function createManifestRegistryFixture(): PluginManifestRegistry { enabledByDefault: true, providers: ["google", "google-gemini-cli"], cliBackends: ["google-gemini-cli"], + contracts: { + imageGenerationProviders: ["google"], + videoGenerationProviders: ["google"], + musicGenerationProviders: ["google"], + }, }, { id: "codex", @@ -754,6 +763,53 @@ describe("resolveGatewayStartupPluginIds", () => { } as OpenClawConfig, ["browser", "memory-core"], ], + [ + "includes bundled generation providers configured by media defaults at startup", + { + channels: {}, + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-2", + fallbacks: ["google/gemini-3-pro-image-preview"], + }, + videoGenerationModel: { + primary: "google/veo-3.1-fast-generate-preview", + }, + musicGenerationModel: { + primary: "google/lyria-3-clip-preview", + }, + }, + }, + } as OpenClawConfig, + ["browser", "openai", "google", "memory-core"], + ], + [ + "honors explicit plugin disablement for configured generation providers", + { + channels: {}, + agents: { + defaults: { + imageGenerationModel: { primary: "google/gemini-3-pro-image-preview" }, + }, + }, + plugins: { entries: { google: { enabled: false } } }, + } as OpenClawConfig, + ["browser", "memory-core"], + ], + [ + "keeps configured generation providers behind restrictive allowlists", + { + channels: {}, + agents: { + defaults: { + imageGenerationModel: { primary: "google/gemini-3-pro-image-preview" }, + }, + }, + plugins: { allow: ["browser"] }, + } as OpenClawConfig, + ["browser"], + ], [ "includes explicitly enabled non-channel sidecars in startup scope", createStartupConfig({ diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 92f6dc6cc9b..8beaf901f68 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -39,6 +39,11 @@ export type GatewayStartupPluginPlan = { }; type NormalizedPluginsConfig = ReturnType; +type GenerationProviderContractKey = + | "imageGenerationProviders" + | "videoGenerationProviders" + | "musicGenerationProviders"; +type ConfiguredGenerationProviderIds = Record>; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -209,6 +214,123 @@ function manifestOwnsConfiguredSpeechProvider(params: { }); } +function listModelProviderRefs(value: unknown): string[] { + if (typeof value === "string") { + return [value]; + } + if (!isRecord(value)) { + return []; + } + const refs: string[] = []; + if (typeof value.primary === "string") { + refs.push(value.primary); + } + if (Array.isArray(value.fallbacks)) { + for (const fallback of value.fallbacks) { + if (typeof fallback === "string") { + refs.push(fallback); + } + } + } + return refs; +} + +function collectModelProviderIds(value: unknown): ReadonlySet { + return new Set( + listModelProviderRefs(value) + .map((ref) => { + const slashIndex = ref.indexOf("/"); + return slashIndex > 0 ? normalizeOptionalLowercaseString(ref.slice(0, slashIndex)) : ""; + }) + .filter((providerId): providerId is string => Boolean(providerId)), + ); +} + +function collectConfiguredGenerationProviderIds( + config: OpenClawConfig, +): ConfiguredGenerationProviderIds { + const defaults = config.agents?.defaults; + return { + imageGenerationProviders: collectModelProviderIds(defaults?.imageGenerationModel), + videoGenerationProviders: collectModelProviderIds(defaults?.videoGenerationModel), + musicGenerationProviders: collectModelProviderIds(defaults?.musicGenerationModel), + }; +} + +function manifestOwnsConfiguredGenerationProvider(params: { + manifest: PluginManifestRecord | undefined; + configuredGenerationProviderIds: ConfiguredGenerationProviderIds; +}): boolean { + for (const contractKey of [ + "imageGenerationProviders", + "videoGenerationProviders", + "musicGenerationProviders", + ] as const) { + const configuredProviderIds = params.configuredGenerationProviderIds[contractKey]; + if (configuredProviderIds.size === 0) { + continue; + } + if ( + (params.manifest?.contracts?.[contractKey] ?? []).some((providerId) => { + const normalized = normalizeOptionalLowercaseString(providerId); + return normalized ? configuredProviderIds.has(normalized) : false; + }) + ) { + return true; + } + } + return false; +} + +function canStartConfiguredGenerationProviderPlugin(params: { + plugin: InstalledPluginIndexRecord; + manifest: PluginManifestRecord | undefined; + config: OpenClawConfig; + pluginsConfig: ReturnType; + activationSource: { + plugins: ReturnType; + rootConfig?: OpenClawConfig; + }; + configuredGenerationProviderIds: ConfiguredGenerationProviderIds; + platform?: NodeJS.Platform; +}): boolean { + if ( + !manifestOwnsConfiguredGenerationProvider({ + manifest: params.manifest, + configuredGenerationProviderIds: params.configuredGenerationProviderIds, + }) + ) { + return false; + } + if (!params.pluginsConfig.enabled || !params.activationSource.plugins.enabled) { + return false; + } + if ( + params.pluginsConfig.deny.includes(params.plugin.pluginId) || + params.activationSource.plugins.deny.includes(params.plugin.pluginId) + ) { + return false; + } + if ( + params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false || + params.activationSource.plugins.entries[params.plugin.pluginId]?.enabled === false + ) { + return false; + } + const activationState = resolveEffectivePluginActivationState({ + id: params.plugin.pluginId, + origin: params.plugin.origin, + config: params.pluginsConfig, + rootConfig: params.config, + enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform), + activationSource: params.activationSource, + }); + return ( + activationState.enabled && + (params.plugin.origin === "bundled" || activationState.explicitlyEnabled) + ); +} + function canStartConfiguredSpeechProviderPlugin(params: { plugin: InstalledPluginIndexRecord; manifest: PluginManifestRecord | undefined; @@ -512,6 +634,8 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config); const manifestLookup = createManifestRegistryLookup(params.manifestRegistry); const configuredSpeechProviderIds = collectConfiguredSpeechProviderIds(activationSourceConfig); + const configuredGenerationProviderIds = + collectConfiguredGenerationProviderIds(activationSourceConfig); const normalizePluginId = createPluginRegistryIdNormalizer(params.index, { manifestRegistry: params.manifestRegistry, }); @@ -581,6 +705,19 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: { ) { return true; } + if ( + canStartConfiguredGenerationProviderPlugin({ + plugin, + manifest, + config: params.config, + pluginsConfig, + activationSource, + configuredGenerationProviderIds, + platform: params.platform, + }) + ) { + return true; + } if ( canStartExplicitHookPlugin({ plugin,