fix(gateway): start configured generation providers

This commit is contained in:
Vincent Koc
2026-05-04 17:01:39 -07:00
parent b2c3202a15
commit ffff3b8c83
3 changed files with 194 additions and 0 deletions

View File

@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- Codex plugin: mirror the experimental upstream app-server protocol and format generated TypeScript before drift checks, keeping OpenClaw's `experimentalApi` bridge compatible with latest Codex while preserving formatter gates.
- Telegram/media: derive no-caption inbound media placeholders from saved MIME metadata instead of the Telegram `photo` shape, so non-image and mixed attachments no longer reach the model as `<media:image>`. Fixes #69793. Thanks @aspalagin.
- Agents/cache: keep per-turn runtime context out of ordinary chat system prompts while still delivering hidden current-turn context, restoring prompt-cache reuse on chat continuations. Fixes #77431. Thanks @Udjin79.

View File

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

View File

@@ -39,6 +39,11 @@ export type GatewayStartupPluginPlan = {
};
type NormalizedPluginsConfig = ReturnType<typeof normalizePluginsConfigWithRegistry>;
type GenerationProviderContractKey =
| "imageGenerationProviders"
| "videoGenerationProviders"
| "musicGenerationProviders";
type ConfiguredGenerationProviderIds = Record<GenerationProviderContractKey, ReadonlySet<string>>;
function isRecord(value: unknown): value is Record<string, unknown> {
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<string> {
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<typeof normalizePluginsConfigWithRegistry>;
activationSource: {
plugins: ReturnType<typeof normalizePluginsConfigWithRegistry>;
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,