mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
feat(openrouter): add video generation provider (#72700)
Adds OpenRouter video generation via video_generate, with hardened async polling/download handling, docs, and regression coverage. Validation: - pnpm test src/plugins/plugin-lookup-table.test.ts src/secrets/target-registry.fast-path.test.ts src/gateway/server-startup-post-attach.test.ts extensions/openrouter/video-generation-provider.test.ts src/video-generation/live-test-helpers.test.ts src/media-generation/provider-capabilities.contract.test.ts src/agents/pi-embedded-helpers/failover-matches.test.ts src/plugins/manifest-metadata-scan.test.ts src/agents/openai-transport-stream.test.ts src/media-understanding/openai-compatible-audio.test.ts src/agents/schema-normalization-runtime-contract.test.ts src/agents/provider-request-config.test.ts src/plugin-sdk/provider-stream.test.ts src/agents/pi-embedded-runner/run/attempt.spawn-workspace.websocket.test.ts -- --reporter=verbose - OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=0 OPENCLAW_LIVE_VIDEO_GENERATION_MODELS=openrouter/google/veo-3.1-fast pnpm test:live src/video-generation/video-generation.live.test.ts -- --runInBand Co-authored-by: notamicrodose <gabrielkripalani@me.com>
This commit is contained in:
committed by
GitHub
parent
5915489631
commit
17ef9ef895
@@ -953,6 +953,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Google Meet joins OpenClaw as a bundled participant plugin, with personal Google auth, Chrome/Twilio realtime sessions, paired-node Chrome support, artifact/attendance exports, and recovery tooling for already-open Meet tabs.
|
||||
- DeepSeek V4 Flash and V4 Pro are in the bundled catalog, V4 Flash is the onboarding default, and DeepSeek thinking/replay behavior is fixed for follow-up tool-call turns.
|
||||
- Talk, Voice Call, and Google Meet can use realtime voice loops that consult the full OpenClaw agent for deeper tool-backed answers.
|
||||
- Providers/OpenRouter: add native video generation through `video_generate`, so OpenRouter video models work with `OPENROUTER_API_KEY`. (#72700) Thanks @notamicrodose.
|
||||
- Browser automation gets coordinate clicks, longer default action budgets, per-profile headless overrides, and steadier tab reuse/recovery.
|
||||
- Plugin and model infrastructure is lighter at startup: static model catalogs, manifest-backed model rows, lazy provider dependencies, and external runtime-dependency repair for packaged installs.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ read_when:
|
||||
- You want a single API key for many LLMs
|
||||
- You want to run models via OpenRouter in OpenClaw
|
||||
- You want to use OpenRouter for image generation
|
||||
- You want to use OpenRouter for video generation
|
||||
title: "OpenRouter"
|
||||
---
|
||||
|
||||
@@ -78,6 +79,33 @@ OpenRouter can also back the `image_generate` tool. Use an OpenRouter image mode
|
||||
|
||||
OpenClaw sends image requests to OpenRouter's chat completions image API with `modalities: ["image", "text"]`. Gemini image models receive supported `aspectRatio` and `resolution` hints through OpenRouter's `image_config`. Use `agents.defaults.imageGenerationModel.timeoutMs` for slower OpenRouter image models; the `image_generate` tool's per-call `timeoutMs` parameter still wins.
|
||||
|
||||
## Video generation
|
||||
|
||||
OpenRouter can also back the `video_generate` tool through its asynchronous `/videos` API. Use an OpenRouter video model under `agents.defaults.videoGenerationModel`:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { OPENROUTER_API_KEY: "sk-or-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
videoGenerationModel: {
|
||||
primary: "openrouter/google/veo-3.1-fast",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
OpenClaw submits text-to-video and image-to-video jobs to OpenRouter, polls
|
||||
the returned `polling_url`, and downloads the completed video from
|
||||
OpenRouter's `unsigned_urls` or the documented job content endpoint.
|
||||
Reference images are sent as first/last frame images by default; images
|
||||
tagged with `reference_image` are sent as OpenRouter input references. The
|
||||
bundled `google/veo-3.1-fast` default advertises the currently supported 4/6/8
|
||||
second durations, `720P`/`1080P` resolutions, and `16:9`/`9:16` aspect
|
||||
ratios. Video-to-video is not registered for OpenRouter because the upstream
|
||||
video generation API currently accepts text and image references.
|
||||
|
||||
## Text-to-speech
|
||||
|
||||
OpenRouter can also be used as a TTS provider through its OpenAI-compatible
|
||||
|
||||
@@ -61,6 +61,7 @@ provider is configured.
|
||||
| MiniMax | ✓ | ✓ | ✓ | ✓ | | | |
|
||||
| Mistral | | | | | ✓ | | |
|
||||
| OpenAI | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ |
|
||||
| OpenRouter | ✓ | ✓ | | ✓ | | | ✓ |
|
||||
| Qwen | | ✓ | | | | | |
|
||||
| Runway | | ✓ | | | | | |
|
||||
| SenseAudio | | | | | ✓ | | |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Generate videos via video_generate from text, image, or video references across 14 provider backends"
|
||||
summary: "Generate videos via video_generate from text, image, or video references across 16 provider backends"
|
||||
read_when:
|
||||
- Generating videos via the agent
|
||||
- Configuring video-generation providers and models
|
||||
@@ -9,7 +9,7 @@ sidebarTitle: "Video generation"
|
||||
---
|
||||
|
||||
OpenClaw agents can generate videos from text prompts, reference images, or
|
||||
existing videos. Fifteen provider backends are supported, each with
|
||||
existing videos. Sixteen provider backends are supported, each with
|
||||
different model options, input modes, and feature sets. The agent picks the
|
||||
right provider automatically based on your configuration and available API
|
||||
keys.
|
||||
@@ -116,6 +116,7 @@ generation.
|
||||
| Google | `veo-3.1-fast-generate-preview` | ✓ | 1 image | 1 video | `GEMINI_API_KEY` |
|
||||
| MiniMax | `MiniMax-Hailuo-2.3` | ✓ | 1 image | — | `MINIMAX_API_KEY` or MiniMax OAuth |
|
||||
| OpenAI | `sora-2` | ✓ | 1 image | 1 video | `OPENAI_API_KEY` |
|
||||
| OpenRouter | `google/veo-3.1-fast` | ✓ | Up to 4 images (first/last frame or references) | — | `OPENROUTER_API_KEY` |
|
||||
| Qwen | `wan2.6-t2v` | ✓ | Yes (remote URL) | Yes (remote URL) | `QWEN_API_KEY` |
|
||||
| Runway | `gen4.5` | ✓ | 1 image | 1 video | `RUNWAYML_API_SECRET` |
|
||||
| Together | `Wan-AI/Wan2.2-T2V-A14B` | ✓ | 1 image | — | `TOGETHER_API_KEY` |
|
||||
@@ -133,21 +134,22 @@ runtime modes at runtime.
|
||||
The explicit mode contract used by `video_generate`, contract tests, and
|
||||
the shared live sweep:
|
||||
|
||||
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
|
||||
| --------- | :--------: | :------------: | :------------: | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Alibaba | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| BytePlus | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| ComfyUI | ✓ | ✓ | — | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
|
||||
| DeepInfra | ✓ | — | — | `generate`; native DeepInfra video schemas are text-to-video in the bundled contract |
|
||||
| fal | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` only when using Seedance reference-to-video |
|
||||
| Google | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
|
||||
| MiniMax | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| OpenAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
|
||||
| Qwen | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| Runway | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
|
||||
| Together | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| Vydra | ✓ | ✓ | — | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
|
||||
| xAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
|
||||
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
|
||||
| ---------- | :--------: | :------------: | :------------: | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Alibaba | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| BytePlus | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| ComfyUI | ✓ | ✓ | — | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
|
||||
| DeepInfra | ✓ | — | — | `generate`; native DeepInfra video schemas are text-to-video in the bundled contract |
|
||||
| fal | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` only when using Seedance reference-to-video |
|
||||
| Google | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
|
||||
| MiniMax | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| OpenAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
|
||||
| OpenRouter | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| Qwen | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| Runway | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
|
||||
| Together | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| Vydra | ✓ | ✓ | — | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
|
||||
| xAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
|
||||
|
||||
## Tool parameters
|
||||
|
||||
@@ -389,6 +391,13 @@ only the explicit `model`, `primary`, and `fallbacks` entries.
|
||||
(`aspectRatio`, `resolution`, `audio`, `watermark`) are ignored with
|
||||
a warning.
|
||||
</Accordion>
|
||||
<Accordion title="OpenRouter">
|
||||
Uses OpenRouter's asynchronous `/videos` API. OpenClaw submits the
|
||||
job, polls `polling_url`, and downloads either `unsigned_urls` or the
|
||||
documented job content endpoint. The bundled `google/veo-3.1-fast` default
|
||||
advertises 4/6/8 second durations, `720P`/`1080P` resolutions, and
|
||||
`16:9`/`9:16` aspect ratios.
|
||||
</Accordion>
|
||||
<Accordion title="Qwen">
|
||||
Same DashScope backend as Alibaba. Reference inputs must be remote
|
||||
`http(s)` URLs; local files are rejected upfront.
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
describe("openrouter provider hooks", () => {
|
||||
it("registers OpenRouter speech alongside model and media providers", async () => {
|
||||
const { providers, speechProviders, mediaProviders, imageProviders } =
|
||||
const { providers, speechProviders, mediaProviders, imageProviders, videoProviders } =
|
||||
await registerProviderPlugin({
|
||||
plugin: openrouterPlugin,
|
||||
id: "openrouter",
|
||||
@@ -23,6 +23,7 @@ describe("openrouter provider hooks", () => {
|
||||
expect(speechProviders).toEqual([expect.objectContaining({ id: "openrouter" })]);
|
||||
expect(mediaProviders).toEqual([expect.objectContaining({ id: "openrouter" })]);
|
||||
expect(imageProviders).toEqual([expect.objectContaining({ id: "openrouter" })]);
|
||||
expect(videoProviders).toEqual([expect.objectContaining({ id: "openrouter" })]);
|
||||
});
|
||||
|
||||
it("includes Kimi K2.6 in the bundled catalog", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "./provider-catalog.js";
|
||||
import { buildOpenRouterSpeechProvider } from "./speech-provider.js";
|
||||
import { wrapOpenRouterProviderStream } from "./stream.js";
|
||||
import { buildOpenRouterVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
const PROVIDER_ID = "openrouter";
|
||||
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
|
||||
@@ -155,6 +156,7 @@ export default definePluginEntry({
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(openrouterMediaUnderstandingProvider);
|
||||
api.registerImageGenerationProvider(buildOpenRouterImageGenerationProvider());
|
||||
api.registerVideoGenerationProvider(buildOpenRouterVideoGenerationProvider());
|
||||
api.registerSpeechProvider(buildOpenRouterSpeechProvider());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["openrouter"],
|
||||
"imageGenerationProviders": ["openrouter"],
|
||||
"videoGenerationProviders": ["openrouter"],
|
||||
"speechProviders": ["openrouter"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
|
||||
332
extensions/openrouter/video-generation-provider.test.ts
Normal file
332
extensions/openrouter/video-generation-provider.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildOpenRouterVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
const {
|
||||
assertOkOrThrowHttpErrorMock,
|
||||
fetchWithTimeoutGuardedMock,
|
||||
postJsonRequestMock,
|
||||
resolveApiKeyForProviderMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
waitProviderOperationPollIntervalMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
|
||||
fetchWithTimeoutGuardedMock: vi.fn(),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "openrouter-key" })),
|
||||
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://openrouter.ai/api/v1",
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
|
||||
dispatcherPolicy: undefined,
|
||||
requestConfig: {},
|
||||
})),
|
||||
waitProviderOperationPollIntervalMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
|
||||
"openclaw/plugin-sdk/provider-http",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
|
||||
fetchWithTimeoutGuarded: fetchWithTimeoutGuardedMock,
|
||||
postJsonRequest: postJsonRequestMock,
|
||||
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
|
||||
waitProviderOperationPollInterval: waitProviderOperationPollIntervalMock,
|
||||
};
|
||||
});
|
||||
|
||||
function releasedJson(value: unknown) {
|
||||
return {
|
||||
response: {
|
||||
json: async () => value,
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function releasedVideo(params: { contentType: string; bytes: string }) {
|
||||
return {
|
||||
response: {
|
||||
headers: new Headers({ "content-type": params.contentType }),
|
||||
arrayBuffer: async () => Buffer.from(params.bytes),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("openrouter video generation provider", () => {
|
||||
afterEach(() => {
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
fetchWithTimeoutGuardedMock.mockReset();
|
||||
postJsonRequestMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockClear();
|
||||
resolveProviderHttpRequestConfigMock.mockClear();
|
||||
waitProviderOperationPollIntervalMock.mockClear();
|
||||
});
|
||||
|
||||
it("declares explicit mode capabilities", () => {
|
||||
const provider = buildOpenRouterVideoGenerationProvider();
|
||||
|
||||
expectExplicitVideoGenerationCapabilities(provider);
|
||||
expect(provider.id).toBe("openrouter");
|
||||
expect(provider.defaultModel).toBe("google/veo-3.1-fast");
|
||||
expect(provider.capabilities.generate?.supportsAudio).toBe(true);
|
||||
expect(provider.capabilities.generate?.supportedDurationSeconds).toEqual([4, 6, 8]);
|
||||
expect(provider.capabilities.generate?.resolutions).toEqual(["720P", "1080P"]);
|
||||
expect(provider.capabilities.generate?.aspectRatios).toEqual(["16:9", "9:16"]);
|
||||
expect(provider.capabilities.imageToVideo?.enabled).toBe(true);
|
||||
expect(provider.capabilities.videoToVideo?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("submits OpenRouter video jobs, polls completion, and downloads the result", async () => {
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
id: "job-123",
|
||||
polling_url: "/api/v1/videos/job-123",
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
fetchWithTimeoutGuardedMock
|
||||
.mockResolvedValueOnce(
|
||||
releasedJson({
|
||||
id: "job-123",
|
||||
generation_id: "gen-123",
|
||||
status: "completed",
|
||||
model: "google/veo-3.1",
|
||||
unsigned_urls: ["/api/v1/videos/job-123/content?index=0"],
|
||||
usage: { cost: 0.25, is_byok: false },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(releasedVideo({ contentType: "video/mp4", bytes: "mp4-bytes" }));
|
||||
|
||||
const requestOverrides = {
|
||||
proxy: { mode: "explicit-proxy", url: "https://proxy.example" },
|
||||
};
|
||||
const provider = buildOpenRouterVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "openrouter",
|
||||
model: "google/veo-3.1",
|
||||
prompt: "A chrome sphere glides across a quiet moonlit beach",
|
||||
durationSeconds: 5.4,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "720P",
|
||||
size: "1280x720",
|
||||
audio: false,
|
||||
inputImages: [
|
||||
{ buffer: Buffer.from("first-frame"), mimeType: "image/png" },
|
||||
{ buffer: Buffer.from("last-frame"), mimeType: "image/png", role: "last_frame" },
|
||||
{
|
||||
buffer: Buffer.from("style-reference"),
|
||||
mimeType: "image/webp",
|
||||
role: "reference_image",
|
||||
},
|
||||
],
|
||||
providerOptions: {
|
||||
callback_url: "https://example.com/openrouter-video-hook",
|
||||
seed: 42,
|
||||
},
|
||||
timeoutMs: 120_000,
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
openrouter: {
|
||||
baseUrl: "https://custom.openrouter.test/api/v1",
|
||||
request: requestOverrides,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "openrouter" }),
|
||||
);
|
||||
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openrouter",
|
||||
capability: "video",
|
||||
baseUrl: "https://custom.openrouter.test/api/v1",
|
||||
request: requestOverrides,
|
||||
}),
|
||||
);
|
||||
expect(postJsonRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://custom.openrouter.test/api/v1/videos",
|
||||
body: {
|
||||
model: "google/veo-3.1",
|
||||
prompt: "A chrome sphere glides across a quiet moonlit beach",
|
||||
duration: 6,
|
||||
resolution: "720p",
|
||||
aspect_ratio: "16:9",
|
||||
size: "1280x720",
|
||||
generate_audio: false,
|
||||
frame_images: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/png;base64,${Buffer.from("first-frame").toString("base64")}`,
|
||||
},
|
||||
frame_type: "first_frame",
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/png;base64,${Buffer.from("last-frame").toString("base64")}`,
|
||||
},
|
||||
frame_type: "last_frame",
|
||||
},
|
||||
],
|
||||
input_references: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/webp;base64,${Buffer.from("style-reference").toString("base64")}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
callback_url: "https://example.com/openrouter-video-hook",
|
||||
seed: 42,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://custom.openrouter.test/api/v1/videos/job-123",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
expect.any(Number),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ auditContext: "openrouter-video-status" }),
|
||||
);
|
||||
expect(
|
||||
(fetchWithTimeoutGuardedMock.mock.calls[0]?.[1]?.headers as Headers | undefined)?.get(
|
||||
"authorization",
|
||||
),
|
||||
).toBe("Bearer openrouter-key");
|
||||
expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://custom.openrouter.test/api/v1/videos/job-123/content?index=0",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
expect.any(Number),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ auditContext: "openrouter-video-download" }),
|
||||
);
|
||||
expect(
|
||||
(fetchWithTimeoutGuardedMock.mock.calls[1]?.[1]?.headers as Headers | undefined)?.get(
|
||||
"authorization",
|
||||
),
|
||||
).toBe("Bearer openrouter-key");
|
||||
expect(result.videos[0]?.buffer?.toString()).toBe("mp4-bytes");
|
||||
expect(result.videos[0]?.mimeType).toBe("video/mp4");
|
||||
expect(result.metadata).toEqual({
|
||||
jobId: "job-123",
|
||||
status: "completed",
|
||||
generationId: "gen-123",
|
||||
usage: { cost: 0.25, is_byok: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not forward auth headers to cross-origin polling URLs", async () => {
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
id: "job-123",
|
||||
polling_url: "https://polling.example.test/videos/job-123",
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
fetchWithTimeoutGuardedMock
|
||||
.mockResolvedValueOnce(
|
||||
releasedJson({
|
||||
id: "job-123",
|
||||
status: "completed",
|
||||
unsigned_urls: ["https://cdn.openrouter.test/video.mp4"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(releasedVideo({ contentType: "video/mp4", bytes: "mp4-bytes" }));
|
||||
|
||||
const provider = buildOpenRouterVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "openrouter",
|
||||
model: "google/veo-3.1",
|
||||
prompt: "A gentle camera pan across a neon reef",
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://polling.example.test/videos/job-123",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
expect.any(Number),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ auditContext: "openrouter-video-status" }),
|
||||
);
|
||||
expect(
|
||||
(fetchWithTimeoutGuardedMock.mock.calls[0]?.[1]?.headers as Headers | undefined)?.get(
|
||||
"authorization",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://cdn.openrouter.test/video.mp4",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
expect.any(Number),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ auditContext: "openrouter-video-download" }),
|
||||
);
|
||||
expect(
|
||||
(fetchWithTimeoutGuardedMock.mock.calls[1]?.[1]?.headers as Headers | undefined)?.get(
|
||||
"authorization",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the documented content endpoint when a completed job has no output URL", async () => {
|
||||
postJsonRequestMock.mockResolvedValue(
|
||||
releasedJson({
|
||||
id: "job-123",
|
||||
polling_url: "https://openrouter.ai/api/v1/videos/job-123",
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(
|
||||
releasedVideo({ contentType: "video/mp4", bytes: "mp4-bytes" }),
|
||||
);
|
||||
|
||||
const provider = buildOpenRouterVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "openrouter",
|
||||
model: "google/veo-3.1",
|
||||
prompt: "A tiny robot watering a bonsai",
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(fetchWithTimeoutGuardedMock).toHaveBeenCalledWith(
|
||||
"https://openrouter.ai/api/v1/videos/job-123/content?index=0",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
expect.any(Number),
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ auditContext: "openrouter-video-download" }),
|
||||
);
|
||||
expect(result.videos[0]?.buffer?.toString()).toBe("mp4-bytes");
|
||||
});
|
||||
|
||||
it("rejects video reference inputs", async () => {
|
||||
const provider = buildOpenRouterVideoGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "openrouter",
|
||||
model: "google/veo-3.1",
|
||||
prompt: "remix this clip",
|
||||
inputVideos: [{ url: "https://example.com/source.mp4", mimeType: "video/mp4" }],
|
||||
cfg: {} as never,
|
||||
}),
|
||||
).rejects.toThrow("does not support video reference inputs");
|
||||
});
|
||||
});
|
||||
478
extensions/openrouter/video-generation-provider.ts
Normal file
478
extensions/openrouter/video-generation-provider.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
createProviderOperationDeadline,
|
||||
fetchWithTimeoutGuarded,
|
||||
postJsonRequest,
|
||||
resolveProviderHttpRequestConfig,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
waitProviderOperationPollInterval,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type {
|
||||
GeneratedVideoAsset,
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
VideoGenerationSourceAsset,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
|
||||
|
||||
const DEFAULT_MODEL = "google/veo-3.1-fast";
|
||||
const DEFAULT_TIMEOUT_MS = 600_000;
|
||||
const DEFAULT_HTTP_TIMEOUT_MS = 60_000;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const MAX_POLL_ATTEMPTS = 120;
|
||||
const SUPPORTED_ASPECT_RATIOS = ["16:9", "9:16"] as const;
|
||||
const SUPPORTED_DURATION_SECONDS = [4, 6, 8] as const;
|
||||
const SUPPORTED_RESOLUTIONS = ["720P", "1080P"] as const;
|
||||
|
||||
type OpenRouterVideoResponse = {
|
||||
id?: string;
|
||||
generation_id?: string | null;
|
||||
polling_url?: string;
|
||||
status?: string;
|
||||
unsigned_urls?: string[];
|
||||
error?: string | null;
|
||||
model?: string | null;
|
||||
usage?: {
|
||||
cost?: number | null;
|
||||
is_byok?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type OpenRouterImagePart = {
|
||||
type: "image_url";
|
||||
image_url: { url: string };
|
||||
};
|
||||
|
||||
type OpenRouterFrameImagePart = OpenRouterImagePart & {
|
||||
frame_type: "first_frame" | "last_frame";
|
||||
};
|
||||
type GuardedFetchResult = Awaited<ReturnType<typeof fetchWithTimeoutGuarded>>;
|
||||
type FetchGuardOptions = NonNullable<Parameters<typeof fetchWithTimeoutGuarded>[4]>;
|
||||
type DispatcherPolicy = FetchGuardOptions["dispatcherPolicy"];
|
||||
|
||||
function toDataUrl(asset: VideoGenerationSourceAsset): string {
|
||||
if (asset.buffer) {
|
||||
const mimeType = normalizeOptionalString(asset.mimeType) ?? "image/png";
|
||||
return `data:${mimeType};base64,${asset.buffer.toString("base64")}`;
|
||||
}
|
||||
const url = normalizeOptionalString(asset.url);
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
throw new Error(
|
||||
"OpenRouter video generation requires image references to include a URL or buffer.",
|
||||
);
|
||||
}
|
||||
|
||||
function toImagePart(asset: VideoGenerationSourceAsset): OpenRouterImagePart {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: { url: toDataUrl(asset) },
|
||||
};
|
||||
}
|
||||
|
||||
function buildImageInputs(inputImages: VideoGenerationSourceAsset[] | undefined): {
|
||||
frameImages: OpenRouterFrameImagePart[];
|
||||
inputReferences: OpenRouterImagePart[];
|
||||
} {
|
||||
const frameImages: OpenRouterFrameImagePart[] = [];
|
||||
const inputReferences: OpenRouterImagePart[] = [];
|
||||
let hasFirstFrame = false;
|
||||
let hasLastFrame = false;
|
||||
|
||||
for (const image of inputImages ?? []) {
|
||||
const role = normalizeOptionalString(image.role);
|
||||
if (role === "reference_image") {
|
||||
inputReferences.push(toImagePart(image));
|
||||
continue;
|
||||
}
|
||||
|
||||
const frameType =
|
||||
role === "last_frame"
|
||||
? "last_frame"
|
||||
: role === "first_frame"
|
||||
? "first_frame"
|
||||
: hasFirstFrame
|
||||
? "last_frame"
|
||||
: "first_frame";
|
||||
|
||||
if (frameType === "first_frame" && !hasFirstFrame) {
|
||||
frameImages.push({ ...toImagePart(image), frame_type: "first_frame" });
|
||||
hasFirstFrame = true;
|
||||
continue;
|
||||
}
|
||||
if (frameType === "last_frame" && !hasLastFrame) {
|
||||
frameImages.push({ ...toImagePart(image), frame_type: "last_frame" });
|
||||
hasLastFrame = true;
|
||||
continue;
|
||||
}
|
||||
inputReferences.push(toImagePart(image));
|
||||
}
|
||||
|
||||
return { frameImages, inputReferences };
|
||||
}
|
||||
|
||||
function resolveDurationSeconds(durationSeconds: number | undefined): number | undefined {
|
||||
if (typeof durationSeconds !== "number" || !Number.isFinite(durationSeconds)) {
|
||||
return undefined;
|
||||
}
|
||||
const rounded = Math.max(1, Math.round(durationSeconds));
|
||||
return SUPPORTED_DURATION_SECONDS.reduce((best, current) => {
|
||||
const currentDistance = Math.abs(current - rounded);
|
||||
const bestDistance = Math.abs(best - rounded);
|
||||
if (currentDistance < bestDistance) {
|
||||
return current;
|
||||
}
|
||||
if (currentDistance === bestDistance && current > best) {
|
||||
return current;
|
||||
}
|
||||
return best;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveResolution(resolution: VideoGenerationRequest["resolution"]): string | undefined {
|
||||
const normalized = normalizeOptionalString(resolution);
|
||||
return normalized ? normalized.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
function buildRequestBody(req: VideoGenerationRequest, model: string): Record<string, unknown> {
|
||||
const { frameImages, inputReferences } = buildImageInputs(req.inputImages);
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
prompt: req.prompt,
|
||||
};
|
||||
|
||||
const duration = resolveDurationSeconds(req.durationSeconds);
|
||||
if (duration != null) {
|
||||
body.duration = duration;
|
||||
}
|
||||
const resolution = resolveResolution(req.resolution);
|
||||
if (resolution) {
|
||||
body.resolution = resolution;
|
||||
}
|
||||
const aspectRatio = normalizeOptionalString(req.aspectRatio);
|
||||
if (aspectRatio) {
|
||||
body.aspect_ratio = aspectRatio;
|
||||
}
|
||||
const size = normalizeOptionalString(req.size);
|
||||
if (size) {
|
||||
body.size = size;
|
||||
}
|
||||
if (typeof req.audio === "boolean") {
|
||||
body.generate_audio = req.audio;
|
||||
}
|
||||
if (frameImages.length > 0) {
|
||||
body.frame_images = frameImages;
|
||||
}
|
||||
if (inputReferences.length > 0) {
|
||||
body.input_references = inputReferences;
|
||||
}
|
||||
|
||||
const seed = typeof req.providerOptions?.seed === "number" ? req.providerOptions.seed : undefined;
|
||||
if (seed != null) {
|
||||
body.seed = Math.trunc(seed);
|
||||
}
|
||||
const callbackUrl =
|
||||
typeof req.providerOptions?.callback_url === "string"
|
||||
? normalizeOptionalString(req.providerOptions.callback_url)
|
||||
: undefined;
|
||||
if (callbackUrl) {
|
||||
body.callback_url = callbackUrl;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function isTerminalFailure(status: string | undefined): boolean {
|
||||
return status === "failed" || status === "cancelled" || status === "expired";
|
||||
}
|
||||
|
||||
async function fetchOpenRouterJson(params: {
|
||||
url: string;
|
||||
baseUrl: string;
|
||||
headers: Headers;
|
||||
timeoutMs: number;
|
||||
allowPrivateNetwork: boolean;
|
||||
dispatcherPolicy: DispatcherPolicy;
|
||||
errorContext: string;
|
||||
auditContext: string;
|
||||
}): Promise<OpenRouterVideoResponse> {
|
||||
const { response, release } = await fetchOpenRouterGet(params);
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, params.errorContext);
|
||||
return (await response.json()) as OpenRouterVideoResponse;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOpenRouterVideo(params: {
|
||||
pollingUrl: string;
|
||||
baseUrl: string;
|
||||
headers: Headers;
|
||||
timeoutMs: number;
|
||||
allowPrivateNetwork: boolean;
|
||||
dispatcherPolicy: DispatcherPolicy;
|
||||
}): Promise<OpenRouterVideoResponse> {
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: params.timeoutMs,
|
||||
label: "OpenRouter video generation",
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||
const payload = await fetchOpenRouterJson({
|
||||
url: params.pollingUrl,
|
||||
baseUrl: params.baseUrl,
|
||||
headers: params.headers,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
|
||||
}),
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
dispatcherPolicy: params.dispatcherPolicy,
|
||||
errorContext: "OpenRouter video status request failed",
|
||||
auditContext: "openrouter-video-status",
|
||||
});
|
||||
const status = normalizeOptionalString(payload.status);
|
||||
if (status === "completed") {
|
||||
return payload;
|
||||
}
|
||||
if (isTerminalFailure(status)) {
|
||||
throw new Error(
|
||||
normalizeOptionalString(payload.error) ?? `OpenRouter video generation ${status}`,
|
||||
);
|
||||
}
|
||||
await waitProviderOperationPollInterval({
|
||||
deadline,
|
||||
pollIntervalMs: POLL_INTERVAL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error("OpenRouter video generation did not finish in time");
|
||||
}
|
||||
|
||||
function headersForOpenRouterGet(url: string, baseUrl: string, requestHeaders: Headers): Headers {
|
||||
try {
|
||||
if (new URL(url).origin !== new URL(baseUrl).origin) {
|
||||
return new Headers();
|
||||
}
|
||||
} catch {
|
||||
return new Headers();
|
||||
}
|
||||
const headers = new Headers(requestHeaders);
|
||||
headers.delete("content-type");
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchOpenRouterGet(params: {
|
||||
url: string;
|
||||
baseUrl: string;
|
||||
headers: Headers;
|
||||
timeoutMs: number;
|
||||
allowPrivateNetwork: boolean;
|
||||
dispatcherPolicy: DispatcherPolicy;
|
||||
auditContext: string;
|
||||
}): Promise<GuardedFetchResult> {
|
||||
const url = resolveOpenRouterResponseUrl(params.url, params.baseUrl);
|
||||
return await fetchWithTimeoutGuarded(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
headers: headersForOpenRouterGet(url, params.baseUrl, params.headers),
|
||||
},
|
||||
params.timeoutMs,
|
||||
fetch,
|
||||
{
|
||||
...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
|
||||
...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}),
|
||||
auditContext: params.auditContext,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOpenRouterResponseUrl(url: string, baseUrl: string): string {
|
||||
return new URL(url, `${baseUrl}/`).href;
|
||||
}
|
||||
|
||||
function resolveOpenRouterContentUrl(params: { baseUrl: string; jobId: string }): string {
|
||||
return new URL(`videos/${encodeURIComponent(params.jobId)}/content?index=0`, `${params.baseUrl}/`)
|
||||
.href;
|
||||
}
|
||||
|
||||
async function downloadOpenRouterVideo(params: {
|
||||
url: string;
|
||||
baseUrl: string;
|
||||
headers: Headers;
|
||||
timeoutMs: number;
|
||||
allowPrivateNetwork: boolean;
|
||||
dispatcherPolicy: DispatcherPolicy;
|
||||
}): Promise<GeneratedVideoAsset> {
|
||||
const { response, release } = await fetchOpenRouterGet({
|
||||
...params,
|
||||
auditContext: "openrouter-video-download",
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "OpenRouter generated video download failed");
|
||||
const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4";
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
buffer,
|
||||
mimeType,
|
||||
fileName: `video-1.${mimeType.includes("webm") ? "webm" : "mp4"}`,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOpenRouterVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return {
|
||||
id: "openrouter",
|
||||
label: "OpenRouter",
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
models: [DEFAULT_MODEL],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({ provider: "openrouter", agentDir }),
|
||||
capabilities: {
|
||||
providerOptions: {
|
||||
callback_url: "string",
|
||||
seed: "number",
|
||||
},
|
||||
generate: {
|
||||
maxVideos: 1,
|
||||
supportedDurationSeconds: [...SUPPORTED_DURATION_SECONDS],
|
||||
supportsAspectRatio: true,
|
||||
supportsResolution: true,
|
||||
supportsSize: true,
|
||||
supportsAudio: true,
|
||||
aspectRatios: [...SUPPORTED_ASPECT_RATIOS],
|
||||
resolutions: [...SUPPORTED_RESOLUTIONS],
|
||||
},
|
||||
imageToVideo: {
|
||||
enabled: true,
|
||||
maxVideos: 1,
|
||||
maxInputImages: 4,
|
||||
supportedDurationSeconds: [...SUPPORTED_DURATION_SECONDS],
|
||||
supportsAspectRatio: true,
|
||||
supportsResolution: true,
|
||||
supportsSize: true,
|
||||
supportsAudio: true,
|
||||
aspectRatios: [...SUPPORTED_ASPECT_RATIOS],
|
||||
resolutions: [...SUPPORTED_RESOLUTIONS],
|
||||
},
|
||||
videoToVideo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
async generateVideo(req) {
|
||||
if ((req.inputVideos?.length ?? 0) > 0) {
|
||||
throw new Error("OpenRouter video generation does not support video reference inputs.");
|
||||
}
|
||||
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: "openrouter",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("OpenRouter API key missing");
|
||||
}
|
||||
|
||||
const model = normalizeOptionalString(req.model) ?? DEFAULT_MODEL;
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: req.cfg?.models?.providers?.openrouter?.baseUrl,
|
||||
defaultBaseUrl: OPENROUTER_BASE_URL,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
request: sanitizeConfiguredModelProviderRequest(
|
||||
req.cfg?.models?.providers?.openrouter?.request,
|
||||
),
|
||||
provider: "openrouter",
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
});
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: req.timeoutMs,
|
||||
label: "OpenRouter video generation",
|
||||
});
|
||||
const { response, release } = await postJsonRequest({
|
||||
url: `${baseUrl}/videos`,
|
||||
headers,
|
||||
body: buildRequestBody(req, model),
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
auditContext: "openrouter-video-submit",
|
||||
});
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "OpenRouter video generation failed");
|
||||
const submitted = (await response.json()) as OpenRouterVideoResponse;
|
||||
const jobId = normalizeOptionalString(submitted.id);
|
||||
const pollingUrl = normalizeOptionalString(submitted.polling_url);
|
||||
if (!jobId || !pollingUrl) {
|
||||
throw new Error("OpenRouter video generation response missing job details");
|
||||
}
|
||||
const completed =
|
||||
normalizeOptionalString(submitted.status) === "completed"
|
||||
? submitted
|
||||
: await pollOpenRouterVideo({
|
||||
pollingUrl,
|
||||
baseUrl,
|
||||
headers,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
const completedJobId = normalizeOptionalString(completed.id) ?? jobId;
|
||||
const videoUrl =
|
||||
completed.unsigned_urls?.find((url) => normalizeOptionalString(url)) ??
|
||||
resolveOpenRouterContentUrl({ baseUrl, jobId: completedJobId });
|
||||
const video = await downloadOpenRouterVideo({
|
||||
url: videoUrl,
|
||||
baseUrl,
|
||||
headers,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
|
||||
}),
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
return {
|
||||
videos: [video],
|
||||
model: normalizeOptionalString(completed.model) ?? model,
|
||||
metadata: {
|
||||
jobId,
|
||||
status: completed.status,
|
||||
...(normalizeOptionalString(completed.generation_id)
|
||||
? { generationId: normalizeOptionalString(completed.generation_id) }
|
||||
: {}),
|
||||
...(completed.usage ? { usage: completed.usage } : {}),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import falPlugin from "./fal/index.js";
|
||||
import googlePlugin from "./google/index.js";
|
||||
import minimaxPlugin from "./minimax/index.js";
|
||||
import openaiPlugin from "./openai/index.js";
|
||||
import openrouterPlugin from "./openrouter/index.js";
|
||||
import qwenPlugin from "./qwen/index.js";
|
||||
import runwayPlugin from "./runway/index.js";
|
||||
import { maybeLoadShellEnvForGenerationProviders } from "./test-support/generation-live-test-helpers.js";
|
||||
@@ -120,6 +121,12 @@ const CASES: LiveProviderCase[] = [
|
||||
providerId: "minimax",
|
||||
},
|
||||
{ plugin: openaiPlugin, pluginId: "openai", pluginName: "OpenAI Provider", providerId: "openai" },
|
||||
{
|
||||
plugin: openrouterPlugin,
|
||||
pluginId: "openrouter",
|
||||
pluginName: "OpenRouter Provider",
|
||||
providerId: "openrouter",
|
||||
},
|
||||
{ plugin: qwenPlugin, pluginId: "qwen", pluginName: "Qwen Provider", providerId: "qwen" },
|
||||
{ plugin: runwayPlugin, pluginId: "runway", pluginName: "Runway Provider", providerId: "runway" },
|
||||
{
|
||||
|
||||
@@ -75,6 +75,14 @@ describe("Z.ai vendor error codes (#48988)", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("OpenRouter high-load text is classified as overloaded", () => {
|
||||
expect(
|
||||
isOverloadedErrorMessage(
|
||||
"The service is currently experiencing high load and cannot process your request.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("billing still classified correctly", () => {
|
||||
expect(isBillingErrorMessage("insufficient credits")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -95,6 +95,7 @@ const ERROR_PATTERNS = {
|
||||
// provider-overload (#32828).
|
||||
/service[_ ]unavailable.*(?:overload|capacity|high[_ ]demand)|(?:overload|capacity|high[_ ]demand).*service[_ ]unavailable/i,
|
||||
"high demand",
|
||||
"high load",
|
||||
// Chinese provider overloaded messages
|
||||
"服务过载",
|
||||
"当前负载过高",
|
||||
|
||||
@@ -10,6 +10,7 @@ const EXPECTED_BUNDLED_VIDEO_PROVIDER_PLUGIN_IDS = [
|
||||
"google",
|
||||
"minimax",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"qwen",
|
||||
"runway",
|
||||
"together",
|
||||
|
||||
@@ -115,8 +115,10 @@ export const pluginRegistrationContractCases = {
|
||||
providerIds: ["openrouter"],
|
||||
mediaUnderstandingProviderIds: ["openrouter"],
|
||||
imageGenerationProviderIds: ["openrouter"],
|
||||
videoGenerationProviderIds: ["openrouter"],
|
||||
requireDescribeImages: true,
|
||||
requireGenerateImage: true,
|
||||
requireGenerateVideo: true,
|
||||
},
|
||||
perplexity: {
|
||||
pluginId: "perplexity",
|
||||
|
||||
56
src/plugins/manifest-metadata-scan.test.ts
Normal file
56
src/plugins/manifest-metadata-scan.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function createTempRoot(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-manifest-metadata-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
describe("listOpenClawPluginManifestMetadata", () => {
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the active bundled manifest over stale persisted bundled installs", () => {
|
||||
const root = createTempRoot();
|
||||
const home = path.join(root, "home");
|
||||
const bundledRoot = path.join(root, "extensions");
|
||||
const staleBundledRoot = path.join(root, "stale", "extensions");
|
||||
|
||||
writeJson(path.join(bundledRoot, "openai", "openclaw.plugin.json"), {
|
||||
id: "openai",
|
||||
providerEndpoints: [{ endpointClass: "openai-public", hosts: ["api.openai.com"] }],
|
||||
});
|
||||
writeJson(path.join(staleBundledRoot, "openai", "openclaw.plugin.json"), {
|
||||
id: "openai",
|
||||
providers: ["openai"],
|
||||
});
|
||||
writeJson(path.join(home, ".openclaw", "plugins", "installs.json"), {
|
||||
plugins: [{ rootDir: path.join(staleBundledRoot, "openai"), origin: "bundled" }],
|
||||
});
|
||||
|
||||
const records = listOpenClawPluginManifestMetadata({
|
||||
OPENCLAW_HOME: home,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
|
||||
});
|
||||
|
||||
const openai = records.find((record) => record.manifest.id === "openai");
|
||||
expect(openai?.pluginDir).toBe(path.join(bundledRoot, "openai"));
|
||||
expect(openai?.manifest.providerEndpoints).toEqual([
|
||||
{ endpointClass: "openai-public", hosts: ["api.openai.com"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,7 @@ function listPersistedIndexPluginDirs(env: NodeJS.ProcessEnv, startOrder: number
|
||||
}
|
||||
dirs.push({
|
||||
pluginDir: resolveUserPath(rootDir, env),
|
||||
rank: rawPlugin.origin === "bundled" ? 2 : 1,
|
||||
rank: rawPlugin.origin === "bundled" ? 3 : 1,
|
||||
order: order++,
|
||||
origin: normalizeTrimmedString(rawPlugin.origin),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-re
|
||||
import type { PluginRegistrySnapshot } from "./plugin-registry.js";
|
||||
|
||||
const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn());
|
||||
const listExplicitlyDisabledChannelIdsForConfig = vi.hoisted(() => vi.fn());
|
||||
const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../channels/config-presence.js", () => ({
|
||||
@@ -20,7 +21,8 @@ vi.mock("../channels/config-presence.js", () => ({
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: { includePersistedAuthState?: boolean },
|
||||
) => listPotentialConfiguredChannelIds(config, env, options),
|
||||
listExplicitlyDisabledChannelIdsForConfig: () => [],
|
||||
listExplicitlyDisabledChannelIdsForConfig: (config: OpenClawConfig) =>
|
||||
listExplicitlyDisabledChannelIdsForConfig(config),
|
||||
}));
|
||||
|
||||
vi.mock("./manifest-registry-installed.js", async (importOriginal) => {
|
||||
@@ -102,6 +104,7 @@ describe("loadPluginLookUpTable", () => {
|
||||
listPotentialConfiguredChannelIds
|
||||
.mockReset()
|
||||
.mockImplementation((config: OpenClawConfig) => Object.keys(config.channels ?? {}));
|
||||
listExplicitlyDisabledChannelIdsForConfig.mockReset().mockReturnValue([]);
|
||||
loadPluginManifestRegistryForInstalledIndex.mockReset();
|
||||
});
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("secret target registry fast path", () => {
|
||||
expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({
|
||||
dirName: "googlechat",
|
||||
artifactBasename: "secret-contract-api.js",
|
||||
installRuntimeDeps: false,
|
||||
});
|
||||
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export const DEFAULT_LIVE_VIDEO_MODELS: Record<string, string> = {
|
||||
google: "google/veo-3.1-fast-generate-preview",
|
||||
minimax: "minimax/MiniMax-Hailuo-2.3",
|
||||
openai: "openai/sora-2",
|
||||
openrouter: "openrouter/google/veo-3.1-fast",
|
||||
qwen: "qwen/wan2.6-t2v",
|
||||
runway: "runway/gen4.5",
|
||||
together: "together/Wan-AI/Wan2.2-T2V-A14B",
|
||||
@@ -31,11 +32,14 @@ const BUFFER_BACKED_IMAGE_TO_VIDEO_UNSUPPORTED_PROVIDERS = new Set(["vydra"]);
|
||||
export function resolveLiveVideoResolution(params: {
|
||||
providerId: string;
|
||||
modelRef: string;
|
||||
}): "480P" | "768P" | "1080P" {
|
||||
}): "480P" | "720P" | "768P" | "1080P" {
|
||||
const providerId = normalizeLowercaseStringOrEmpty(params.providerId);
|
||||
if (providerId === "minimax") {
|
||||
return "768P";
|
||||
}
|
||||
if (providerId === "openrouter") {
|
||||
return "720P";
|
||||
}
|
||||
return "480P";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user