feat(xai): support Grok Imagine Video 1.5 (#103316)

* feat(media): add per-model generation catalog metadata

* feat(xai): support Grok Imagine Video 1.5

* test(plugin-sdk): update public surface budget
This commit is contained in:
Peter Steinberger
2026-07-10 04:49:42 +01:00
committed by GitHub
parent 071e30ecd5
commit 75dfd3dc09
16 changed files with 569 additions and 43 deletions

View File

@@ -26,6 +26,7 @@ type MediaGenerateProvider = {
defaultModel?: string;
models?: readonly string[];
capabilities: unknown;
catalogByModel?: Readonly<Record<string, { capabilities?: unknown; modes?: readonly string[] }>>;
isConfigured?: (ctx: { cfg?: OpenClawConfig; agentDir?: string }) => boolean;
};
@@ -41,6 +42,11 @@ type MediaGenerateListProviderDetails<TProvider extends MediaGenerateProvider> =
catalog: ReturnType<typeof synthesizeMediaGenerationCatalogEntries<TProvider["capabilities"]>>;
};
type MediaGenerateCapabilitySummaryOptions = {
modes?: readonly string[];
includeModes?: boolean;
};
/** Common tool result shape for media generation list/status actions. */
export type { MediaGenerateActionResult };
@@ -56,7 +62,10 @@ export function createMediaGenerateProviderListActionResult<
agentDir?: string;
authStore?: AuthProfileStore;
listModes: (provider: TProvider) => string[];
summarizeCapabilities: (provider: TProvider) => string;
summarizeCapabilities: (
provider: TProvider,
options?: MediaGenerateCapabilitySummaryOptions,
) => string;
formatAuthHint?: (provider: { id: string; authEnvVars: readonly string[] }) => string | undefined;
}): MediaGenerateActionResult {
if (params.providers.length === 0) {
@@ -104,6 +113,22 @@ export function createMediaGenerateProviderListActionResult<
const authHint =
params.formatAuthHint?.({ id: details.id, authEnvVars: authHints }) ??
(authHints.length > 0 ? `set ${authHints.join(" / ")} to use ${details.id}/*` : undefined);
const modelCapabilityLines = details.catalog.flatMap((entry) => {
if (!provider.catalogByModel?.[entry.model]) {
return [];
}
const modelProvider = {
...provider,
capabilities: entry.capabilities ?? provider.capabilities,
} as TProvider;
const modelCapabilities = params.summarizeCapabilities(modelProvider, {
modes: entry.modes,
includeModes: false,
});
const modelModes = entry.modes?.length ? `modes=${entry.modes.join("/")}` : undefined;
const modelSummary = [modelModes, modelCapabilities || undefined].filter(Boolean).join(", ");
return [` model ${entry.model}: ${modelSummary || "no capabilities declared"}`];
});
return [
`${details.id}${details.defaultModel ? ` (default ${details.defaultModel})` : ""}`,
` models: ${modelLine}`,
@@ -111,6 +136,7 @@ export function createMediaGenerateProviderListActionResult<
...(authHint ? [` auth: ${authHint}`] : []),
" source: static",
...(capabilities ? [` capabilities: ${capabilities}`] : []),
...modelCapabilityLines,
];
});

View File

@@ -24,11 +24,26 @@ type VideoGenerateActionResult = MediaGenerateActionResult;
function summarizeVideoGenerationCapabilities(
provider: ReturnType<typeof listRuntimeVideoGenerationProviders>[number],
options?: { modes?: readonly string[]; includeModes?: boolean },
): string {
const supportedModes = listSupportedVideoGenerationModes(provider);
const supportedModes = options?.modes ?? listSupportedVideoGenerationModes(provider);
const generate = provider.capabilities.generate;
const imageToVideo = provider.capabilities.imageToVideo;
const videoToVideo = provider.capabilities.videoToVideo;
const activeModeCapabilities = [
supportedModes.includes("generate") ? generate : undefined,
supportedModes.includes("imageToVideo") && imageToVideo?.enabled ? imageToVideo : undefined,
supportedModes.includes("videoToVideo") && videoToVideo?.enabled ? videoToVideo : undefined,
].filter((capabilities) => capabilities !== undefined);
const maxDurationSeconds = activeModeCapabilities
.map((capabilities) => capabilities.maxDurationSeconds)
.find((value) => typeof value === "number");
const supportedDurationSeconds = activeModeCapabilities
.map((capabilities) => capabilities.supportedDurationSeconds)
.find((value) => value && value.length > 0);
const supportedDurationSecondsByModel = activeModeCapabilities
.map((capabilities) => capabilities.supportedDurationSecondsByModel)
.find((value) => value && Object.keys(value).length > 0);
// providerOptions may be declared at the mode level (generate) or at the flat
// provider-capabilities level. The runtime checks both; surface the union so
// the agent sees a single merged view of which opaque keys each provider
@@ -52,28 +67,39 @@ function summarizeVideoGenerationCapabilities(
videoToVideo?.maxInputAudios ??
provider.capabilities.maxInputAudios;
const capabilities = [
supportedModes.length > 0 ? `modes=${supportedModes.join("/")}` : null,
options?.includeModes !== false && supportedModes.length > 0
? `modes=${supportedModes.join("/")}`
: null,
generate?.maxVideos ? `maxVideos=${generate.maxVideos}` : null,
imageToVideo?.maxInputImages ? `maxInputImages=${imageToVideo.maxInputImages}` : null,
videoToVideo?.maxInputVideos ? `maxInputVideos=${videoToVideo.maxInputVideos}` : null,
typeof maxInputAudios === "number" && maxInputAudios > 0
? `maxInputAudios=${maxInputAudios}`
: null,
generate?.maxDurationSeconds ? `maxDurationSeconds=${generate.maxDurationSeconds}` : null,
generate?.supportedDurationSeconds?.length
? `supportedDurationSeconds=${generate.supportedDurationSeconds.join("/")}`
maxDurationSeconds ? `maxDurationSeconds=${maxDurationSeconds}` : null,
supportedDurationSeconds
? `supportedDurationSeconds=${supportedDurationSeconds.join("/")}`
: null,
generate?.supportedDurationSecondsByModel &&
Object.keys(generate.supportedDurationSecondsByModel).length > 0
? `supportedDurationSecondsByModel=${Object.entries(generate.supportedDurationSecondsByModel)
supportedDurationSecondsByModel
? `supportedDurationSecondsByModel=${Object.entries(supportedDurationSecondsByModel)
.map(([modelId, durations]) => `${modelId}:${durations.join("/")}`)
.join("; ")}`
: null,
generate?.supportsResolution ? "resolution" : null,
generate?.supportsAspectRatio ? "aspectRatio" : null,
generate?.supportsSize ? "size" : null,
generate?.supportsAudio ? "audio" : null,
generate?.supportsWatermark ? "watermark" : null,
activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsResolution)
? "resolution"
: null,
activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsAspectRatio)
? "aspectRatio"
: null,
activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsSize)
? "size"
: null,
activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsAudio)
? "audio"
: null,
activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsWatermark)
? "watermark"
: null,
Object.keys(declaredProviderOptions).length > 0
? `providerOptions={${Object.entries(declaredProviderOptions)
.map(([key, type]) => `${key}:${type}`)

View File

@@ -1295,6 +1295,73 @@ describe("createVideoGenerateTool", () => {
expect(providers[0]?.modes).toEqual(["generate", "imageToVideo"]);
});
it("lists model-specific catalog capabilities and modes", async () => {
const imageToVideoCapabilities = {
imageToVideo: {
enabled: true,
maxInputImages: 1,
maxDurationSeconds: 15,
resolutions: ["480P", "720P", "1080P"] as const,
aspectRatios: ["16:9", "9:16"] as const,
supportsResolution: true,
supportsAspectRatio: true,
},
};
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([
{
id: "video-plugin",
defaultModel: "text-video",
models: ["text-video", "image-video"],
capabilities: {
generate: {
maxDurationSeconds: 10,
},
},
catalogByModel: {
"image-video": {
capabilities: imageToVideoCapabilities,
modes: ["imageToVideo"],
},
},
generateVideo: vi.fn(async () => {
throw new Error("not used");
}),
},
]);
const tool = createVideoGenerateTool({
config: asConfig({
agents: {
defaults: {
videoGenerationModel: { primary: "video-plugin/text-video" },
},
},
}),
});
if (!tool) {
throw new Error("expected video_generate tool");
}
const result = await tool.execute("call-1", { action: "list" });
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(text).toContain(
"model image-video: modes=imageToVideo, maxInputImages=1, maxDurationSeconds=15, resolution, aspectRatio",
);
const providers = resultDetails(result).providers as Array<{
catalog?: Array<{
model?: string;
capabilities?: unknown;
modes?: string[];
}>;
}>;
const catalogEntry = providers[0]?.catalog?.find((entry) => entry.model === "image-video");
expect(catalogEntry).toMatchObject({
model: "image-video",
capabilities: imageToVideoCapabilities,
modes: ["imageToVideo"],
});
});
it("rejects image-to-video when the provider disables that mode", async () => {
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([
{

View File

@@ -5,6 +5,7 @@ export type { FallbackAttempt } from "../agents/model-fallback.types.js";
export type { VideoGenerationProviderPlugin } from "../plugins/types.js";
export type {
GeneratedVideoAsset,
VideoGenerationCatalogModelEntry,
VideoGenerationIgnoredOverride,
VideoGenerationMode,
VideoGenerationModeCapabilities,

View File

@@ -8,6 +8,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type {
GeneratedVideoAsset as CoreGeneratedVideoAsset,
VideoGenerationAssetRole as CoreVideoGenerationAssetRole,
VideoGenerationCatalogModelEntry as CoreVideoGenerationCatalogModelEntry,
VideoGenerationMode as CoreVideoGenerationMode,
VideoGenerationModeCapabilities as CoreVideoGenerationModeCapabilities,
VideoGenerationModelCapabilitiesContext as CoreVideoGenerationModelCapabilitiesContext,
@@ -171,6 +172,12 @@ export type VideoGenerationProviderCapabilities = VideoGenerationModeCapabilitie
videoToVideo?: VideoGenerationTransformCapabilities;
};
/** Static catalog metadata that overrides provider defaults for one video model. */
export type VideoGenerationCatalogModelEntry = {
capabilities?: VideoGenerationProviderCapabilities;
modes?: readonly VideoGenerationMode[];
};
/** Video generation provider contract implemented by provider plugins. */
export type VideoGenerationProvider = {
id: string;
@@ -181,6 +188,7 @@ export type VideoGenerationProvider = {
defaultTimeoutMs?: number;
models?: string[];
capabilities: VideoGenerationProviderCapabilities;
catalogByModel?: Readonly<Record<string, VideoGenerationCatalogModelEntry>>;
isConfigured?: (ctx: VideoGenerationProviderConfiguredContext) => boolean;
resolveModelCapabilities?: (
ctx: VideoGenerationModelCapabilitiesContext,
@@ -201,6 +209,8 @@ const videoGenerationSdkCompat: [
AssertAssignable<CoreVideoGenerationProviderOptionType, VideoGenerationProviderOptionType>,
AssertAssignable<VideoGenerationMode, CoreVideoGenerationMode>,
AssertAssignable<CoreVideoGenerationMode, VideoGenerationMode>,
AssertAssignable<VideoGenerationCatalogModelEntry, CoreVideoGenerationCatalogModelEntry>,
AssertAssignable<CoreVideoGenerationCatalogModelEntry, VideoGenerationCatalogModelEntry>,
AssertAssignable<VideoGenerationModeCapabilities, CoreVideoGenerationModeCapabilities>,
AssertAssignable<CoreVideoGenerationModeCapabilities, VideoGenerationModeCapabilities>,
AssertAssignable<VideoGenerationProvider, CoreVideoGenerationProvider>,

View File

@@ -156,6 +156,12 @@ export type VideoGenerationProviderCapabilities = VideoGenerationModeCapabilitie
videoToVideo?: VideoGenerationTransformCapabilities;
};
/** Static catalog metadata that overrides provider defaults for one video model. */
export type VideoGenerationCatalogModelEntry = {
capabilities?: VideoGenerationProviderCapabilities;
modes?: readonly VideoGenerationMode[];
};
export type VideoGenerationNormalization = {
size?: MediaNormalizationEntry<string>;
aspectRatio?: MediaNormalizationEntry<string>;
@@ -172,6 +178,7 @@ export type VideoGenerationProvider = {
defaultTimeoutMs?: number;
models?: string[];
capabilities: VideoGenerationProviderCapabilities;
catalogByModel?: Readonly<Record<string, VideoGenerationCatalogModelEntry>>;
isConfigured?: (ctx: VideoGenerationProviderConfiguredContext) => boolean;
resolveModelCapabilities?: (
ctx: VideoGenerationModelCapabilitiesContext,