mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
feat(qwen): add qwen provider and video generation
This commit is contained in:
1
extensions/video-generation-core/api.ts
Normal file
1
extensions/video-generation-core/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "openclaw/plugin-sdk/video-generation-core";
|
||||
7
extensions/video-generation-core/package.json
Normal file
7
extensions/video-generation-core/package.json
Normal 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"
|
||||
}
|
||||
6
extensions/video-generation-core/runtime-api.ts
Normal file
6
extensions/video-generation-core/runtime-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
generateVideo,
|
||||
listRuntimeVideoGenerationProviders,
|
||||
type GenerateVideoParams,
|
||||
type GenerateVideoRuntimeResult,
|
||||
} from "./src/runtime.js";
|
||||
164
extensions/video-generation-core/src/runtime.test.ts
Normal file
164
extensions/video-generation-core/src/runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
189
extensions/video-generation-core/src/runtime.ts
Normal file
189
extensions/video-generation-core/src/runtime.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user