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
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:
191
extensions/vydra/image-generation-provider.test.ts
Normal file
191
extensions/vydra/image-generation-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
124
extensions/vydra/image-generation-provider.ts
Normal file
124
extensions/vydra/image-generation-provider.ts
Normal 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
50
extensions/vydra/index.ts
Normal 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());
|
||||
},
|
||||
});
|
||||
22
extensions/vydra/onboard.ts
Normal file
22
extensions/vydra/onboard.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
42
extensions/vydra/openclaw.plugin.json
Normal file
42
extensions/vydra/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
15
extensions/vydra/package.json
Normal file
15
extensions/vydra/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
34
extensions/vydra/provider-test-helpers.test.ts
Normal file
34
extensions/vydra/provider-test-helpers.test.ts
Normal 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
333
extensions/vydra/shared.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
126
extensions/vydra/speech-provider.test.ts
Normal file
126
extensions/vydra/speech-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
156
extensions/vydra/speech-provider.ts
Normal file
156
extensions/vydra/speech-provider.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
16
extensions/vydra/tsconfig.json
Normal file
16
extensions/vydra/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
181
extensions/vydra/video-generation-provider.test.ts
Normal file
181
extensions/vydra/video-generation-provider.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
163
extensions/vydra/video-generation-provider.ts
Normal file
163
extensions/vydra/video-generation-provider.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
120
extensions/vydra/vydra.live.test.ts
Normal file
120
extensions/vydra/vydra.live.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user