Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
// Vydra tests cover image generation provider plugin behavior.
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildVydraImageGenerationProvider } from "./image-generation-provider.js";
import {
binaryResponse,
jsonResponse,
stubFetch,
stubVydraApiKey,
} from "./provider-test-helpers.test.js";
function fetchCall(fetchMock: ReturnType<typeof vi.fn>, index = 0): [string, RequestInit] {
const call = fetchMock.mock.calls[index];
if (!call) {
throw new Error(`Expected fetch call ${index}`);
}
return call as [string, RequestInit];
}
function oversizedJsonResponse(): Response {
return new Response(Buffer.alloc(16 * 1024 * 1024 + 1, 0x20), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("vydra image-generation provider", () => {
installPinnedHostnameTestHooks();
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("posts to the www api and downloads the generated image", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({
jobId: "job-123",
status: "completed",
imageUrl: "https://cdn.vydra.ai/generated/test.png",
}),
binaryResponse("png-data", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
const result = await provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {},
});
const createCall = fetchCall(fetchMock);
expect(createCall[0]).toBe("https://www.vydra.ai/api/v1/models/grok-imagine");
expect(createCall[1].method).toBe("POST");
expect(createCall[1].body).toBe(
JSON.stringify({
prompt: "draw a cat",
model: "text-to-image",
}),
);
const headers = new Headers(createCall[1].headers);
expect(headers.get("authorization")).toBe("Bearer vydra-test-key");
expect(result).toEqual({
images: [
{
buffer: Buffer.from("png-data"),
mimeType: "image/png",
fileName: "image-1.png",
},
],
model: "grok-imagine",
metadata: {
jobId: "job-123",
imageUrl: "https://cdn.vydra.ai/generated/test.png",
status: "completed",
},
});
});
it("rejects generated image downloads that exceed the configured media cap", async () => {
stubVydraApiKey();
stubFetch(
jsonResponse({
jobId: "job-123",
status: "completed",
imageUrl: "https://cdn.vydra.ai/generated/test.png",
}),
binaryResponse("too-large", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
await expect(
provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
).rejects.toThrow("Vydra image download exceeds 1 bytes");
});
it("rejects image creation JSON responses that exceed the provider cap", async () => {
stubVydraApiKey();
stubFetch(oversizedJsonResponse());
const provider = buildVydraImageGenerationProvider();
await expect(
provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {},
}),
).rejects.toThrow("vydra.image-generation: JSON response exceeds 16777216 bytes");
});
it("passes request SSRF policy to the image creation request", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({
jobId: "job-123",
status: "completed",
imageUrl: "https://cdn.vydra.ai/generated/test.png",
}),
binaryResponse("png-data", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
await provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {
models: {
providers: {
vydra: {
baseUrl: "https://198.18.0.10/api/v1",
},
},
},
} as never,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
});
const createCall = fetchCall(fetchMock);
expect(createCall[0]).toBe("https://198.18.0.10/api/v1/models/grok-imagine");
expect(createCall[1].method).toBe("POST");
});
it("polls jobs when the create response is not completed yet", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({ jobId: "job-456", status: "queued" }),
jsonResponse({
jobId: "job-456",
status: "completed",
resultUrls: ["https://cdn.vydra.ai/generated/polled.png"],
}),
binaryResponse("png-data", "image/png"),
);
const provider = buildVydraImageGenerationProvider();
await provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {},
});
const pollCall = fetchCall(fetchMock, 1);
expect(pollCall[0]).toBe("https://www.vydra.ai/api/v1/jobs/job-456");
expect(pollCall[1].method).toBe("GET");
});
it("rejects job poll JSON responses that exceed the provider cap", async () => {
stubVydraApiKey();
stubFetch(jsonResponse({ jobId: "job-456", status: "queued" }), oversizedJsonResponse());
const provider = buildVydraImageGenerationProvider();
await expect(
provider.generateImage({
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
cfg: {},
}),
).rejects.toThrow("Vydra job status: JSON response exceeds 16777216 bytes");
});
});

View File

@@ -0,0 +1,124 @@
// Vydra provider module implements model/runtime integration.
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_VYDRA_IMAGE_MODEL,
downloadVydraAsset,
extractVydraResultUrls,
resolveCompletedVydraPayload,
resolveVydraGeneratedMediaMaxBytes,
resolveVydraResponseJobId,
resolveVydraResponseStatus,
resolveVydraRequestContext,
} from "./shared.js";
export function buildVydraImageGenerationProvider(): ImageGenerationProvider {
return {
id: "vydra",
label: "Vydra",
defaultModel: DEFAULT_VYDRA_IMAGE_MODEL,
models: [DEFAULT_VYDRA_IMAGE_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "vydra",
agentDir,
}),
capabilities: {
generate: {
maxCount: 1,
supportsSize: false,
supportsAspectRatio: false,
supportsResolution: false,
},
edit: {
enabled: false,
maxCount: 1,
maxInputImages: 0,
supportsSize: false,
supportsAspectRatio: false,
supportsResolution: false,
},
},
async generateImage(req) {
if ((req.inputImages?.length ?? 0) > 0) {
throw new Error(
"Vydra image generation currently supports text-to-image only in the bundled plugin.",
);
}
if ((req.count ?? 1) > 1) {
throw new Error("Vydra image generation supports at most one image per request.");
}
const { fetchFn, baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
await resolveVydraRequestContext({
cfg: req.cfg,
agentDir: req.agentDir,
authStore: req.authStore,
capability: "image",
});
const model = req.model?.trim() || DEFAULT_VYDRA_IMAGE_MODEL;
const { response, release } = await postJsonRequest({
url: `${baseUrl}/models/${model}`,
headers,
body: {
prompt: req.prompt,
model: "text-to-image",
},
timeoutMs: req.timeoutMs,
fetchFn,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(response, "Vydra image generation failed");
const submitted = await readProviderJsonResponse(response, "vydra.image-generation");
const completedPayload = await resolveCompletedVydraPayload({
submitted,
baseUrl,
headers,
timeoutMs: req.timeoutMs,
fetchFn,
kind: "image",
missingJobIdMessage: "Vydra image generation response missing job id",
});
const imageUrl = extractVydraResultUrls(completedPayload, "image")[0];
if (!imageUrl) {
throw new Error("Vydra image generation completed without an image URL");
}
const image = await downloadVydraAsset({
url: imageUrl,
kind: "image",
timeoutMs: req.timeoutMs,
fetchFn,
maxBytes: resolveVydraGeneratedMediaMaxBytes({ cfg: req.cfg, kind: "image" }),
});
return {
images: [
{
buffer: image.buffer,
mimeType: image.mimeType,
fileName: image.fileName,
},
],
model,
metadata: {
jobId:
resolveVydraResponseJobId(completedPayload) ?? resolveVydraResponseJobId(submitted),
imageUrl,
status: resolveVydraResponseStatus(completedPayload) ?? "completed",
},
};
} finally {
await release();
}
},
};
}

50
extensions/vydra/index.ts Normal file
View File

@@ -0,0 +1,50 @@
// Vydra plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildVydraImageGenerationProvider } from "./image-generation-provider.js";
import { applyVydraConfig, VYDRA_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
import { buildVydraSpeechProvider } from "./speech-provider.js";
import { buildVydraVideoGenerationProvider } from "./video-generation-provider.js";
const PROVIDER_ID = "vydra";
export default definePluginEntry({
id: PROVIDER_ID,
name: "Vydra Provider",
description: "Bundled Vydra image, video, and speech provider",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Vydra",
docsPath: "/providers/vydra",
envVars: ["VYDRA_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "Vydra API key",
hint: "Image, video, and speech API key",
optionKey: "vydraApiKey",
flagName: "--vydra-api-key",
envVar: "VYDRA_API_KEY",
promptMessage: "Enter Vydra API key",
defaultModel: VYDRA_DEFAULT_IMAGE_MODEL_REF,
expectedProviders: [PROVIDER_ID],
applyConfig: (cfg) => applyVydraConfig(cfg),
wizard: {
choiceId: "vydra-api-key",
choiceLabel: "Vydra API key",
choiceHint: "Image, video, and speech API key",
groupId: "vydra",
groupLabel: "Vydra",
groupHint: "Image, video, and speech",
onboardingScopes: ["image-generation"],
},
}),
],
});
api.registerSpeechProvider(buildVydraSpeechProvider());
api.registerImageGenerationProvider(buildVydraImageGenerationProvider());
api.registerVideoGenerationProvider(buildVydraVideoGenerationProvider());
},
});

View File

@@ -0,0 +1,22 @@
// Vydra setup module handles plugin onboarding behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
export const VYDRA_DEFAULT_IMAGE_MODEL_REF = "vydra/grok-imagine";
export function applyVydraConfig(cfg: OpenClawConfig): OpenClawConfig {
if (cfg.agents?.defaults?.imageGenerationModel) {
return cfg;
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
imageGenerationModel: {
primary: VYDRA_DEFAULT_IMAGE_MODEL_REF,
},
},
},
};
}

View File

@@ -0,0 +1,42 @@
{
"id": "vydra",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"providers": ["vydra"],
"setup": {
"providers": [
{
"id": "vydra",
"envVars": ["VYDRA_API_KEY"]
}
]
},
"providerAuthChoices": [
{
"provider": "vydra",
"method": "api-key",
"choiceId": "vydra-api-key",
"choiceLabel": "Vydra API key",
"groupId": "vydra",
"groupLabel": "Vydra",
"groupHint": "Image, video, and speech",
"onboardingScopes": ["image-generation"],
"optionKey": "vydraApiKey",
"cliFlag": "--vydra-api-key",
"cliOption": "--vydra-api-key <key>",
"cliDescription": "Vydra API key"
}
],
"contracts": {
"speechProviders": ["vydra"],
"imageGenerationProviders": ["vydra"],
"videoGenerationProviders": ["vydra"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@openclaw/vydra-provider",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw Vydra media provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,34 @@
// Vydra tests cover provider test helpers plugin behavior.
import * as providerAuth from "openclaw/plugin-sdk/provider-auth-runtime";
import { vi } from "vitest";
export function stubVydraApiKey(): void {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "vydra-test-key",
source: "env",
mode: "api-key",
});
}
export function jsonResponse(payload: Record<string, unknown>): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
export function binaryResponse(data: string, contentType: string): Response {
return new Response(Buffer.from(data), {
status: 200,
headers: { "Content-Type": contentType },
});
}
export function stubFetch(...responses: Response[]): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn();
for (const response of responses) {
fetchMock.mockResolvedValueOnce(response);
}
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

333
extensions/vydra/shared.ts Normal file
View File

@@ -0,0 +1,333 @@
// Vydra plugin module implements shared behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
readProviderJsonResponse,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
type ProviderOperationDeadline,
type ProviderOperationTimeoutMs,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export const DEFAULT_VYDRA_BASE_URL = "https://www.vydra.ai/api/v1";
export const DEFAULT_VYDRA_IMAGE_MODEL = "grok-imagine";
export const DEFAULT_VYDRA_VIDEO_MODEL = "veo3";
export const DEFAULT_VYDRA_SPEECH_MODEL = "elevenlabs/tts";
export const DEFAULT_VYDRA_VOICE_ID = "21m00Tcm4TlvDq8ikWAM";
const DEFAULT_HTTP_TIMEOUT_MS = 120_000;
const DEFAULT_GENERATED_IMAGE_MAX_BYTES = 6 * 1024 * 1024;
const DEFAULT_GENERATED_AUDIO_MAX_BYTES = 16 * 1024 * 1024;
const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024;
const POLL_INTERVAL_MS = 2_500;
const MAX_POLL_ATTEMPTS = 120;
type VydraAuthStore = Parameters<typeof resolveApiKeyForProvider>[0]["store"];
type VydraMediaKind = "audio" | "image" | "video";
type VydraJobPayload = {
id?: string;
jobId?: string;
status?: string;
message?: string;
error?: string | { message?: string; detail?: string } | null;
};
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function addUrlValue(value: unknown, urls: Set<string>): void {
if (typeof value === "string") {
const trimmed = value.trim();
if (/^https?:\/\//iu.test(trimmed)) {
urls.add(trimmed);
}
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
addUrlValue(entry, urls);
}
}
}
export const trimToUndefined = normalizeOptionalString;
export function normalizeVydraBaseUrl(value: string | undefined): string {
const fallback = DEFAULT_VYDRA_BASE_URL;
const trimmed = trimToUndefined(value);
if (!trimmed) {
return fallback;
}
try {
const url = new URL(trimmed);
if (url.hostname === "vydra.ai") {
url.hostname = "www.vydra.ai";
}
const pathname = url.pathname.replace(/\/+$/u, "");
if (!pathname) {
url.pathname = "/api/v1";
} else {
url.pathname = pathname;
}
return url.toString().replace(/\/$/u, "");
} catch {
return fallback;
}
}
function resolveVydraBaseUrlFromConfig(cfg: unknown): string {
const models = asObject(asObject(cfg)?.models);
const providers = asObject(models?.providers);
const vydra = asObject(providers?.vydra);
return normalizeVydraBaseUrl(trimToUndefined(vydra?.baseUrl));
}
export async function resolveVydraRequestContext(params: {
cfg: OpenClawConfig;
agentDir?: string;
authStore?: VydraAuthStore;
capability: "image" | "video";
}): Promise<{
fetchFn: typeof fetch;
baseUrl: string;
allowPrivateNetwork: boolean;
headers: Headers;
dispatcherPolicy: ReturnType<typeof resolveProviderHttpRequestConfig>["dispatcherPolicy"];
}> {
const auth = await resolveApiKeyForProvider({
provider: "vydra",
cfg: params.cfg,
agentDir: params.agentDir,
store: params.authStore,
});
if (!auth.apiKey) {
throw new Error("Vydra API key missing");
}
const fetchFn = fetch;
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveVydraBaseUrlFromConfig(params.cfg),
defaultBaseUrl: DEFAULT_VYDRA_BASE_URL,
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
},
provider: "vydra",
capability: params.capability,
transport: "http",
});
return {
fetchFn,
baseUrl,
allowPrivateNetwork,
headers,
dispatcherPolicy,
};
}
export function resolveVydraResponseJobId(payload: unknown): string | undefined {
const object = asObject(payload) as VydraJobPayload | undefined;
return trimToUndefined(object?.jobId) ?? trimToUndefined(object?.id);
}
export function resolveVydraResponseStatus(payload: unknown): string | undefined {
return normalizeOptionalLowercaseString(trimToUndefined(asObject(payload)?.status));
}
function resolveVydraErrorMessage(payload: unknown): string | undefined {
const object = asObject(payload) as VydraJobPayload | undefined;
const error = object?.error;
if (typeof error === "string" && error.trim()) {
return error.trim();
}
const errorObject = asObject(error);
return (
trimToUndefined(errorObject?.message) ??
trimToUndefined(errorObject?.detail) ??
trimToUndefined(object?.message)
);
}
export function extractVydraResultUrls(payload: unknown, kind: VydraMediaKind): string[] {
const urls = new Set<string>();
const preferredKeys =
kind === "audio"
? ["audioUrl", "audioUrls"]
: kind === "image"
? ["imageUrl", "imageUrls"]
: ["videoUrl", "videoUrls"];
const sharedKeys = ["resultUrl", "resultUrls", "outputUrl", "outputUrls", "url", "urls"];
const recurseKeys = ["output", "outputs", "result", "results", "data", "asset", "assets"];
const visit = (value: unknown, depth = 0) => {
if (depth > 5) {
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
visit(entry, depth + 1);
}
return;
}
const object = asObject(value);
if (!object) {
return;
}
for (const key of [...preferredKeys, ...sharedKeys]) {
addUrlValue(object[key], urls);
}
for (const key of recurseKeys) {
if (key in object) {
visit(object[key], depth + 1);
}
}
};
visit(payload);
return [...urls];
}
function resolveVydraFileExtension(kind: VydraMediaKind, mimeType: string): string {
return (
extensionForMime(mimeType)?.slice(1) ??
(kind === "image" ? "png" : kind === "audio" ? "mp3" : "mp4")
);
}
function resolveVydraHttpTimeoutMs(timeoutMs: ProviderOperationTimeoutMs | undefined): number {
const resolved = typeof timeoutMs === "function" ? timeoutMs() : timeoutMs;
if (typeof resolved !== "number" || !Number.isFinite(resolved) || resolved <= 0) {
return DEFAULT_HTTP_TIMEOUT_MS;
}
return resolved;
}
export function resolveVydraGeneratedMediaMaxBytes(params: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
kind: VydraMediaKind;
}): number {
const configured = params.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * 1024 * 1024);
}
if (params.kind === "image") {
return DEFAULT_GENERATED_IMAGE_MAX_BYTES;
}
if (params.kind === "audio") {
return DEFAULT_GENERATED_AUDIO_MAX_BYTES;
}
return DEFAULT_GENERATED_VIDEO_MAX_BYTES;
}
export async function downloadVydraAsset(params: {
url: string;
kind: VydraMediaKind;
timeoutMs?: ProviderOperationTimeoutMs;
fetchFn: typeof fetch;
maxBytes: number;
}): Promise<{ buffer: Buffer; mimeType: string; fileName: string }> {
const timeoutMs = resolveVydraHttpTimeoutMs(params.timeoutMs);
const response = await fetchWithTimeout(params.url, { method: "GET" }, timeoutMs, params.fetchFn);
await assertOkOrThrowHttpError(response, `Vydra ${params.kind} download failed`);
const mimeType =
response.headers.get("content-type")?.trim() ||
(params.kind === "image" ? "image/png" : params.kind === "audio" ? "audio/mpeg" : "video/mp4");
const buffer = await readResponseWithLimit(response, params.maxBytes, {
chunkTimeoutMs: timeoutMs,
onOverflow: ({ maxBytes }) =>
new Error(`Vydra ${params.kind} download exceeds ${maxBytes} bytes`),
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`Vydra ${params.kind} download stalled after ${chunkTimeoutMs}ms`),
});
const extension = resolveVydraFileExtension(params.kind, mimeType);
const fileStem = params.kind === "image" ? "image" : params.kind === "audio" ? "audio" : "video";
return {
buffer,
mimeType,
fileName: `${fileStem}-1.${extension}`,
};
}
async function waitForVydraJob(params: {
baseUrl: string;
jobId: string;
headers: Headers;
timeoutMs?: number;
deadline?: ProviderOperationDeadline;
fetchFn: typeof fetch;
kind: VydraMediaKind;
}): Promise<unknown> {
const deadline =
params.deadline ??
createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `Vydra job ${params.jobId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/jobs/${params.jobId}`,
{
method: "GET",
headers: params.headers,
},
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_HTTP_TIMEOUT_MS }),
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Vydra job status request failed");
const payload = await readProviderJsonResponse<unknown>(response, "Vydra job status");
const status = resolveVydraResponseStatus(payload);
if (status === "completed" || extractVydraResultUrls(payload, params.kind).length > 0) {
return payload;
}
if (status === "failed" || status === "error" || status === "cancelled") {
throw new Error(resolveVydraErrorMessage(payload) ?? `Vydra job ${params.jobId} failed`);
}
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
}
throw new Error(`Vydra job ${params.jobId} did not finish in time`);
}
export async function resolveCompletedVydraPayload(params: {
submitted: unknown;
baseUrl: string;
headers: Headers;
timeoutMs?: number;
deadline?: ProviderOperationDeadline;
fetchFn: typeof fetch;
kind: VydraMediaKind;
missingJobIdMessage: string;
}): Promise<unknown> {
if (
resolveVydraResponseStatus(params.submitted) === "completed" ||
extractVydraResultUrls(params.submitted, params.kind).length > 0
) {
return params.submitted;
}
const jobId = resolveVydraResponseJobId(params.submitted);
if (!jobId) {
throw new Error(resolveVydraErrorMessage(params.submitted) ?? params.missingJobIdMessage);
}
return waitForVydraJob({
baseUrl: params.baseUrl,
jobId,
headers: params.headers,
timeoutMs: params.timeoutMs,
...(params.deadline ? { deadline: params.deadline } : {}),
fetchFn: params.fetchFn,
kind: params.kind,
});
}

View File

@@ -0,0 +1,126 @@
// Vydra tests cover speech provider plugin behavior.
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildVydraSpeechProvider } from "./speech-provider.js";
describe("vydra speech provider", () => {
installPinnedHostnameTestHooks();
const provider = buildVydraSpeechProvider();
const oversizedJsonResponse = () =>
new Response(Buffer.alloc(16 * 1024 * 1024 + 1, 0x20), {
status: 200,
headers: { "Content-Type": "application/json" },
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("exposes the default voice and model", async () => {
expect(provider.models).toEqual(["elevenlabs/tts"]);
const voices = await provider.listVoices?.({});
expect(voices).toEqual([
{
id: "21m00Tcm4TlvDq8ikWAM",
name: "Rachel",
},
]);
});
it("posts to the tts endpoint and downloads the audio", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
audioUrl: "https://cdn.vydra.ai/generated/test.mp3",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
)
.mockResolvedValueOnce(
new Response(Buffer.from("mp3-data"), {
status: 200,
headers: { "Content-Type": "audio/mpeg" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const result = await provider.synthesize({
text: "OpenClaw test",
cfg: {} as never,
providerConfig: { apiKey: "vydra-test-key" },
target: "audio-file",
timeoutMs: 30_000,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://www.vydra.ai/api/v1/models/elevenlabs/tts");
expect(init.method).toBe("POST");
expect(init.body).toBe(
JSON.stringify({
text: "OpenClaw test",
voice_id: "21m00Tcm4TlvDq8ikWAM",
}),
);
const headers = new Headers(init.headers);
expect(headers.get("authorization")).toBe("Bearer vydra-test-key");
expect(result.outputFormat).toBe("mp3");
expect(result.fileExtension).toBe(".mp3");
expect(result.audioBuffer).toEqual(Buffer.from("mp3-data"));
});
it("rejects generated audio downloads that exceed the configured media cap", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
audioUrl: "https://cdn.vydra.ai/generated/test.mp3",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
)
.mockResolvedValueOnce(
new Response(Buffer.from("too-large"), {
status: 200,
headers: { "Content-Type": "audio/mpeg" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(
provider.synthesize({
text: "OpenClaw test",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } } as never,
providerConfig: { apiKey: "vydra-test-key" },
target: "audio-file",
timeoutMs: 30_000,
}),
).rejects.toThrow("Vydra audio download exceeds 1 bytes");
});
it("rejects speech synthesis JSON responses that exceed the provider cap", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(oversizedJsonResponse()));
await expect(
provider.synthesize({
text: "OpenClaw test",
cfg: {} as never,
providerConfig: { apiKey: "vydra-test-key" },
target: "audio-file",
timeoutMs: 30_000,
}),
).rejects.toThrow("Vydra speech synthesis: JSON response exceeds 16777216 bytes");
});
});

View File

@@ -0,0 +1,156 @@
// Vydra provider module implements model/runtime integration.
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import type {
SpeechProviderConfig,
SpeechProviderOverrides,
SpeechProviderPlugin,
} from "openclaw/plugin-sdk/speech-core";
import { asObject } from "openclaw/plugin-sdk/speech-core";
import {
DEFAULT_VYDRA_BASE_URL,
DEFAULT_VYDRA_SPEECH_MODEL,
DEFAULT_VYDRA_VOICE_ID,
downloadVydraAsset,
extractVydraResultUrls,
normalizeVydraBaseUrl,
resolveVydraGeneratedMediaMaxBytes,
trimToUndefined,
} from "./shared.js";
type VydraSpeechConfig = {
apiKey?: string;
baseUrl: string;
model: string;
voiceId: string;
};
const VYDRA_SPEECH_VOICES = [
{
id: DEFAULT_VYDRA_VOICE_ID,
name: "Rachel",
},
] as const;
function normalizeVydraSpeechConfig(rawConfig: Record<string, unknown>): VydraSpeechConfig {
const providers = asObject(rawConfig.providers);
const raw = asObject(providers?.vydra) ?? asObject(rawConfig.vydra);
return {
apiKey: normalizeResolvedSecretInputString({
value: raw?.apiKey,
path: "messages.tts.providers.vydra.apiKey",
}),
baseUrl: normalizeVydraBaseUrl(
trimToUndefined(raw?.baseUrl) ?? trimToUndefined(process.env.VYDRA_BASE_URL),
),
model:
trimToUndefined(raw?.model) ??
trimToUndefined(process.env.VYDRA_TTS_MODEL) ??
DEFAULT_VYDRA_SPEECH_MODEL,
voiceId:
trimToUndefined(raw?.voiceId) ??
trimToUndefined(process.env.VYDRA_TTS_VOICE_ID) ??
DEFAULT_VYDRA_VOICE_ID,
};
}
function readVydraSpeechConfig(config: SpeechProviderConfig): VydraSpeechConfig {
const normalized = normalizeVydraSpeechConfig({});
return {
apiKey: trimToUndefined(config.apiKey) ?? normalized.apiKey,
baseUrl: normalizeVydraBaseUrl(trimToUndefined(config.baseUrl) ?? normalized.baseUrl),
model: trimToUndefined(config.model) ?? normalized.model,
voiceId: trimToUndefined(config.voiceId) ?? normalized.voiceId,
};
}
function readVydraOverrides(overrides: SpeechProviderOverrides | undefined): {
model?: string;
voiceId?: string;
} {
if (!overrides) {
return {};
}
return {
model: trimToUndefined(overrides.model),
voiceId: trimToUndefined(overrides.voiceId),
};
}
export function buildVydraSpeechProvider(): SpeechProviderPlugin {
return {
id: "vydra",
label: "Vydra",
models: [DEFAULT_VYDRA_SPEECH_MODEL],
voices: VYDRA_SPEECH_VOICES.map((voice) => voice.id),
resolveConfig: ({ rawConfig }) => normalizeVydraSpeechConfig(rawConfig),
listVoices: async () => VYDRA_SPEECH_VOICES.map((voice) => Object.assign({}, voice)),
isConfigured: ({ providerConfig }) =>
Boolean(readVydraSpeechConfig(providerConfig).apiKey || process.env.VYDRA_API_KEY),
synthesize: async (req) => {
const config = readVydraSpeechConfig(req.providerConfig);
const overrides = readVydraOverrides(req.providerOverrides);
const apiKey = config.apiKey || process.env.VYDRA_API_KEY;
if (!apiKey) {
throw new Error("Vydra API key missing");
}
const fetchFn = fetch;
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: config.baseUrl,
defaultBaseUrl: DEFAULT_VYDRA_BASE_URL,
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
provider: "vydra",
capability: "audio",
transport: "http",
});
const { response, release } = await postJsonRequest({
url: `${baseUrl}/models/${overrides.model ?? config.model}`,
headers,
body: {
text: req.text,
voice_id: overrides.voiceId ?? config.voiceId,
},
timeoutMs: req.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(response, "Vydra speech synthesis failed");
const payload = await readProviderJsonResponse<unknown>(response, "Vydra speech synthesis");
const audioUrl = extractVydraResultUrls(payload, "audio")[0];
if (!audioUrl) {
throw new Error("Vydra speech synthesis response missing audio URL");
}
const audio = await downloadVydraAsset({
url: audioUrl,
kind: "audio",
timeoutMs: req.timeoutMs,
fetchFn,
maxBytes: resolveVydraGeneratedMediaMaxBytes({ cfg: req.cfg, kind: "audio" }),
});
return {
audioBuffer: audio.buffer,
outputFormat: audio.mimeType.includes("wav") ? "wav" : "mp3",
fileExtension: audio.fileName.endsWith(".wav") ? ".wav" : ".mp3",
voiceCompatible: false,
};
} finally {
await release();
}
},
};
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -0,0 +1,181 @@
// Vydra tests cover video generation provider plugin behavior.
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
binaryResponse,
jsonResponse,
stubFetch,
stubVydraApiKey,
} from "./provider-test-helpers.test.js";
import { buildVydraVideoGenerationProvider } from "./video-generation-provider.js";
function fetchCall(fetchMock: ReturnType<typeof vi.fn>, index: number) {
const call = fetchMock.mock.calls[index];
if (!call) {
throw new Error(`expected fetch call ${index}`);
}
return call;
}
function oversizedJsonResponse(): Response {
return new Response(Buffer.alloc(16 * 1024 * 1024 + 1, 0x20), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("vydra video-generation provider", () => {
installPinnedHostnameTestHooks();
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(buildVydraVideoGenerationProvider());
});
it("submits veo3 jobs and downloads the completed video", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({ jobId: "job-123", status: "processing" }),
jsonResponse({
jobId: "job-123",
status: "completed",
videoUrl: "https://cdn.vydra.ai/generated/test.mp4",
}),
binaryResponse("webm-data", "video/webm"),
);
const provider = buildVydraVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "vydra",
model: "veo3",
prompt: "tiny city at sunrise",
cfg: {},
});
const createCall = fetchCall(fetchMock, 0);
expect(createCall[0]).toBe("https://www.vydra.ai/api/v1/models/veo3");
const createInit = createCall[1] as { method?: string; body?: unknown } | undefined;
expect(createInit?.method).toBe("POST");
expect(createInit?.body).toBe(JSON.stringify({ prompt: "tiny city at sunrise" }));
const pollCall = fetchCall(fetchMock, 1);
expect(pollCall[0]).toBe("https://www.vydra.ai/api/v1/jobs/job-123");
const pollInit = pollCall[1] as { method?: string } | undefined;
expect(pollInit?.method).toBe("GET");
expect(result.videos).toHaveLength(1);
const [video] = result.videos;
if (!video) {
throw new Error("Expected generated Vydra video");
}
expect(video.mimeType).toBe("video/webm");
expect(video.fileName).toBe("video-1.webm");
expect(result.metadata).toEqual({
jobId: "job-123",
videoUrl: "https://cdn.vydra.ai/generated/test.mp4",
status: "completed",
});
});
it("rejects generated video downloads that exceed the configured media cap", async () => {
stubVydraApiKey();
stubFetch(
jsonResponse({ jobId: "job-123", status: "processing" }),
jsonResponse({
jobId: "job-123",
status: "completed",
videoUrl: "https://cdn.vydra.ai/generated/test.mp4",
}),
binaryResponse("too-large", "video/mp4"),
);
const provider = buildVydraVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "vydra",
model: "veo3",
prompt: "tiny city at sunrise",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
).rejects.toThrow("Vydra video download exceeds 1 bytes");
});
it("rejects video creation JSON responses that exceed the provider cap", async () => {
stubVydraApiKey();
stubFetch(oversizedJsonResponse());
const provider = buildVydraVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "vydra",
model: "veo3",
prompt: "tiny city at sunrise",
cfg: {},
}),
).rejects.toThrow("Vydra video generation: JSON response exceeds 16777216 bytes");
});
it("requires a remote image url for kling", async () => {
stubVydraApiKey();
vi.stubGlobal("fetch", vi.fn());
const provider = buildVydraVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "vydra",
model: "kling",
prompt: "animate this image",
cfg: {},
inputImages: [{ buffer: Buffer.from("png"), mimeType: "image/png" }],
}),
).rejects.toThrow("Vydra kling currently requires a remote image URL reference.");
});
it("submits kling jobs with a remote image url", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
jsonResponse({ jobId: "job-kling", status: "processing" }),
jsonResponse({
jobId: "job-kling",
status: "completed",
videoUrl: "https://cdn.vydra.ai/generated/kling.mp4",
}),
binaryResponse("mp4-data", "video/mp4"),
);
const provider = buildVydraVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "vydra",
model: "kling",
prompt: "animate this image",
cfg: {},
inputImages: [{ url: "https://example.com/reference.png" }],
});
const createCall = fetchCall(fetchMock, 0);
expect(createCall[0]).toBe("https://www.vydra.ai/api/v1/models/kling");
const createInit = createCall[1] as { method?: string; body?: unknown } | undefined;
expect(createInit?.method).toBe("POST");
expect(createInit?.body).toBe(
JSON.stringify({
prompt: "animate this image",
image_url: "https://example.com/reference.png",
video_url: "https://example.com/reference.png",
}),
);
expect(result.videos).toHaveLength(1);
const [video] = result.videos;
if (!video) {
throw new Error("Expected generated Vydra kling video");
}
expect(video.mimeType).toBe("video/mp4");
expect(result.metadata).toEqual({
jobId: "job-kling",
videoUrl: "https://cdn.vydra.ai/generated/kling.mp4",
status: "completed",
});
});
});

View File

@@ -0,0 +1,163 @@
// Vydra provider module implements model/runtime integration.
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
createProviderOperationTimeoutResolver,
postJsonRequest,
readProviderJsonResponse,
resolveProviderOperationTimeoutMs,
} from "openclaw/plugin-sdk/provider-http";
import type { VideoGenerationProvider } from "openclaw/plugin-sdk/video-generation";
import {
DEFAULT_VYDRA_VIDEO_MODEL,
downloadVydraAsset,
extractVydraResultUrls,
resolveCompletedVydraPayload,
resolveVydraGeneratedMediaMaxBytes,
resolveVydraResponseJobId,
resolveVydraResponseStatus,
resolveVydraRequestContext,
} from "./shared.js";
const VYDRA_KLING_MODEL = "kling";
const DEFAULT_VYDRA_VIDEO_TIMEOUT_MS = 120_000;
function resolveVydraVideoRequestBody(
req: Parameters<VideoGenerationProvider["generateVideo"]>[0],
) {
const model = req.model?.trim() || DEFAULT_VYDRA_VIDEO_MODEL;
if (model === VYDRA_KLING_MODEL) {
const input = req.inputImages?.[0];
const imageUrl = input?.url?.trim();
if (!imageUrl) {
throw new Error("Vydra kling currently requires a remote image URL reference.");
}
return {
model,
body: {
prompt: req.prompt,
// Vydra's kling route has been inconsistent about which field it requires.
image_url: imageUrl,
video_url: imageUrl,
},
};
}
if ((req.inputImages?.length ?? 0) > 0) {
throw new Error(
`Vydra ${model} does not support image reference inputs in the bundled plugin.`,
);
}
return {
model,
body: {
prompt: req.prompt,
},
};
}
export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
return {
id: "vydra",
label: "Vydra",
defaultModel: DEFAULT_VYDRA_VIDEO_MODEL,
models: [DEFAULT_VYDRA_VIDEO_MODEL, VYDRA_KLING_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "vydra",
agentDir,
}),
capabilities: {
generate: {
maxVideos: 1,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
},
videoToVideo: {
enabled: false,
},
},
async generateVideo(req) {
if ((req.inputVideos?.length ?? 0) > 0) {
throw new Error("Vydra video generation does not support video reference inputs.");
}
const { fetchFn, baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
await resolveVydraRequestContext({
cfg: req.cfg,
agentDir: req.agentDir,
authStore: req.authStore,
capability: "video",
});
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs ?? DEFAULT_VYDRA_VIDEO_TIMEOUT_MS,
label: "Vydra video generation",
});
const { model, body } = resolveVydraVideoRequestBody(req);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/models/${model}`,
headers,
body,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_VYDRA_VIDEO_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(response, "Vydra video generation failed");
const submitted = await readProviderJsonResponse<unknown>(
response,
"Vydra video generation",
);
const completedPayload = await resolveCompletedVydraPayload({
submitted,
baseUrl,
headers,
deadline,
fetchFn,
kind: "video",
missingJobIdMessage: "Vydra video generation response missing job id",
});
const videoUrl = extractVydraResultUrls(completedPayload, "video")[0];
if (!videoUrl) {
throw new Error("Vydra video generation completed without a video URL");
}
const video = await downloadVydraAsset({
url: videoUrl,
kind: "video",
timeoutMs: createProviderOperationTimeoutResolver({
deadline,
defaultTimeoutMs: DEFAULT_VYDRA_VIDEO_TIMEOUT_MS,
}),
fetchFn,
maxBytes: resolveVydraGeneratedMediaMaxBytes({ cfg: req.cfg, kind: "video" }),
});
return {
videos: [
{
buffer: video.buffer,
mimeType: video.mimeType,
fileName: video.fileName,
},
],
model,
metadata: {
jobId:
resolveVydraResponseJobId(completedPayload) ?? resolveVydraResponseJobId(submitted),
videoUrl,
status: resolveVydraResponseStatus(completedPayload) ?? "completed",
},
};
} finally {
await release();
}
},
};
}

View File

@@ -0,0 +1,120 @@
// Vydra tests cover vydra plugin behavior.
import {
registerProviderPlugin,
requireRegisteredProvider,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
const LIVE = isLiveTestEnabled();
const VYDRA_API_KEY = process.env.VYDRA_API_KEY?.trim() ?? "";
const ENABLE_VYDRA_VIDEO_LIVE = process.env.OPENCLAW_LIVE_VYDRA_VIDEO === "1";
const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_VYDRA_IMAGE_MODEL?.trim() || "grok-imagine";
const LIVE_VIDEO_MODEL = process.env.OPENCLAW_LIVE_VYDRA_VIDEO_MODEL?.trim() || "veo3";
const DEFAULT_LIVE_KLING_IMAGE_URL =
"https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/showcase/roof-camera-sky.jpg";
const LIVE_KLING_IMAGE_URL =
process.env.OPENCLAW_LIVE_VYDRA_KLING_IMAGE_URL?.trim() || DEFAULT_LIVE_KLING_IMAGE_URL;
const VYDRA_KLING_TIMEOUT_MS = 12 * 60_000;
const registerVydraPlugin = () =>
registerProviderPlugin({
plugin,
id: "vydra",
name: "Vydra Provider",
});
function expectBufferedAsset(
asset: { buffer?: Buffer; mimeType: string } | undefined,
kind: "image" | "video",
minBytes: number,
): void {
if (!asset) {
throw new Error(`expected generated ${kind} asset`);
}
expect(asset.mimeType.startsWith(`${kind}/`)).toBe(true);
if (!asset.buffer) {
throw new Error(`expected generated ${kind} buffer`);
}
expect(asset.buffer.byteLength).toBeGreaterThan(minBytes);
}
describe.skipIf(!LIVE || !VYDRA_API_KEY)("vydra live", () => {
it("generates an image through the registered provider", async () => {
const { imageProviders } = await registerVydraPlugin();
const provider = requireRegisteredProvider(imageProviders, "vydra");
const result = await provider.generateImage({
provider: "vydra",
model: LIVE_IMAGE_MODEL,
prompt: "Create a minimal flat orange square centered on a white background.",
cfg: { plugins: { enabled: true } } as never,
agentDir: "/tmp/openclaw-live-vydra-image",
});
expect(result.images.length).toBeGreaterThan(0);
expectBufferedAsset(result.images[0], "image", 512);
}, 60_000);
it("synthesizes speech through the registered provider", async () => {
const { speechProviders } = await registerVydraPlugin();
const provider = requireRegisteredProvider(speechProviders, "vydra");
const voices = await provider.listVoices?.({});
expect(voices?.some((voice) => voice.id === "21m00Tcm4TlvDq8ikWAM")).toBe(true);
const result = await provider.synthesize({
text: "OpenClaw integration test OK.",
cfg: { plugins: { enabled: true } } as never,
providerConfig: { apiKey: VYDRA_API_KEY },
target: "audio-file",
timeoutMs: 45_000,
});
expect(result.outputFormat).toBe("mp3");
expect(result.audioBuffer.byteLength).toBeGreaterThan(512);
}, 60_000);
it.skipIf(!ENABLE_VYDRA_VIDEO_LIVE)(
"generates a short video through the registered provider",
async () => {
const { videoProviders } = await registerVydraPlugin();
const provider = requireRegisteredProvider(videoProviders, "vydra");
const result = await provider.generateVideo({
provider: "vydra",
model: LIVE_VIDEO_MODEL,
prompt:
"A tiny paper diorama city at sunrise with slow cinematic camera motion and no text.",
cfg: { plugins: { enabled: true } } as never,
agentDir: "/tmp/openclaw-live-vydra-video",
});
expect(result.videos.length).toBeGreaterThan(0);
expectBufferedAsset(result.videos[0], "video", 1024);
},
8 * 60_000,
);
it.skipIf(!ENABLE_VYDRA_VIDEO_LIVE)(
"generates a kling image-to-video clip from a remote image url",
async () => {
const { videoProviders } = await registerVydraPlugin();
const provider = requireRegisteredProvider(videoProviders, "vydra");
const result = await provider.generateVideo({
provider: "vydra",
model: "kling",
prompt: "Animate the scene with subtle camera drift and soft cloud motion.",
cfg: { plugins: { enabled: true } } as never,
agentDir: "/tmp/openclaw-live-vydra-kling",
inputImages: [{ url: LIVE_KLING_IMAGE_URL }],
timeoutMs: VYDRA_KLING_TIMEOUT_MS,
});
expect(result.videos.length).toBeGreaterThan(0);
expectBufferedAsset(result.videos[0], "video", 1024);
},
15 * 60_000,
);
});