From 17ef9ef895ad84b586a0c4311490022da50964e5 Mon Sep 17 00:00:00 2001 From: Gabriel Kripalani <82028676+notamicrodose@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:57:31 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + docs/providers/openrouter.md | 28 + docs/tools/media-overview.md | 1 + docs/tools/video-generation.md | 43 +- extensions/openrouter/index.test.ts | 3 +- extensions/openrouter/index.ts | 2 + extensions/openrouter/openclaw.plugin.json | 1 + .../video-generation-provider.test.ts | 332 ++++++++++++ .../openrouter/video-generation-provider.ts | 478 ++++++++++++++++++ .../video-generation-providers.live.test.ts | 7 + .../failover-matches.test.ts | 8 + .../pi-embedded-helpers/failover-matches.ts | 1 + .../provider-capabilities.contract.test.ts | 1 + .../plugin-registration-contract-cases.ts | 2 + src/plugins/manifest-metadata-scan.test.ts | 56 ++ src/plugins/manifest-metadata-scan.ts | 2 +- src/plugins/plugin-lookup-table.test.ts | 5 +- src/secrets/target-registry.fast-path.test.ts | 1 + src/video-generation/live-test-helpers.ts | 6 +- 19 files changed, 957 insertions(+), 21 deletions(-) create mode 100644 extensions/openrouter/video-generation-provider.test.ts create mode 100644 extensions/openrouter/video-generation-provider.ts create mode 100644 src/plugins/manifest-metadata-scan.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d22e1c702..09cc00ffbd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index da030f663a0..9ef2957aad4 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -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 diff --git a/docs/tools/media-overview.md b/docs/tools/media-overview.md index a7cc26cf875..5feb8b5ddf4 100644 --- a/docs/tools/media-overview.md +++ b/docs/tools/media-overview.md @@ -61,6 +61,7 @@ provider is configured. | MiniMax | ✓ | ✓ | ✓ | ✓ | | | | | Mistral | | | | | ✓ | | | | OpenAI | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | +| OpenRouter | ✓ | ✓ | | ✓ | | | ✓ | | Qwen | | ✓ | | | | | | | Runway | | ✓ | | | | | | | SenseAudio | | | | | ✓ | | | diff --git a/docs/tools/video-generation.md b/docs/tools/video-generation.md index ac9d65cb26c..7370a2496ad 100644 --- a/docs/tools/video-generation.md +++ b/docs/tools/video-generation.md @@ -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. + + 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. + Same DashScope backend as Alibaba. Reference inputs must be remote `http(s)` URLs; local files are rejected upfront. diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index f540f332759..66d77857d1e 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -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", () => { diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 717e5dcf6de..4fa371bb62b 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -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()); }, }); diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index f366ac57b3e..eaf60343f69 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -56,6 +56,7 @@ "contracts": { "mediaUnderstandingProviders": ["openrouter"], "imageGenerationProviders": ["openrouter"], + "videoGenerationProviders": ["openrouter"], "speechProviders": ["openrouter"] }, "mediaUnderstandingProviderMetadata": { diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts new file mode 100644 index 00000000000..0a5722bf3a5 --- /dev/null +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -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) => ({ + 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( + "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"); + }); +}); diff --git a/extensions/openrouter/video-generation-provider.ts b/extensions/openrouter/video-generation-provider.ts new file mode 100644 index 00000000000..59e996cfca9 --- /dev/null +++ b/extensions/openrouter/video-generation-provider.ts @@ -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>; +type FetchGuardOptions = NonNullable[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 { + const { frameImages, inputReferences } = buildImageInputs(req.inputImages); + const body: Record = { + 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 { + 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 { + 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 { + 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 { + 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(); + } + }, + }; +} diff --git a/extensions/video-generation-providers.live.test.ts b/extensions/video-generation-providers.live.test.ts index 91ae29db3b8..364b17bb4df 100644 --- a/extensions/video-generation-providers.live.test.ts +++ b/extensions/video-generation-providers.live.test.ts @@ -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" }, { diff --git a/src/agents/pi-embedded-helpers/failover-matches.test.ts b/src/agents/pi-embedded-helpers/failover-matches.test.ts index a7b7c0928af..c60de3e0494 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.test.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.test.ts @@ -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); }); diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index 0b0773e3f26..bb3ed5ad1ed 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -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 "服务过载", "当前负载过高", diff --git a/src/media-generation/provider-capabilities.contract.test.ts b/src/media-generation/provider-capabilities.contract.test.ts index 54aa5f48bbb..3cfe301854c 100644 --- a/src/media-generation/provider-capabilities.contract.test.ts +++ b/src/media-generation/provider-capabilities.contract.test.ts @@ -10,6 +10,7 @@ const EXPECTED_BUNDLED_VIDEO_PROVIDER_PLUGIN_IDS = [ "google", "minimax", "openai", + "openrouter", "qwen", "runway", "together", diff --git a/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts b/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts index 04f9def91a6..4133a90c5c2 100644 --- a/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts +++ b/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts @@ -115,8 +115,10 @@ export const pluginRegistrationContractCases = { providerIds: ["openrouter"], mediaUnderstandingProviderIds: ["openrouter"], imageGenerationProviderIds: ["openrouter"], + videoGenerationProviderIds: ["openrouter"], requireDescribeImages: true, requireGenerateImage: true, + requireGenerateVideo: true, }, perplexity: { pluginId: "perplexity", diff --git a/src/plugins/manifest-metadata-scan.test.ts b/src/plugins/manifest-metadata-scan.test.ts new file mode 100644 index 00000000000..4c1d5002728 --- /dev/null +++ b/src/plugins/manifest-metadata-scan.test.ts @@ -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"] }, + ]); + }); +}); diff --git a/src/plugins/manifest-metadata-scan.ts b/src/plugins/manifest-metadata-scan.ts index 30627eb5af4..86afadb87c3 100644 --- a/src/plugins/manifest-metadata-scan.ts +++ b/src/plugins/manifest-metadata-scan.ts @@ -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), }); diff --git a/src/plugins/plugin-lookup-table.test.ts b/src/plugins/plugin-lookup-table.test.ts index 24bf9a86603..8ce6542cb30 100644 --- a/src/plugins/plugin-lookup-table.test.ts +++ b/src/plugins/plugin-lookup-table.test.ts @@ -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(); }); diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index 98f1567087c..e172efbbee1 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -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(); }); diff --git a/src/video-generation/live-test-helpers.ts b/src/video-generation/live-test-helpers.ts index 004517fd364..eb431018461 100644 --- a/src/video-generation/live-test-helpers.ts +++ b/src/video-generation/live-test-helpers.ts @@ -18,6 +18,7 @@ export const DEFAULT_LIVE_VIDEO_MODELS: Record = { 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"; }