feat(qwen): add qwen provider and video generation

This commit is contained in:
Peter Steinberger
2026-04-04 17:43:15 +01:00
parent 759373e887
commit e3ac0f43df
104 changed files with 2477 additions and 483 deletions

View File

@@ -0,0 +1 @@
export * from "openclaw/plugin-sdk/video-generation-core";

View File

@@ -0,0 +1,7 @@
{
"name": "@openclaw/video-generation-core",
"version": "2026.4.1-beta.1",
"private": true,
"description": "OpenClaw video generation runtime package",
"type": "module"
}

View File

@@ -0,0 +1,6 @@
export {
generateVideo,
listRuntimeVideoGenerationProviders,
type GenerateVideoParams,
type GenerateVideoRuntimeResult,
} from "./src/runtime.js";

View File

@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { VideoGenerationProvider } from "../api.js";
import { generateVideo, listRuntimeVideoGenerationProviders } from "./runtime.js";
const mocks = vi.hoisted(() => {
const debug = vi.fn();
return {
createSubsystemLogger: vi.fn(() => ({ debug })),
describeFailoverError: vi.fn(),
getProviderEnvVars: vi.fn<(providerId: string) => string[]>(() => []),
getVideoGenerationProvider: vi.fn<
(providerId: string, config?: OpenClawConfig) => VideoGenerationProvider | undefined
>(() => undefined),
isFailoverError: vi.fn<(err: unknown) => boolean>(() => false),
listVideoGenerationProviders: vi.fn<(config?: OpenClawConfig) => VideoGenerationProvider[]>(
() => [],
),
parseVideoGenerationModelRef: vi.fn<
(raw?: string) => { provider: string; model: string } | undefined
>((raw?: string) => {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash === trimmed.length - 1) {
return undefined;
}
return {
provider: trimmed.slice(0, slash),
model: trimmed.slice(slash + 1),
};
}),
resolveAgentModelFallbackValues: vi.fn<(value: unknown) => string[]>(() => []),
resolveAgentModelPrimaryValue: vi.fn<(value: unknown) => string | undefined>(() => undefined),
debug,
};
});
vi.mock("../api.js", () => ({
createSubsystemLogger: mocks.createSubsystemLogger,
describeFailoverError: mocks.describeFailoverError,
getProviderEnvVars: mocks.getProviderEnvVars,
getVideoGenerationProvider: mocks.getVideoGenerationProvider,
isFailoverError: mocks.isFailoverError,
listVideoGenerationProviders: mocks.listVideoGenerationProviders,
parseVideoGenerationModelRef: mocks.parseVideoGenerationModelRef,
resolveAgentModelFallbackValues: mocks.resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue: mocks.resolveAgentModelPrimaryValue,
}));
describe("video-generation runtime", () => {
beforeEach(() => {
mocks.createSubsystemLogger.mockClear();
mocks.describeFailoverError.mockReset();
mocks.getProviderEnvVars.mockReset();
mocks.getProviderEnvVars.mockReturnValue([]);
mocks.getVideoGenerationProvider.mockReset();
mocks.isFailoverError.mockReset();
mocks.isFailoverError.mockReturnValue(false);
mocks.listVideoGenerationProviders.mockReset();
mocks.listVideoGenerationProviders.mockReturnValue([]);
mocks.parseVideoGenerationModelRef.mockClear();
mocks.resolveAgentModelFallbackValues.mockReset();
mocks.resolveAgentModelFallbackValues.mockReturnValue([]);
mocks.resolveAgentModelPrimaryValue.mockReset();
mocks.resolveAgentModelPrimaryValue.mockReturnValue(undefined);
mocks.debug.mockReset();
});
it("generates videos through the active video-generation provider", async () => {
const authStore = { version: 1, profiles: {} } as const;
let seenAuthStore: unknown;
mocks.resolveAgentModelPrimaryValue.mockReturnValue("video-plugin/vid-v1");
const provider: VideoGenerationProvider = {
id: "video-plugin",
capabilities: {},
async generateVideo(req: { authStore?: unknown }) {
seenAuthStore = req.authStore;
return {
videos: [
{
buffer: Buffer.from("mp4-bytes"),
mimeType: "video/mp4",
fileName: "sample.mp4",
},
],
model: "vid-v1",
};
},
};
mocks.getVideoGenerationProvider.mockReturnValue(provider);
const result = await generateVideo({
cfg: {
agents: {
defaults: {
videoGenerationModel: { primary: "video-plugin/vid-v1" },
},
},
} as OpenClawConfig,
prompt: "animate a cat",
agentDir: "/tmp/agent",
authStore,
});
expect(result.provider).toBe("video-plugin");
expect(result.model).toBe("vid-v1");
expect(result.attempts).toEqual([]);
expect(seenAuthStore).toEqual(authStore);
expect(result.videos).toEqual([
{
buffer: Buffer.from("mp4-bytes"),
mimeType: "video/mp4",
fileName: "sample.mp4",
},
]);
});
it("lists runtime video-generation providers through the owner runtime", () => {
const providers: VideoGenerationProvider[] = [
{
id: "video-plugin",
defaultModel: "vid-v1",
models: ["vid-v1"],
capabilities: {
supportsAudio: true,
},
generateVideo: async () => ({
videos: [{ buffer: Buffer.from("mp4-bytes"), mimeType: "video/mp4" }],
}),
},
];
mocks.listVideoGenerationProviders.mockReturnValue(providers);
expect(listRuntimeVideoGenerationProviders({ config: {} as OpenClawConfig })).toEqual(
providers,
);
expect(mocks.listVideoGenerationProviders).toHaveBeenCalledWith({} as OpenClawConfig);
});
it("explains native video-generation config and provider auth when no model is configured", async () => {
mocks.listVideoGenerationProviders.mockReturnValue([
{
id: "qwen",
defaultModel: "wan2.6-t2v",
capabilities: {},
generateVideo: async () => ({
videos: [{ buffer: Buffer.from("mp4-bytes"), mimeType: "video/mp4" }],
}),
},
]);
mocks.getProviderEnvVars.mockReturnValue(["QWEN_API_KEY"]);
const promise = generateVideo({ cfg: {} as OpenClawConfig, prompt: "animate a cat" });
await expect(promise).rejects.toThrow("No video-generation model configured.");
await expect(promise).rejects.toThrow(
'Set agents.defaults.videoGenerationModel.primary to a provider/model like "',
);
await expect(promise).rejects.toThrow("qwen: QWEN_API_KEY");
});
});

View File

@@ -0,0 +1,189 @@
import {
createSubsystemLogger,
describeFailoverError,
getProviderEnvVars,
getVideoGenerationProvider,
isFailoverError,
listVideoGenerationProviders,
parseVideoGenerationModelRef,
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
type AuthProfileStore,
type FallbackAttempt,
type GeneratedVideoAsset,
type OpenClawConfig,
type VideoGenerationResolution,
type VideoGenerationResult,
type VideoGenerationSourceAsset,
} from "../api.js";
const log = createSubsystemLogger("video-generation");
export type GenerateVideoParams = {
cfg: OpenClawConfig;
prompt: string;
agentDir?: string;
authStore?: AuthProfileStore;
modelOverride?: string;
size?: string;
aspectRatio?: string;
resolution?: VideoGenerationResolution;
durationSeconds?: number;
audio?: boolean;
watermark?: boolean;
inputImages?: VideoGenerationSourceAsset[];
inputVideos?: VideoGenerationSourceAsset[];
};
export type GenerateVideoRuntimeResult = {
videos: GeneratedVideoAsset[];
provider: string;
model: string;
attempts: FallbackAttempt[];
metadata?: Record<string, unknown>;
};
function resolveVideoGenerationCandidates(params: {
cfg: OpenClawConfig;
modelOverride?: string;
}): Array<{ provider: string; model: string }> {
const candidates: Array<{ provider: string; model: string }> = [];
const seen = new Set<string>();
const add = (raw: string | undefined) => {
const parsed = parseVideoGenerationModelRef(raw);
if (!parsed) {
return;
}
const key = `${parsed.provider}/${parsed.model}`;
if (seen.has(key)) {
return;
}
seen.add(key);
candidates.push(parsed);
};
add(params.modelOverride);
add(resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.videoGenerationModel));
for (const fallback of resolveAgentModelFallbackValues(
params.cfg.agents?.defaults?.videoGenerationModel,
)) {
add(fallback);
}
return candidates;
}
function throwVideoGenerationFailure(params: {
attempts: FallbackAttempt[];
lastError: unknown;
}): never {
if (params.attempts.length <= 1 && params.lastError) {
throw params.lastError;
}
const summary =
params.attempts.length > 0
? params.attempts
.map((attempt) => `${attempt.provider}/${attempt.model}: ${attempt.error}`)
.join(" | ")
: "unknown";
throw new Error(`All video generation models failed (${params.attempts.length}): ${summary}`, {
cause: params.lastError instanceof Error ? params.lastError : undefined,
});
}
function buildNoVideoGenerationModelConfiguredMessage(cfg: OpenClawConfig): string {
const providers = listVideoGenerationProviders(cfg);
const sampleModel =
providers.find((provider) => provider.defaultModel) ??
({ id: "qwen", defaultModel: "wan2.6-t2v" } as const);
const authHints = providers
.flatMap((provider) => {
const envVars = getProviderEnvVars(provider.id);
if (envVars.length === 0) {
return [];
}
return [`${provider.id}: ${envVars.join(" / ")}`];
})
.slice(0, 3);
return [
`No video-generation model configured. Set agents.defaults.videoGenerationModel.primary to a provider/model like "${sampleModel.id}/${sampleModel.defaultModel}".`,
authHints.length > 0
? `If you want a specific provider, also configure that provider's auth/API key first (${authHints.join("; ")}).`
: "If you want a specific provider, also configure that provider's auth/API key first.",
].join(" ");
}
export function listRuntimeVideoGenerationProviders(params?: { config?: OpenClawConfig }) {
return listVideoGenerationProviders(params?.config);
}
export async function generateVideo(
params: GenerateVideoParams,
): Promise<GenerateVideoRuntimeResult> {
const candidates = resolveVideoGenerationCandidates({
cfg: params.cfg,
modelOverride: params.modelOverride,
});
if (candidates.length === 0) {
throw new Error(buildNoVideoGenerationModelConfiguredMessage(params.cfg));
}
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
for (const candidate of candidates) {
const provider = getVideoGenerationProvider(candidate.provider, params.cfg);
if (!provider) {
const error = `No video-generation provider registered for ${candidate.provider}`;
attempts.push({
provider: candidate.provider,
model: candidate.model,
error,
});
lastError = new Error(error);
continue;
}
try {
const result: VideoGenerationResult = await provider.generateVideo({
provider: candidate.provider,
model: candidate.model,
prompt: params.prompt,
cfg: params.cfg,
agentDir: params.agentDir,
authStore: params.authStore,
size: params.size,
aspectRatio: params.aspectRatio,
resolution: params.resolution,
durationSeconds: params.durationSeconds,
audio: params.audio,
watermark: params.watermark,
inputImages: params.inputImages,
inputVideos: params.inputVideos,
});
if (!Array.isArray(result.videos) || result.videos.length === 0) {
throw new Error("Video generation provider returned no videos.");
}
return {
videos: result.videos,
provider: candidate.provider,
model: result.model ?? candidate.model,
attempts,
metadata: result.metadata,
};
} catch (err) {
lastError = err;
const described = isFailoverError(err) ? describeFailoverError(err) : undefined;
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: described?.message ?? (err instanceof Error ? err.message : String(err)),
reason: described?.reason,
status: described?.status,
code: described?.code,
});
log.debug(`video-generation candidate failed: ${candidate.provider}/${candidate.model}`);
}
}
throwVideoGenerationFailure({ attempts, lastError });
}