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

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

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

View File

@@ -0,0 +1,13 @@
// Openrouter API module exposes the plugin public contract.
export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
export { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
export {
buildOpenrouterProvider,
isOpenRouterProxyReasoningUnsupportedModel,
} from "./provider-catalog.js";
export { buildOpenRouterSpeechProvider } from "./speech-provider.js";
export {
applyOpenrouterConfig,
applyOpenrouterProviderConfig,
OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";

View File

@@ -0,0 +1,364 @@
// Openrouter tests cover image generation provider plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildOpenRouterImageGenerationProvider,
extractOpenRouterImagesFromResponse,
} from "./image-generation-provider.js";
const {
assertOkOrThrowHttpErrorMock,
postJsonRequestMock,
resolveApiKeyForProviderMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
postJsonRequestMock: vi.fn(),
resolveApiKeyForProviderMock: vi.fn(async (_params: unknown) => ({
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,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
// Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests.
readProviderJsonResponse: async (response: { json(): Promise<unknown> }) => response.json(),
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
function requireOpenRouterPostBody(): {
messages?: Array<{ content?: unknown }>;
} {
const request = requireOpenRouterPostRequest();
return request.body as { messages?: Array<{ content?: unknown }> };
}
function requireOpenRouterPostRequest(): Record<string, unknown> {
const [call] = postJsonRequestMock.mock.calls;
if (!call) {
throw new Error("expected OpenRouter image generation request");
}
const [request] = call;
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected OpenRouter image generation request");
}
return request as Record<string, unknown>;
}
function requireOpenRouterConfigRequest(): Record<string, unknown> {
const [call] = resolveProviderHttpRequestConfigMock.mock.calls;
if (!call) {
throw new Error("expected OpenRouter image config request");
}
const [request] = call;
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected OpenRouter image config request");
}
return request;
}
function requireHeaders(value: unknown): Headers {
if (!(value instanceof Headers)) {
throw new Error("expected OpenRouter image request headers");
}
return value;
}
function requireGeneratedImage(
result: Awaited<
ReturnType<ReturnType<typeof buildOpenRouterImageGenerationProvider>["generateImage"]>
>,
index: number,
) {
const image = result.images[index];
if (!image) {
throw new Error(`expected OpenRouter generated image at index ${index}`);
}
return image;
}
describe("openrouter image generation provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
postJsonRequestMock.mockReset();
resolveApiKeyForProviderMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("builds provider metadata and capabilities", () => {
const provider = buildOpenRouterImageGenerationProvider();
expect(provider.id).toBe("openrouter");
expect(provider.label).toBe("OpenRouter");
expect(provider.defaultModel).toBe("google/gemini-3.1-flash-image-preview");
expect(provider.models).toContain("google/gemini-3-pro-image-preview");
expect(provider.capabilities.generate.maxCount).toBe(4);
expect(provider.capabilities.generate.supportsAspectRatio).toBe(true);
expect(provider.capabilities.edit.enabled).toBe(true);
expect(provider.capabilities.edit.maxInputImages).toBe(5);
});
it("sends chat completion image requests with Gemini image config and count", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
choices: [
{
message: {
images: [
{
imageUrl: {
url: `data:image/png;base64,${Buffer.from("png-one").toString("base64")}`,
},
},
],
},
},
],
}),
},
release,
});
const provider = buildOpenRouterImageGenerationProvider();
const result = await provider.generateImage({
provider: "openrouter",
model: "google/gemini-3.1-flash-image-preview",
prompt: "draw a sticker",
aspectRatio: "16:9",
resolution: "2K",
count: 2,
timeoutMs: 12_345,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
cfg: {
models: {
providers: {
openrouter: {
baseUrl: "https://custom.openrouter.test/api/v1",
models: [],
},
},
},
},
});
expect(resolveApiKeyForProviderMock).toHaveBeenCalledOnce();
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({
provider: "openrouter",
cfg: {
models: {
providers: {
openrouter: {
baseUrl: "https://custom.openrouter.test/api/v1",
models: [],
},
},
},
},
agentDir: undefined,
store: undefined,
});
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledOnce();
expect(requireOpenRouterConfigRequest()).toEqual({
baseUrl: "https://custom.openrouter.test/api/v1",
defaultBaseUrl: "https://openrouter.ai/api/v1",
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: "Bearer openrouter-key",
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
provider: "openrouter",
capability: "image",
transport: "http",
});
expect(postJsonRequestMock).toHaveBeenCalledOnce();
const request = requireOpenRouterPostRequest();
const headers = requireHeaders(request.headers);
expect(Object.fromEntries(headers.entries())).toEqual({
authorization: "Bearer openrouter-key",
"http-referer": "https://openclaw.ai",
"x-openrouter-title": "OpenClaw",
});
expect(request).toEqual({
url: "https://custom.openrouter.test/api/v1/chat/completions",
headers,
body: {
model: "google/gemini-3.1-flash-image-preview",
messages: [
{
role: "user",
content: "draw a sticker",
},
],
modalities: ["image", "text"],
n: 2,
image_config: {
aspect_ratio: "16:9",
image_size: "2K",
},
},
timeoutMs: 12_345,
fetchFn: fetch,
allowPrivateNetwork: false,
ssrfPolicy: { allowRfc2544BenchmarkRange: true },
dispatcherPolicy: undefined,
});
const image = requireGeneratedImage(result, 0);
expect(image.buffer.toString()).toBe("png-one");
expect(image.mimeType).toBe("image/png");
expect(release).toHaveBeenCalledOnce();
});
it("uses a 180s default timeout when no request timeout is provided", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
choices: [
{
message: {
images: [
{
imageUrl: {
url: `data:image/png;base64,${Buffer.from("png-one").toString("base64")}`,
},
},
],
},
},
],
}),
},
release,
});
const provider = buildOpenRouterImageGenerationProvider();
await provider.generateImage({
provider: "openrouter",
model: "google/gemini-3.1-flash-image-preview",
prompt: "draw a sticker",
cfg: {},
});
expect(postJsonRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 180_000,
}),
);
});
it("sends reference images as data URLs for edit-style requests", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
choices: [
{
message: {
content: [
{
type: "image_url",
image_url: {
url: `data:image/webp;base64,${Buffer.from("webp-one").toString("base64")}`,
},
},
],
},
},
],
}),
},
release: vi.fn(async () => {}),
});
const provider = buildOpenRouterImageGenerationProvider();
const result = await provider.generateImage({
provider: "openrouter",
model: "google/gemini-3.1-flash-image-preview",
prompt: "turn this into watercolor",
inputImages: [{ buffer: Buffer.from("source-image"), mimeType: "image/png" }],
cfg: {},
});
const body = requireOpenRouterPostBody();
expect(body.messages?.[0]?.content).toEqual([
{ type: "text", text: "turn this into watercolor" },
{
type: "image_url",
image_url: {
url: `data:image/png;base64,${Buffer.from("source-image").toString("base64")}`,
},
},
]);
const image = requireGeneratedImage(result, 0);
expect(image.buffer.toString()).toBe("webp-one");
expect(image.mimeType).toBe("image/webp");
});
it("wraps wrong-shape successful OpenRouter image responses", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ choices: { message: {} } }),
},
release: vi.fn(async () => {}),
});
const provider = buildOpenRouterImageGenerationProvider();
await expect(
provider.generateImage({
provider: "openrouter",
model: "google/gemini-3.1-flash-image-preview",
prompt: "bad shape",
cfg: {},
}),
).rejects.toThrow("OpenRouter image generation response malformed");
});
it("extracts image fallbacks from string content and raw b64 parts", () => {
const png = Buffer.from("png-inline").toString("base64");
const raw = Buffer.from("raw-inline").toString("base64");
const images = extractOpenRouterImagesFromResponse({
choices: [
{
message: {
content: `done data:image/png;base64,${png}`,
},
},
{
message: {
content: [{ b64_json: raw }],
},
},
],
});
expect(images.map((image) => image.buffer.toString())).toEqual(["png-inline", "raw-inline"]);
});
it("rejects invalid raw image parts in strict extraction mode", () => {
expect(() =>
extractOpenRouterImagesFromResponse(
{
choices: [
{
message: {
content: [{ b64_json: "not-base64!" }],
},
},
],
},
{ malformedResponseError: "OpenRouter image generation response malformed" },
),
).toThrow("OpenRouter image generation response malformed");
});
});

View File

@@ -0,0 +1,362 @@
// Openrouter provider module implements model/runtime integration.
import type {
GeneratedImageAsset,
ImageGenerationProvider,
ImageGenerationRequest,
} from "openclaw/plugin-sdk/image-generation";
import {
generatedImageAssetFromBase64,
generatedImageAssetFromDataUrl,
resolveInlineImageJsonResponseMaxBytes,
toImageDataUrl,
} from "openclaw/plugin-sdk/image-generation";
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const MAX_IMAGE_RESULTS = 4;
const MB = 1024 * 1024;
const SUPPORTED_MODELS = [
DEFAULT_MODEL,
"google/gemini-3-pro-image-preview",
"openai/gpt-5.4-image-2",
] as const;
const SUPPORTED_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
] as const;
const OPENROUTER_IMAGE_MALFORMED_RESPONSE = "OpenRouter image generation response malformed";
function throwMalformedOpenRouterImageResponse(message: string | undefined): never | undefined {
if (message) {
throw new Error(message);
}
return undefined;
}
function pushDataUrlImage(
images: GeneratedImageAsset[],
dataUrl: string,
malformedResponseError?: string,
): void {
const image = generatedImageAssetFromDataUrl({ dataUrl, index: images.length });
if (!image) {
throwMalformedOpenRouterImageResponse(malformedResponseError);
return;
}
images.push(image);
}
function extractImagesFromPart(
images: GeneratedImageAsset[],
part: unknown,
malformedResponseError?: string,
): void {
if (!isRecord(part)) {
throwMalformedOpenRouterImageResponse(malformedResponseError);
return;
}
if (part.type === "text") {
return;
}
if (part.type === "image_url") {
const imageUrl = part.image_url ?? part.imageUrl;
if (!isRecord(imageUrl)) {
throwMalformedOpenRouterImageResponse(malformedResponseError);
return;
}
const url = normalizeOptionalString(imageUrl.url);
if (url) {
pushDataUrlImage(images, url, malformedResponseError);
return;
}
throwMalformedOpenRouterImageResponse(malformedResponseError);
return;
}
const rawBase64 = normalizeOptionalString(part.b64_json);
if (rawBase64) {
const image = generatedImageAssetFromBase64({ base64: rawBase64, index: images.length });
if (image) {
images.push(image);
return;
}
throwMalformedOpenRouterImageResponse(malformedResponseError);
return;
}
if ("b64_json" in part) {
throwMalformedOpenRouterImageResponse(malformedResponseError);
return;
}
const inlineData = part.inlineData ?? part.inline_data;
if (inlineData === undefined || inlineData === null) {
return;
}
if (!isRecord(inlineData)) {
throwMalformedOpenRouterImageResponse(malformedResponseError);
return;
}
const data = normalizeOptionalString(inlineData.data);
if (!data) {
throwMalformedOpenRouterImageResponse(malformedResponseError);
return;
}
const mimeType =
normalizeOptionalString(inlineData.mimeType) ??
normalizeOptionalString(inlineData.mime_type) ??
"image/png";
const image = generatedImageAssetFromBase64({
base64: data,
index: images.length,
mimeType,
});
if (image) {
images.push(image);
return;
}
throwMalformedOpenRouterImageResponse(malformedResponseError);
}
export function extractOpenRouterImagesFromResponse(
body: unknown,
options: { malformedResponseError?: string } = {},
): GeneratedImageAsset[] {
if (!isRecord(body)) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
return [];
}
const choices = body.choices;
if (choices === undefined || choices === null) {
return [];
}
if (!Array.isArray(choices)) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
return [];
}
const images: GeneratedImageAsset[] = [];
for (const choice of choices) {
if (!isRecord(choice)) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
continue;
}
const message = choice.message;
if (message === undefined || message === null) {
continue;
}
if (!isRecord(message)) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
continue;
}
const messageImages = message.images;
if (messageImages !== undefined && messageImages !== null) {
if (!Array.isArray(messageImages)) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
continue;
}
for (const entry of messageImages) {
if (!isRecord(entry)) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
continue;
}
const imageUrl = entry.image_url ?? entry.imageUrl;
if (!isRecord(imageUrl)) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
continue;
}
const url = normalizeOptionalString(imageUrl.url);
if (!url) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
continue;
}
pushDataUrlImage(images, url, options.malformedResponseError);
}
}
const content = message.content;
if (typeof content === "string" && content.length > 0) {
const dataUrlPattern = /data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/g;
for (const match of content.matchAll(dataUrlPattern)) {
pushDataUrlImage(images, match[0]);
}
} else if (Array.isArray(content)) {
for (const part of content) {
extractImagesFromPart(images, part, options.malformedResponseError);
}
} else if (content !== undefined && content !== null) {
throwMalformedOpenRouterImageResponse(options.malformedResponseError);
}
}
return images;
}
function resolveImageCount(count: number | undefined): number {
if (typeof count !== "number" || !Number.isFinite(count)) {
return 1;
}
return Math.max(1, Math.min(MAX_IMAGE_RESULTS, Math.trunc(count)));
}
function resolveGeneratedImageMaxBytes(req: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
}): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * MB);
}
return MAX_IMAGE_BYTES;
}
function isGeminiImageModel(model: string): boolean {
return model.startsWith("google/gemini-");
}
function buildMessageContent(
req: ImageGenerationRequest,
):
| string
| Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> {
const inputImages = req.inputImages ?? [];
if (inputImages.length === 0) {
return req.prompt;
}
return [
{ type: "text", text: req.prompt },
...inputImages.map((image) => ({
type: "image_url" as const,
image_url: { url: toImageDataUrl(image) },
})),
];
}
function buildImageConfig(req: ImageGenerationRequest, model: string): Record<string, string> {
if (!isGeminiImageModel(model)) {
return {};
}
const imageConfig: Record<string, string> = {};
const aspectRatio = normalizeOptionalString(req.aspectRatio);
if (aspectRatio) {
imageConfig.aspect_ratio = aspectRatio;
}
const resolution = normalizeOptionalString(req.resolution);
if (resolution) {
imageConfig.image_size = resolution;
}
return imageConfig;
}
export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvider {
return {
id: "openrouter",
label: "OpenRouter",
defaultModel: DEFAULT_MODEL,
models: [...SUPPORTED_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({ provider: "openrouter", agentDir }),
capabilities: {
generate: {
maxCount: MAX_IMAGE_RESULTS,
supportsSize: false,
supportsAspectRatio: true,
supportsResolution: true,
},
edit: {
enabled: true,
maxCount: MAX_IMAGE_RESULTS,
maxInputImages: 5,
supportsSize: false,
supportsAspectRatio: true,
supportsResolution: true,
},
geometry: {
aspectRatios: [...SUPPORTED_ASPECT_RATIOS],
resolutions: ["1K", "2K", "4K"],
},
},
async generateImage(req) {
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 imageConfig = buildImageConfig(req, 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}`,
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
provider: "openrouter",
capability: "image",
transport: "http",
});
const count = resolveImageCount(req.count);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/chat/completions`,
headers,
body: {
model,
messages: [{ role: "user", content: buildMessageContent(req) }],
modalities: ["image", "text"],
n: count,
...(Object.keys(imageConfig).length > 0 ? { image_config: imageConfig } : {}),
},
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
fetchFn: fetch,
allowPrivateNetwork,
ssrfPolicy: req.ssrfPolicy,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(response, "OpenRouter image generation failed");
const payload = await readProviderJsonResponse(response, "openrouter.image-generation", {
maxBytes: resolveInlineImageJsonResponseMaxBytes(
count,
resolveGeneratedImageMaxBytes(req),
),
});
const images = extractOpenRouterImagesFromResponse(payload, {
malformedResponseError: OPENROUTER_IMAGE_MALFORMED_RESPONSE,
});
if (images.length === 0) {
throw new Error("OpenRouter image generation response missing image data");
}
return { images, model };
} finally {
await release();
}
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,373 @@
// Openrouter plugin entrypoint registers its OpenClaw integration.
import {
definePluginEntry,
type ProviderReplayPolicy,
type ProviderReplayPolicyContext,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
DEFAULT_CONTEXT_TOKENS,
PASSTHROUGH_GEMINI_REPLAY_HOOKS,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
getOpenRouterModelCapabilities,
loadOpenRouterModelCapabilities,
} from "openclaw/plugin-sdk/provider-stream-family";
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { isOpenRouterMistralModelId, normalizeOpenRouterApiModelId } from "./models.js";
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
import { createOpenRouterOAuthAuthMethod } from "./oauth.js";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
import {
buildOpenrouterProvider,
isOpenRouterProxyReasoningUnsupportedModel,
normalizeOpenRouterBaseUrl,
OPENROUTER_BASE_URL,
} from "./provider-catalog.js";
import { resolveOpenRouterExtraParamsForTransport } from "./provider-routing.js";
import { buildOpenRouterSpeechProvider } from "./speech-provider.js";
import { wrapOpenRouterProviderStream } from "./stream.js";
import {
resolveOpenRouterThinkingProfile,
supportsOpenRouterXHighThinking,
} from "./thinking-policy.js";
import {
buildOpenRouterVideoGenerationProvider,
listOpenRouterVideoModelCatalog,
} from "./video-generation-provider.js";
const PROVIDER_ID = "openrouter";
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
const OPENROUTER_FUSION_MODEL_ID = "openrouter/fusion";
const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [
"anthropic/",
"deepseek/",
"moonshot/",
"moonshotai/",
"zai/",
] as const;
const MAX_PROMPT_MODEL_ID_DISPLAY_CHARS = 256;
type OpenRouterFusionPromptContext = {
config?: {
agents?: {
defaults?: {
params?: Record<string, unknown>;
models?: Record<string, { params?: Record<string, unknown> }>;
};
list?: Array<{ id?: string; params?: Record<string, unknown> }>;
};
};
agentId?: string;
modelId: string;
};
type OpenRouterFusionPromptContribution = {
dynamicSuffix?: string;
};
function normalizeOpenRouterResolvedModel<T extends ProviderRuntimeModel>(model: T): T | undefined {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(model.baseUrl);
const normalizedId = normalizeOpenRouterApiModelId(model.id);
const reasoning = isOpenRouterProxyReasoningUnsupportedModel(model.id) ? false : model.reasoning;
if (
(!normalizedBaseUrl || normalizedBaseUrl === model.baseUrl) &&
(!normalizedId || normalizedId === model.id) &&
reasoning === model.reasoning
) {
return undefined;
}
return {
...model,
...(normalizedId ? { id: normalizedId } : {}),
...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}),
reasoning,
};
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function sanitizePromptModelId(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = Array.from(value)
.filter((char) => {
const codePoint = char.codePointAt(0) ?? 0;
return (
codePoint > 0x1f &&
(codePoint < 0x7f || codePoint > 0x9f) &&
codePoint !== 0x2028 &&
codePoint !== 0x2029
);
})
.join("")
.trim()
.slice(0, MAX_PROMPT_MODEL_ID_DISPLAY_CHARS);
return normalized || undefined;
}
function openRouterModelConfigKey(modelId: string): string {
const providerPrefix = `${PROVIDER_ID}/`;
return modelId.trim().toLowerCase().startsWith(providerPrefix)
? modelId
: `${PROVIDER_ID}/${modelId}`;
}
function findConfiguredOpenRouterModelParams(
ctx: OpenRouterFusionPromptContext,
): Record<string, unknown> | undefined {
const configuredModels = ctx.config?.agents?.defaults?.models;
if (!configuredModels) {
return undefined;
}
const normalizedModelId = normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId;
const directKeys = [
openRouterModelConfigKey(ctx.modelId),
openRouterModelConfigKey(normalizedModelId),
`${PROVIDER_ID}/${ctx.modelId}`,
`${PROVIDER_ID}/${normalizedModelId}`,
];
for (const key of directKeys) {
const params = readRecord(configuredModels[key]?.params);
if (params) {
return params;
}
}
for (const [rawKey, entry] of Object.entries(configuredModels)) {
const slashIndex = rawKey.indexOf("/");
if (slashIndex <= 0) {
continue;
}
const provider = rawKey.slice(0, slashIndex).trim().toLowerCase();
const modelId = rawKey.slice(slashIndex + 1);
const candidateModelId = normalizeOpenRouterApiModelId(modelId) ?? modelId;
if (
provider === PROVIDER_ID &&
candidateModelId.trim().toLowerCase() === normalizedModelId.trim().toLowerCase()
) {
return readRecord(entry.params);
}
}
return undefined;
}
function findConfiguredOpenRouterAgentParams(
ctx: OpenRouterFusionPromptContext,
): Record<string, unknown> | undefined {
if (!ctx.agentId) {
return undefined;
}
return readRecord(ctx.config?.agents?.list?.find((agent) => agent.id === ctx.agentId)?.params);
}
function resolveMergedOpenRouterPromptParams(
ctx: OpenRouterFusionPromptContext,
): Record<string, unknown> | undefined {
const merged = {
...readRecord(ctx.config?.agents?.defaults?.params),
...findConfiguredOpenRouterModelParams(ctx),
...findConfiguredOpenRouterAgentParams(ctx),
};
return Object.keys(merged).length > 0 ? merged : undefined;
}
function resolveFusionExtraBody(
ctx: OpenRouterFusionPromptContext,
): Record<string, unknown> | undefined {
const params = resolveMergedOpenRouterPromptParams(ctx);
const rawExtraBody =
params && Object.hasOwn(params, "extra_body") ? params.extra_body : params?.extraBody;
return readRecord(rawExtraBody);
}
function resolveOpenRouterFusionPromptContribution(
ctx: OpenRouterFusionPromptContext,
): OpenRouterFusionPromptContribution | undefined {
const normalizedModelId = normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId;
if (normalizedModelId !== OPENROUTER_FUSION_MODEL_ID) {
return undefined;
}
const extraBody = resolveFusionExtraBody(ctx);
const fusionPlugin = Array.isArray(extraBody?.plugins)
? extraBody.plugins.map(readRecord).find((plugin) => plugin?.id === "fusion")
: undefined;
if (!fusionPlugin) {
return undefined;
}
if (fusionPlugin.enabled === false) {
return undefined;
}
const analysisModels = Array.isArray(fusionPlugin.analysis_models)
? fusionPlugin.analysis_models
.map(sanitizePromptModelId)
.filter((model): model is string => Boolean(model))
: [];
const finalModel = sanitizePromptModelId(fusionPlugin.model);
const lines = [
"## OpenRouter Fusion Configuration",
"The active OpenRouter Fusion request is configured with these non-secret Fusion plugin fields.",
analysisModels.length > 0 ? `Analysis models: ${analysisModels.join(", ")}.` : undefined,
finalModel ? `Final Fusion model: ${finalModel}.` : undefined,
].filter((line): line is string => Boolean(line));
return lines.length > 2 ? { dynamicSuffix: lines.join("\n") } : undefined;
}
export default definePluginEntry({
id: "openrouter",
name: "OpenRouter Provider",
description: "Bundled OpenRouter provider plugin",
register(api) {
function buildDynamicOpenRouterModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel {
const apiModelId = normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId;
const capabilities = getOpenRouterModelCapabilities(apiModelId);
return {
id: ctx.modelId,
name: capabilities?.name ?? ctx.modelId,
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENROUTER_BASE_URL,
reasoning:
(capabilities?.reasoning ?? false) &&
!isOpenRouterProxyReasoningUnsupportedModel(ctx.modelId),
input: capabilities?.input ?? ["text"],
...(capabilities?.supportsTools !== undefined
? { compat: { supportsTools: capabilities.supportsTools } }
: {}),
cost: capabilities?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: capabilities?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
maxTokens: capabilities?.maxTokens ?? OPENROUTER_DEFAULT_MAX_TOKENS,
};
}
function isOpenRouterCacheTtlModel(modelId: string): boolean {
return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
}
const passthroughReplayHook = PASSTHROUGH_GEMINI_REPLAY_HOOKS.buildReplayPolicy;
function buildOpenRouterReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy {
const base = passthroughReplayHook?.(ctx) ?? {};
// OpenRouter proxies Mistral, which uses non-base62 tool_call_ids and
// requires the 9-char id contract that direct `mistral` provider already
// applies. Without strict9, replayed assistant turns fail with HTTP 400
// `invalid_function_call` 3280 (#58012).
if (isOpenRouterMistralModelId(ctx.modelId)) {
return {
...base,
sanitizeToolCallIds: true,
toolCallIdMode: "strict9",
};
}
return base;
}
api.registerProvider({
id: PROVIDER_ID,
label: "OpenRouter",
docsPath: "/providers/models",
envVars: ["OPENROUTER_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenRouter API key",
hint: "API key",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
expectedProviders: ["openrouter"],
applyConfig: (cfg) => applyOpenrouterConfig(cfg),
wizard: {
choiceId: "openrouter-api-key",
choiceLabel: "OpenRouter API key",
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "OAuth or API key",
onboardingScopes: ["text-inference", "music-generation"],
},
}),
createOpenRouterOAuthAuthMethod(),
],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
return {
provider: {
...buildOpenrouterProvider(),
apiKey,
},
};
},
},
staticCatalog: {
order: "simple",
run: async () => ({
provider: buildOpenrouterProvider(),
}),
},
resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx),
prepareDynamicModel: async (ctx) => {
await loadOpenRouterModelCapabilities(
normalizeOpenRouterApiModelId(ctx.modelId) ?? ctx.modelId,
);
},
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(providerConfig.baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => normalizeOpenRouterResolvedModel(model),
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeOpenRouterBaseUrl(baseUrl);
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
...PASSTHROUGH_GEMINI_REPLAY_HOOKS,
buildReplayPolicy: buildOpenRouterReplayPolicy,
resolveReasoningOutputMode: () => "native",
supportsXHighThinking: ({ modelId }) => supportsOpenRouterXHighThinking(modelId),
resolveThinkingProfile: ({ modelId }) => resolveOpenRouterThinkingProfile(modelId),
isModernModelRef: () => true,
resolveSystemPromptContribution: resolveOpenRouterFusionPromptContribution,
extraParamsForTransport: resolveOpenRouterExtraParamsForTransport,
wrapStreamFn: wrapOpenRouterProviderStream,
isCacheTtlEligible: (ctx) => isOpenRouterCacheTtlModel(ctx.modelId),
});
api.registerMediaUnderstandingProvider(openrouterMediaUnderstandingProvider);
api.registerImageGenerationProvider(buildOpenRouterImageGenerationProvider());
api.registerMusicGenerationProvider(buildOpenRouterMusicGenerationProvider());
api.registerVideoGenerationProvider(buildOpenRouterVideoGenerationProvider());
api.registerModelCatalogProvider({
provider: PROVIDER_ID,
kinds: ["video_generation"],
liveCatalog: listOpenRouterVideoModelCatalog,
});
api.registerSpeechProvider(buildOpenRouterSpeechProvider());
},
});

View File

@@ -0,0 +1,291 @@
// Openrouter tests cover media understanding provider plugin behavior.
import {
describeImageWithModel,
describeImagesWithModel,
} from "openclaw/plugin-sdk/media-understanding";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
openrouterMediaUnderstandingProvider,
transcribeOpenRouterAudio,
} from "./media-understanding-provider.js";
const { assertOkOrThrowHttpErrorMock, postJsonRequestMock, resolveProviderHttpRequestConfigMock } =
vi.hoisted(() => ({
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
postJsonRequestMock: vi.fn(),
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,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
// Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests.
readProviderJsonResponse: async (response: { json(): Promise<unknown> }) => response.json(),
requireTranscriptionText: (value: string | undefined, message: string) => {
const text = value?.trim();
if (!text) {
throw new Error(message);
}
return text;
},
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
function firstPostJsonRequest(): { body?: unknown; headers?: Headers } {
const [request] = postJsonRequestMock.mock.calls[0] ?? [];
if (!request || typeof request !== "object") {
throw new Error("expected first OpenRouter JSON request");
}
return request as { body?: unknown; headers?: Headers };
}
describe("openrouter media understanding provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
postJsonRequestMock.mockReset();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("declares image and audio capabilities with defaults", () => {
expect(openrouterMediaUnderstandingProvider).toEqual({
id: "openrouter",
capabilities: ["image", "audio"],
defaultModels: {
image: "auto",
audio: "openai/whisper-large-v3-turbo",
},
autoPriority: { audio: 35 },
describeImage: describeImageWithModel,
describeImages: describeImagesWithModel,
transcribeAudio: transcribeOpenRouterAudio,
});
});
it("sends JSON STT payload to OpenRouter transcriptions endpoint", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(JSON.stringify({ text: "hello world" }), { status: 200 }),
release,
});
const result = await transcribeOpenRouterAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "voice.oga",
mime: "audio/ogg",
apiKey: "sk-openrouter",
timeoutMs: 12_000,
language: " en ",
fetchFn: fetch,
});
expect(result).toEqual({
text: "hello world",
model: "openai/whisper-large-v3-turbo",
});
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith({
baseUrl: undefined,
defaultBaseUrl: "https://openrouter.ai/api/v1",
headers: undefined,
request: undefined,
defaultHeaders: {
Authorization: "Bearer sk-openrouter",
"Content-Type": "application/json",
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
provider: "openrouter",
api: "openrouter-stt",
capability: "audio",
transport: "media-understanding",
});
expect(postJsonRequestMock).toHaveBeenCalledWith({
url: "https://openrouter.ai/api/v1/audio/transcriptions",
headers: expect.any(Headers),
body: {
model: "openai/whisper-large-v3-turbo",
input_audio: {
data: Buffer.from("audio-bytes").toString("base64"),
format: "ogg",
},
language: "en",
},
timeoutMs: 12_000,
fetchFn: fetch,
allowPrivateNetwork: false,
dispatcherPolicy: undefined,
auditContext: "openrouter stt",
});
const headers = firstPostJsonRequest().headers;
if (!headers) {
throw new Error("expected OpenRouter request headers");
}
expect(headers.get("authorization")).toBe("Bearer sk-openrouter");
expect(headers.get("http-referer")).toBe("https://openclaw.ai");
expect(headers.get("x-openrouter-title")).toBe("OpenClaw");
expect(release).toHaveBeenCalledOnce();
});
it("accepts temperature via provider query options", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(JSON.stringify({ text: "ok" }), { status: 200 }),
release,
});
await transcribeOpenRouterAudio({
buffer: Buffer.from("audio"),
fileName: "voice.webm",
apiKey: "sk-openrouter",
timeoutMs: 5_000,
query: { temperature: 0.2 },
fetchFn: fetch,
});
expect(firstPostJsonRequest().body).toEqual({
model: "openai/whisper-large-v3-turbo",
input_audio: {
data: Buffer.from("audio").toString("base64"),
format: "webm",
},
temperature: 0.2,
});
});
it("drops malformed temperature query options", async () => {
for (const temperature of [Number.NaN, Number.POSITIVE_INFINITY]) {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValueOnce({
response: new Response(JSON.stringify({ text: "ok" }), { status: 200 }),
release,
});
await transcribeOpenRouterAudio({
buffer: Buffer.from("audio"),
fileName: "voice.webm",
apiKey: "sk-openrouter",
timeoutMs: 5_000,
query: { temperature },
fetchFn: fetch,
});
expect(firstPostJsonRequest().body).toEqual({
model: "openai/whisper-large-v3-turbo",
input_audio: {
data: Buffer.from("audio").toString("base64"),
format: "webm",
},
});
postJsonRequestMock.mockClear();
}
});
it("falls back to filename extension when mime is missing", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(JSON.stringify({ text: "ok" }), { status: 200 }),
release,
});
await transcribeOpenRouterAudio({
buffer: Buffer.from("audio"),
fileName: "voice.opus",
apiKey: "sk-openrouter",
timeoutMs: 5_000,
fetchFn: fetch,
});
expect(firstPostJsonRequest().body).toEqual({
model: "openai/whisper-large-v3-turbo",
input_audio: {
data: Buffer.from("audio").toString("base64"),
format: "ogg",
},
});
});
it("maps mp4 filename extension to m4a when mime is missing", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(JSON.stringify({ text: "ok" }), { status: 200 }),
release,
});
await transcribeOpenRouterAudio({
buffer: Buffer.from("audio"),
fileName: "voice.mp4",
apiKey: "sk-openrouter",
timeoutMs: 5_000,
fetchFn: fetch,
});
expect(firstPostJsonRequest().body).toEqual({
model: "openai/whisper-large-v3-turbo",
input_audio: {
data: Buffer.from("audio").toString("base64"),
format: "m4a",
},
});
});
it("normalizes parameterized mime for extensionless filenames", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(JSON.stringify({ text: "ok" }), { status: 200 }),
release,
});
await transcribeOpenRouterAudio({
buffer: Buffer.from("audio"),
fileName: "media-1",
mime: " Audio/Ogg; codecs=opus ",
apiKey: "sk-openrouter",
timeoutMs: 5_000,
fetchFn: fetch,
});
expect(firstPostJsonRequest().body).toEqual({
model: "openai/whisper-large-v3-turbo",
input_audio: {
data: Buffer.from("audio").toString("base64"),
format: "ogg",
},
});
});
it("throws when format cannot be resolved", async () => {
await expect(
transcribeOpenRouterAudio({
buffer: Buffer.from("audio"),
fileName: "voice.bin",
mime: "application/octet-stream",
apiKey: "sk-openrouter",
timeoutMs: 5_000,
fetchFn: fetch,
}),
).rejects.toThrow("OpenRouter STT could not resolve audio format");
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
it("throws when provider response omits text", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(JSON.stringify({}), { status: 200 }),
release,
});
await expect(
transcribeOpenRouterAudio({
buffer: Buffer.from("audio"),
fileName: "voice.mp3",
apiKey: "sk-openrouter",
timeoutMs: 5_000,
fetchFn: fetch,
}),
).rejects.toThrow("OpenRouter transcription response missing text");
});
});

View File

@@ -0,0 +1,181 @@
// Openrouter provider module implements model/runtime integration.
import path from "node:path";
import {
describeImageWithModel,
describeImagesWithModel,
type AudioTranscriptionRequest,
type AudioTranscriptionResult,
type MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
requireTranscriptionText,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
const DEFAULT_OPENROUTER_AUDIO_TRANSCRIPTION_MODEL = "openai/whisper-large-v3-turbo";
const SUPPORTED_AUDIO_FORMATS = new Set(["wav", "mp3", "flac", "m4a", "ogg", "webm", "aac"]);
function normalizeMimeType(mime?: string): string | undefined {
const normalized = mime?.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const [type] = normalized.split(";");
const clean = type?.trim();
return clean || undefined;
}
function resolveFormatFromMime(mime?: string): string | undefined {
const normalized = normalizeMimeType(mime);
if (!normalized) {
return undefined;
}
switch (normalized) {
case "audio/wav":
case "audio/x-wav":
return "wav";
case "audio/mpeg":
case "audio/mp3":
return "mp3";
case "audio/flac":
return "flac";
case "audio/mp4":
case "audio/m4a":
case "audio/x-m4a":
return "m4a";
case "audio/ogg":
case "audio/oga":
case "audio/opus":
return "ogg";
case "audio/webm":
return "webm";
case "audio/aac":
return "aac";
default:
return undefined;
}
}
function resolveFormatFromFileName(fileName?: string): string | undefined {
const ext = path
.extname(fileName ?? "")
.trim()
.toLowerCase()
.replace(/^\./, "");
if (!ext) {
return undefined;
}
if (ext === "mpeg") {
return "mp3";
}
if (ext === "mp4") {
return "m4a";
}
if (ext === "oga" || ext === "opus") {
return "ogg";
}
return SUPPORTED_AUDIO_FORMATS.has(ext) ? ext : undefined;
}
function resolveOpenRouterAudioFormat(params: { mime?: string; fileName?: string }): string {
const fromMime = resolveFormatFromMime(params.mime);
if (fromMime) {
return fromMime;
}
const fromFileName = resolveFormatFromFileName(params.fileName);
if (fromFileName) {
return fromFileName;
}
throw new Error(
`OpenRouter STT could not resolve audio format from mime "${params.mime ?? ""}" and file "${params.fileName ?? ""}"`,
);
}
type OpenRouterSttResponse = {
text?: string;
};
export async function transcribeOpenRouterAudio(
params: AudioTranscriptionRequest,
): Promise<AudioTranscriptionResult> {
const model = params.model?.trim() || DEFAULT_OPENROUTER_AUDIO_TRANSCRIPTION_MODEL;
const format = resolveOpenRouterAudioFormat({
mime: params.mime,
fileName: params.fileName,
});
const fetchFn = params.fetchFn ?? fetch;
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: OPENROUTER_BASE_URL,
headers: params.headers,
request: params.request,
defaultHeaders: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
provider: "openrouter",
api: "openrouter-stt",
capability: "audio",
transport: "media-understanding",
});
const temperature = asFiniteNumber(params.query?.temperature);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/audio/transcriptions`,
headers,
body: {
model,
input_audio: {
data: params.buffer.toString("base64"),
format,
},
...(params.language?.trim() ? { language: params.language.trim() } : {}),
...(temperature !== undefined ? { temperature } : {}),
},
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
auditContext: "openrouter stt",
});
try {
await assertOkOrThrowHttpError(response, "OpenRouter audio transcription failed");
const payload = await readProviderJsonResponse<OpenRouterSttResponse>(
response,
"openrouter.stt",
);
return {
text: requireTranscriptionText(
payload.text,
"OpenRouter transcription response missing text",
),
model,
};
} finally {
await release();
}
}
export const openrouterMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "openrouter",
capabilities: ["image", "audio"],
defaultModels: {
image: "auto",
audio: DEFAULT_OPENROUTER_AUDIO_TRANSCRIPTION_MODEL,
},
autoPriority: {
audio: 35,
},
describeImage: describeImageWithModel,
describeImages: describeImagesWithModel,
transcribeAudio: transcribeOpenRouterAudio,
};

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
isOpenRouterDeepSeekV4ModelId,
isOpenRouterMistralModelId,
normalizeOpenRouterApiModelId,
normalizeOpenRouterModelId,
} from "./models.js";
describe("normalizeOpenRouterModelId", () => {
it("strips the openrouter/ provider qualifier", () => {
expect(normalizeOpenRouterModelId("openrouter/deepseek/deepseek-v4-flash")).toBe(
"deepseek/deepseek-v4-flash",
);
});
it("leaves non-openrouter refs unchanged", () => {
expect(normalizeOpenRouterModelId("deepseek/deepseek-v4-flash")).toBe(
"deepseek/deepseek-v4-flash",
);
});
it("returns undefined for non-strings", () => {
expect(normalizeOpenRouterModelId(null)).toBeUndefined();
});
});
describe("normalizeOpenRouterApiModelId", () => {
it.each([
["openrouter/deepseek-v4-flash", "deepseek/deepseek-v4-flash"],
["openrouter/deepseek-v4-pro", "deepseek/deepseek-v4-pro"],
["openrouter/DEEPSEEK-V4-FLASH", "deepseek/deepseek-v4-flash"],
])("expands short OpenRouter ref %s to %s", (input, expected) => {
expect(normalizeOpenRouterApiModelId(input)).toBe(expected);
});
it("strips provider prefix from already-namespaced refs", () => {
expect(normalizeOpenRouterApiModelId("openrouter/deepseek/deepseek-v4-flash")).toBe(
"deepseek/deepseek-v4-flash",
);
});
it.each([
["openrouter/auto", "openrouter/auto"],
["openrouter/auto:free", "openrouter/auto:free"],
["openrouter/free", "openrouter/free"],
])("preserves native OpenRouter route %s", (input, expected) => {
expect(normalizeOpenRouterApiModelId(input)).toBe(expected);
});
it("passes through refs without the openrouter prefix", () => {
expect(normalizeOpenRouterApiModelId("deepseek/deepseek-v4-flash")).toBe(
"deepseek/deepseek-v4-flash",
);
});
});
describe("isOpenRouterDeepSeekV4ModelId", () => {
it("matches namespaced DeepSeek V4 refs", () => {
expect(isOpenRouterDeepSeekV4ModelId("openrouter/deepseek/deepseek-v4-flash")).toBe(true);
expect(isOpenRouterDeepSeekV4ModelId("openrouter/deepseek/deepseek-v4-pro")).toBe(true);
expect(isOpenRouterDeepSeekV4ModelId("openrouter/anthropic/claude-sonnet-4-6")).toBe(false);
});
});
describe("isOpenRouterMistralModelId", () => {
it("matches Mistral-prefixed refs", () => {
expect(isOpenRouterMistralModelId("openrouter/mistral/ministral-8b")).toBe(true);
expect(isOpenRouterMistralModelId("openrouter/codestral-22b")).toBe(true);
});
});

View File

@@ -0,0 +1,66 @@
// Openrouter plugin module implements models behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
const OPENROUTER_MISTRAL_MODEL_PREFIXES = [
"mistralai/",
"mistral/",
"mistral-",
"codestral-",
"devstral-",
"ministral-",
"mixtral-",
"pixtral-",
"voxtral-",
] as const;
const OPENROUTER_MODEL_PREFIX = "openrouter/";
// Short OpenRouter model refs surfaced by OpenClaw (e.g. `models list`) that are
// not native OpenRouter routes. The upstream API expects the namespaced slug.
const OPENROUTER_SHORT_TO_API_MODEL_ID = new Map([
["deepseek-v4-flash", "deepseek/deepseek-v4-flash"],
["deepseek-v4-pro", "deepseek/deepseek-v4-pro"],
]);
export function normalizeOpenRouterModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(modelId);
return normalized.startsWith(OPENROUTER_MODEL_PREFIX)
? normalized.slice(OPENROUTER_MODEL_PREFIX.length)
: normalized;
}
export function normalizeOpenRouterApiModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(modelId);
if (!normalized.startsWith(OPENROUTER_MODEL_PREFIX)) {
return normalized;
}
const unprefixed = normalized.slice(OPENROUTER_MODEL_PREFIX.length);
const shortExpanded = OPENROUTER_SHORT_TO_API_MODEL_ID.get(unprefixed);
if (shortExpanded) {
return shortExpanded;
}
// `openrouter/` is both a provider qualifier and an upstream namespace.
// Strip it only when the remainder is still a namespaced API model id.
return unprefixed.includes("/") ? unprefixed : normalized;
}
export function isOpenRouterMistralModelId(modelId: unknown): boolean {
const normalized = normalizeOpenRouterModelId(modelId);
return Boolean(
normalized && OPENROUTER_MISTRAL_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix)),
);
}
export function isOpenRouterDeepSeekV4ModelId(modelId: unknown): boolean {
const normalized = normalizeOpenRouterModelId(modelId);
if (!normalized?.startsWith("deepseek/")) {
return false;
}
const deepSeekModelId = normalized.slice("deepseek/".length).split(":", 1)[0];
return deepSeekModelId === "deepseek-v4-flash" || deepSeekModelId === "deepseek-v4-pro";
}

View File

@@ -0,0 +1,355 @@
// Openrouter tests cover music generation provider plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { expectExplicitMusicGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
const {
assertOkOrThrowHttpErrorMock,
postJsonRequestMock,
resolveApiKeyForProviderMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
postJsonRequestMock: vi.fn(),
resolveApiKeyForProviderMock: vi.fn(async () => ({
apiKey: "openrouter-key",
source: "env",
mode: "api-key",
})),
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
dispatcherPolicy: undefined,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => {
const original = await importOriginal<typeof import("openclaw/plugin-sdk/provider-http")>();
return {
...original,
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
};
});
function sseResponse(lines: string[], options?: { releaseLock?: () => void }): Response {
const encoder = new TextEncoder();
if (!options?.releaseLock) {
return new Response(
new ReadableStream({
start(controller) {
for (const line of lines) {
controller.enqueue(encoder.encode(line));
}
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
const chunks: Array<ReadableStreamReadResult<Uint8Array>> = lines.map((line) => ({
done: false,
value: encoder.encode(line),
}));
chunks.push({ done: true, value: undefined });
const reader = {
read: async () => chunks.shift() ?? { done: true, value: undefined },
cancel: async () => undefined,
releaseLock: options.releaseLock,
} as ReadableStreamDefaultReader<Uint8Array>;
return {
ok: true,
status: 200,
headers: new Headers({ "content-type": "text/event-stream" }),
body: {
getReader: () => reader,
},
} as Response;
}
function sseResponseLines(params: {
audio?: string;
transcript?: string;
done?: boolean;
}): string[] {
const lines: string[] = [];
if (params.audio || params.transcript) {
lines.push(
`data: ${JSON.stringify({
choices: [
{
delta: {
audio: {
...(params.audio ? { data: params.audio } : {}),
...(params.transcript ? { transcript: params.transcript } : {}),
},
},
},
],
})}\n`,
);
}
if (params.done) {
lines.push("data: [DONE]\n");
}
return lines;
}
function stalledSseResponse(line: string): Response {
const encoder = new TextEncoder();
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(line));
},
cancel() {},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
function postRequest(): Record<string, unknown> {
const request = postJsonRequestMock.mock.calls[0]?.[0];
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected OpenRouter music request");
}
return request as Record<string, unknown>;
}
function resetOpenRouterMusicMocks() {
assertOkOrThrowHttpErrorMock.mockResolvedValue(undefined);
postJsonRequestMock.mockReset();
resolveApiKeyForProviderMock.mockResolvedValue({
apiKey: "openrouter-key",
source: "env",
mode: "api-key",
});
resolveProviderHttpRequestConfigMock.mockImplementation((params: Record<string, unknown>) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
dispatcherPolicy: undefined,
}));
}
describe("openrouter music generation provider", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
resetOpenRouterMusicMocks();
});
afterEach(() => {
resetOpenRouterMusicMocks();
vi.restoreAllMocks();
vi.useRealTimers();
});
it("declares explicit mode capabilities", () => {
expectExplicitMusicGenerationCapabilities(buildOpenRouterMusicGenerationProvider());
});
it("streams OpenRouter audio chunks into a generated music asset", async () => {
const release = vi.fn(async () => {});
const audioBase64 = Buffer.from("wav-bytes").toString("base64");
postJsonRequestMock.mockResolvedValue({
response: sseResponse([
`data: ${JSON.stringify({ choices: [{ delta: { audio: { transcript: "line " } } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: audioBase64.slice(0, 4) } } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: audioBase64.slice(4), transcript: "two" } } }] })}\n`,
"data: [DONE]\n",
]),
release,
});
const result = await buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "",
prompt: "bright soundtrack",
cfg: {},
instrumental: true,
format: "wav",
});
expect(postRequest().url).toBe("https://openrouter.ai/api/v1/chat/completions");
expect(postRequest().body).toEqual({
model: "google/lyria-3-pro-preview",
messages: [
{
role: "user",
content:
"bright soundtrack\n\nInstrumental only. No vocals, no sung lyrics, no spoken word.",
},
],
modalities: ["text", "audio"],
audio: { format: "wav" },
stream: true,
});
expect(result.tracks[0]?.mimeType).toBe("audio/wav");
expect(result.tracks[0]?.buffer).toEqual(Buffer.from("wav-bytes"));
expect(result.lyrics).toEqual(["line two"]);
expect(release).toHaveBeenCalledOnce();
});
it("releases OpenRouter audio stream readers after completion", async () => {
const releaseLock = vi.fn();
postJsonRequestMock.mockResolvedValue({
response: sseResponse(
sseResponseLines({
audio: Buffer.from("wav-bytes").toString("base64"),
done: true,
}),
{ releaseLock },
),
release: vi.fn(async () => {}),
});
await expect(
buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-pro-preview",
prompt: "release stream reader",
cfg: {},
}),
).resolves.toMatchObject({
tracks: [{ mimeType: "audio/wav" }],
});
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it("decodes independently padded OpenRouter audio chunks", async () => {
postJsonRequestMock.mockResolvedValue({
response: sseResponse([
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("a").toString("base64") } } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("b").toString("base64") } } }] })}\n`,
"data: [DONE]\n",
]),
release: vi.fn(async () => {}),
});
const result = await buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-pro-preview",
prompt: "chunked soundtrack",
cfg: {},
});
expect(result.tracks[0]?.buffer).toEqual(Buffer.from("ab"));
});
it("sends reference images as multimodal message content", async () => {
postJsonRequestMock.mockResolvedValue({
response: sseResponse([
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("mp3").toString("base64") } } }] })}\n`,
"data: [DONE]\n",
]),
release: vi.fn(async () => {}),
});
await buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-clip-preview",
prompt: "score this image",
cfg: {},
format: "mp3",
inputImages: [{ buffer: Buffer.from("png"), mimeType: "image/png" }],
});
expect(postRequest().body).toEqual(
expect.objectContaining({
model: "google/lyria-3-clip-preview",
audio: { format: "mp3" },
messages: [
{
role: "user",
content: [
{ type: "text", text: "score this image" },
{
type: "image_url",
image_url: {
url: `data:image/png;base64,${Buffer.from("png").toString("base64")}`,
},
},
],
},
],
}),
);
});
it("times out stalled OpenRouter audio streams after headers", async () => {
postJsonRequestMock.mockResolvedValue({
response: stalledSseResponse(
`data: ${JSON.stringify({ choices: [{ delta: { audio: { transcript: "start" } } }] })}\n`,
),
release: vi.fn(async () => {}),
});
await expect(
buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-clip-preview",
prompt: "never finish",
cfg: {},
timeoutMs: 1,
}),
).rejects.toThrow("OpenRouter music generation timed out after 1ms");
});
it("caps oversized OpenRouter music stream timeouts", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
postJsonRequestMock.mockResolvedValue({
response: sseResponse(["data: [DONE]\n"]),
release: vi.fn(async () => {}),
});
await expect(
buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-clip-preview",
prompt: "huge timeout",
cfg: {},
timeoutMs: Number.MAX_SAFE_INTEGER,
}),
).rejects.toThrow("OpenRouter music generation response missing audio data");
expect(postRequest().timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
const streamTimeoutMs = timeoutSpy.mock.calls.at(-1)?.[1];
expect(streamTimeoutMs).toBeGreaterThan(MAX_TIMER_TIMEOUT_MS - 1_000);
expect(streamTimeoutMs).toBeLessThanOrEqual(MAX_TIMER_TIMEOUT_MS);
} finally {
timeoutSpy.mockRestore();
vi.useRealTimers();
}
});
it("rejects OpenRouter streams that end before completion", async () => {
postJsonRequestMock.mockResolvedValue({
response: sseResponse([
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("partial").toString("base64") } } }] })}\n`,
]),
release: vi.fn(async () => {}),
});
await expect(
buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-clip-preview",
prompt: "interrupted",
cfg: {},
}),
).rejects.toThrow("OpenRouter music generation stream ended before completion");
});
});

View File

@@ -0,0 +1,338 @@
// Openrouter provider module implements model/runtime integration.
import { toImageDataUrl } from "openclaw/plugin-sdk/image-generation";
import type {
MusicGenerationProvider,
MusicGenerationRequest,
MusicGenerationSourceImage,
} from "openclaw/plugin-sdk/music-generation";
import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
postJsonRequest,
resolveProviderHttpRequestConfig,
resolveProviderOperationTimeoutMs,
type ProviderOperationDeadline,
} from "openclaw/plugin-sdk/provider-http";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
const DEFAULT_OPENROUTER_MUSIC_MODEL = "google/lyria-3-pro-preview";
const OPENROUTER_CLIP_MUSIC_MODEL = "google/lyria-3-clip-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const OPENROUTER_MUSIC_MODELS = [
DEFAULT_OPENROUTER_MUSIC_MODEL,
OPENROUTER_CLIP_MUSIC_MODEL,
] as const;
type OpenRouterAudioStreamResult = {
audioBuffer: Buffer;
transcript: string;
};
function resolveOpenRouterMusicModel(model: string | undefined): string {
return normalizeOptionalString(model) ?? DEFAULT_OPENROUTER_MUSIC_MODEL;
}
function outputFormatToMimeType(format: "mp3" | "wav" | undefined): string {
return format === "mp3" ? "audio/mpeg" : "audio/wav";
}
function imageToContentPart(image: MusicGenerationSourceImage): {
type: "image_url";
image_url: { url: string };
} {
const url =
normalizeOptionalString(image.url) ??
(image.buffer
? toImageDataUrl({ ...image, buffer: image.buffer, defaultMimeType: "image/png" })
: undefined);
if (!url) {
throw new Error("OpenRouter music generation reference image is missing data.");
}
return {
type: "image_url",
image_url: { url },
};
}
function buildOpenRouterMusicPrompt(req: MusicGenerationRequest): string {
const parts = [req.prompt.trim()];
const lyrics = normalizeOptionalString(req.lyrics);
if (req.instrumental === true) {
parts.push("Instrumental only. No vocals, no sung lyrics, no spoken word.");
}
if (lyrics) {
parts.push(`Lyrics:\n${lyrics}`);
}
if (typeof req.durationSeconds === "number") {
parts.push(`Target duration: about ${Math.round(req.durationSeconds)} seconds.`);
}
return parts.join("\n\n");
}
function buildOpenRouterMessageContent(
req: MusicGenerationRequest,
):
| string
| Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> {
const prompt = buildOpenRouterMusicPrompt(req);
const images = req.inputImages ?? [];
if (images.length === 0) {
return prompt;
}
return [{ type: "text", text: prompt }, ...images.map((image) => imageToContentPart(image))];
}
function readDeltaAudio(part: unknown): { data?: string; transcript?: string } | undefined {
if (!isRecord(part)) {
return undefined;
}
const choices = part.choices;
if (!Array.isArray(choices)) {
return undefined;
}
const first = choices[0];
if (!isRecord(first)) {
return undefined;
}
const delta = first.delta;
if (!isRecord(delta)) {
return undefined;
}
const audio = delta.audio;
if (!isRecord(audio)) {
return undefined;
}
return {
data: normalizeOptionalString(audio.data),
transcript: typeof audio.transcript === "string" ? audio.transcript : undefined,
};
}
function processOpenRouterSseLine(
line: string,
result: { audioBuffers: Buffer[]; transcriptChunks: string[] },
): boolean {
if (!line.startsWith("data:")) {
return false;
}
const data = line.slice("data:".length).trim();
if (!data) {
return false;
}
if (data === "[DONE]") {
return true;
}
const audio = readDeltaAudio(JSON.parse(data));
if (audio?.data) {
result.audioBuffers.push(Buffer.from(audio.data, "base64"));
}
if (audio?.transcript) {
result.transcriptChunks.push(audio.transcript);
}
return false;
}
function resolveOpenRouterStreamRemainingMs(deadline: ProviderOperationDeadline): number {
return resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: deadline.timeoutMs ?? DEFAULT_TIMEOUT_MS,
});
}
async function readOpenRouterStreamChunk(
reader: ReadableStreamDefaultReader<Uint8Array>,
deadline: ProviderOperationDeadline,
): Promise<ReadableStreamReadResult<Uint8Array>> {
const timeoutMs = resolveOpenRouterStreamRemainingMs(deadline);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${deadline.label} timed out after ${deadline.timeoutMs}ms`));
}, timeoutMs);
}),
]);
} catch (error) {
await reader.cancel().catch(() => {});
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
async function readOpenRouterAudioStream(
response: Response,
deadline: ProviderOperationDeadline,
): Promise<OpenRouterAudioStreamResult> {
if (!response.body) {
throw new Error("OpenRouter music generation response missing stream body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const result = { audioBuffers: [] as Buffer[], transcriptChunks: [] as string[] };
let buffer = "";
let doneSeen = false;
try {
for (;;) {
const { value, done } = await readOpenRouterStreamChunk(reader, deadline);
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/u);
buffer = lines.pop() ?? "";
for (const line of lines) {
if (processOpenRouterSseLine(line.trim(), result)) {
await reader.cancel();
return {
audioBuffer: Buffer.concat(result.audioBuffers),
transcript: result.transcriptChunks.join(""),
};
}
}
}
resolveOpenRouterStreamRemainingMs(deadline);
buffer += decoder.decode();
if (buffer.trim()) {
for (const line of buffer.split(/\r?\n/u)) {
if (processOpenRouterSseLine(line.trim(), result)) {
doneSeen = true;
}
}
}
if (!doneSeen) {
throw new Error("OpenRouter music generation stream ended before completion");
}
return {
audioBuffer: Buffer.concat(result.audioBuffers),
transcript: result.transcriptChunks.join(""),
};
} finally {
try {
reader.releaseLock();
} catch {}
}
}
export function buildOpenRouterMusicGenerationProvider(): MusicGenerationProvider {
return {
id: "openrouter",
label: "OpenRouter",
defaultModel: DEFAULT_OPENROUTER_MUSIC_MODEL,
models: [...OPENROUTER_MUSIC_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "openrouter",
agentDir,
}),
capabilities: {
generate: {
maxTracks: 1,
maxDurationSeconds: 180,
supportsLyrics: true,
supportsInstrumental: true,
supportsDuration: true,
supportsFormat: true,
supportedFormats: ["mp3", "wav"],
},
edit: {
enabled: true,
maxTracks: 1,
maxInputImages: 1,
maxDurationSeconds: 180,
supportsLyrics: true,
supportsInstrumental: true,
supportsDuration: true,
supportsFormat: true,
supportedFormats: ["mp3", "wav"],
},
},
async generateMusic(req) {
if ((req.inputImages?.length ?? 0) > 1) {
throw new Error("OpenRouter music generation supports at most one reference image.");
}
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 { 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",
},
provider: "openrouter",
capability: "audio",
transport: "http",
});
const model = resolveOpenRouterMusicModel(req.model);
const format = req.format ?? "wav";
const requestedTimeoutMs = resolvePositiveTimerTimeoutMs(req.timeoutMs, DEFAULT_TIMEOUT_MS);
const streamDeadline = createProviderOperationDeadline({
timeoutMs: requestedTimeoutMs,
label: "OpenRouter music generation",
});
const timeoutMs = resolveOpenRouterStreamRemainingMs(streamDeadline);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/chat/completions`,
headers,
body: {
model,
messages: [{ role: "user", content: buildOpenRouterMessageContent(req) }],
modalities: ["text", "audio"],
audio: { format },
stream: true,
},
timeoutMs,
fetchFn: fetch,
allowPrivateNetwork,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(response, "OpenRouter music generation failed");
const streamResult = await readOpenRouterAudioStream(response, streamDeadline);
if (streamResult.audioBuffer.byteLength === 0) {
throw new Error("OpenRouter music generation response missing audio data");
}
return {
tracks: [
{
buffer: streamResult.audioBuffer,
mimeType: outputFormatToMimeType(format),
fileName: `track-1.${format}`,
},
],
model,
...(streamResult.transcript ? { lyrics: [streamResult.transcript] } : {}),
metadata: {
inputImageCount: req.inputImages?.length ?? 0,
instrumental: req.instrumental === true,
requestedFormat: format,
},
};
} finally {
await release();
}
},
};
}

View File

@@ -0,0 +1,315 @@
// Openrouter OAuth tests cover PKCE exchange and auth profile output.
import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import {
buildOpenRouterOAuthAuthorizeUrl,
buildOpenRouterOAuthRedirectUri,
exchangeOpenRouterOAuthCode,
loginOpenRouterOAuth,
OPENROUTER_OAUTH_CALLBACK_PATH,
OPENROUTER_OAUTH_CALLBACK_PORT,
OPENROUTER_OAUTH_CHOICE_ID,
OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
OPENROUTER_OAUTH_REDIRECT_URI,
OPENROUTER_OAUTH_TOKEN_URL,
parseOpenRouterOAuthCallbackInput,
waitForOpenRouterOAuthCallback,
} from "./oauth.js";
function jsonResponse(value: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(value), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function boundedTextErrorResponse(body: string, status = 502): {
response: Response;
cancel: ReturnType<typeof vi.fn>;
releaseLock: ReturnType<typeof vi.fn>;
text: ReturnType<typeof vi.fn>;
} {
const encoded = new TextEncoder().encode(body);
let read = false;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("response.text() should not be called");
});
const response = {
ok: false,
status,
headers: new Headers(),
body: {
getReader: () => ({
read: async () => {
if (read) {
return { done: true, value: undefined };
}
read = true;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
text,
} as unknown as Response;
return { response, cancel, releaseLock, text };
}
function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input.url;
}
function requestJsonBody(init: RequestInit | undefined): Record<string, unknown> {
if (typeof init?.body !== "string") {
throw new Error("expected JSON request body");
}
return JSON.parse(init.body) as Record<string, unknown>;
}
function createOpenRouterOAuthContext(params: {
isRemote: boolean;
redirectInput?: string;
openUrl?: (url: string) => Promise<void>;
}) {
const progress = {
update: vi.fn(),
stop: vi.fn(),
};
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => undefined);
const text = vi.fn<(prompt: { message: string; placeholder?: string }) => Promise<string>>(
async () =>
params.redirectInput ?? `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=AUTHCODE`,
);
const log = vi.fn<(message: string) => void>();
const openUrl = params.openUrl ?? vi.fn<(url: string) => Promise<void>>(async () => undefined);
const ctx = {
config: {},
isRemote: params.isRemote,
openUrl,
prompter: {
note,
text,
progress: vi.fn(() => progress),
},
runtime: {
log,
error: vi.fn(),
exit: vi.fn(),
},
oauth: {
createVpsAwareHandlers: vi.fn(),
},
} as unknown as ProviderAuthContext;
return { ctx, progress, note, text, log, openUrl };
}
describe("OpenRouter OAuth", () => {
it("builds the documented PKCE authorize URL", () => {
const url = new URL(
buildOpenRouterOAuthAuthorizeUrl({ codeChallenge: "challenge-1", state: "state-1" }),
);
const callbackUrl = new URL(url.searchParams.get("callback_url") ?? "");
expect(url.origin + url.pathname).toBe("https://openrouter.ai/auth");
expect(callbackUrl.origin + callbackUrl.pathname).toBe(OPENROUTER_OAUTH_REDIRECT_URI);
expect(callbackUrl.searchParams.get("state")).toBe("state-1");
expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
expect(url.searchParams.get("code_challenge_method")).toBe(
OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
);
expect(OPENROUTER_OAUTH_REDIRECT_URI).toContain(`:${OPENROUTER_OAUTH_CALLBACK_PORT}/`);
expect(OPENROUTER_OAUTH_REDIRECT_URI).toContain(OPENROUTER_OAUTH_CALLBACK_PATH);
});
it("parses state-bound OpenRouter redirect URLs and query strings", () => {
expect(
parseOpenRouterOAuthCallbackInput(
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&code=AUTHCODE`,
"state-1",
),
).toEqual({ code: "AUTHCODE", state: "state-1" });
expect(parseOpenRouterOAuthCallbackInput("state=state-1&code=AUTHCODE", "state-1")).toEqual({
code: "AUTHCODE",
state: "state-1",
});
expect(buildOpenRouterOAuthRedirectUri({ state: "state-1" })).toBe(
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1`,
);
expect(() =>
parseOpenRouterOAuthCallbackInput(
`${OPENROUTER_OAUTH_REDIRECT_URI}?code=AUTHCODE`,
"state-1",
),
).toThrow("Missing OpenRouter OAuth state");
expect(() =>
parseOpenRouterOAuthCallbackInput(
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=wrong&code=AUTHCODE`,
"state-1",
),
).toThrow("OpenRouter OAuth state mismatch");
expect(() => parseOpenRouterOAuthCallbackInput("AUTHCODE", "state-1")).toThrow(
"Paste the full OpenRouter redirect URL",
);
});
it("exchanges an authorization code for the issued OpenRouter API key", async () => {
const fetchImpl = vi.fn<typeof fetch>(async (url, init) => {
expect(requestUrl(url)).toBe(OPENROUTER_OAUTH_TOKEN_URL);
expect(init?.method).toBe("POST");
expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
expect(requestJsonBody(init)).toEqual({
code: "AUTHCODE",
code_verifier: "verifier-1",
code_challenge_method: OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
});
return jsonResponse({ key: "sk-or-v1-test", user_id: "user-1" });
});
await expect(
exchangeOpenRouterOAuthCode({
code: "AUTHCODE",
codeVerifier: "verifier-1",
fetchImpl,
}),
).resolves.toEqual({
key: "sk-or-v1-test",
userId: "user-1",
});
});
it("surfaces OpenRouter OAuth exchange errors without credential material", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
jsonResponse({ error: "Invalid code or code_verifier" }, { status: 403 }),
)
.mockResolvedValueOnce(
jsonResponse({ error: { message: "Invalid code", code: 400 } }, { status: 400 }),
);
await expect(
exchangeOpenRouterOAuthCode({
code: "bad-code",
codeVerifier: "bad-verifier",
fetchImpl,
}),
).rejects.toThrow("OpenRouter OAuth key exchange failed (403): Invalid code or code_verifier");
await expect(
exchangeOpenRouterOAuthCode({
code: "bad-code",
codeVerifier: "bad-verifier",
fetchImpl,
}),
).rejects.toThrow("OpenRouter OAuth key exchange failed (400): Invalid code");
});
it("bounds OpenRouter OAuth exchange error bodies without requiring response.text()", async () => {
const errorResponse = boundedTextErrorResponse(
`${"openrouter denied ".repeat(1024)}tail-marker`,
502,
);
const fetchImpl = vi.fn<typeof fetch>(async () => errorResponse.response);
let error: unknown;
try {
await exchangeOpenRouterOAuthCode({
code: "bad-code",
codeVerifier: "bad-verifier",
fetchImpl,
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain("OpenRouter OAuth key exchange failed (502): openrouter denied");
expect(message).not.toContain("tail-marker");
expect(errorResponse.text).not.toHaveBeenCalled();
expect(errorResponse.cancel).toHaveBeenCalledTimes(1);
expect(errorResponse.releaseLock).toHaveBeenCalledTimes(1);
});
it("stores a browser OAuth result as the default OpenRouter API-key profile", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse({ key: "sk-or-v1-test", user_id: "user-1" }),
);
const { ctx, progress, text, log, openUrl } = createOpenRouterOAuthContext({
isRemote: true,
});
const result = await loginOpenRouterOAuth(ctx, {
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl,
});
expect(openUrl).not.toHaveBeenCalled();
expect(log.mock.calls[0]?.[0]).toContain("https://openrouter.ai/auth?");
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Paste the OpenRouter redirect URL",
}),
);
expect(result.defaultModel).toBe("openrouter/auto");
expect(result.profiles).toEqual([
{
profileId: "openrouter:default",
credential: {
type: "api_key",
provider: "openrouter",
key: "sk-or-v1-test",
displayName: "OpenRouter user-1",
metadata: {
authFlow: "oauth-pkce",
userId: "user-1",
},
},
},
]);
expect(progress.stop).toHaveBeenCalledWith("OpenRouter OAuth complete");
});
it("uses the local callback path before opening the browser locally", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ key: "sk-or-v1-test" }));
const waitForCallback = vi.fn<typeof waitForOpenRouterOAuthCallback>(async () => ({
code: "AUTHCODE",
state: "state-1",
}));
const { ctx, openUrl, text } = createOpenRouterOAuthContext({ isRemote: false });
await loginOpenRouterOAuth(ctx, {
createPkce: () => ({ verifier: "verifier-1", challenge: "challenge-1" }),
createState: () => "state-1",
fetchImpl,
waitForCallback,
});
expect(waitForCallback).toHaveBeenCalledWith(
expect.objectContaining({ expectedState: "state-1" }),
);
expect(waitForCallback.mock.invocationCallOrder[0]).toBeLessThan(
(openUrl as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER,
);
expect(openUrl).toHaveBeenCalledWith(expect.stringContaining("https://openrouter.ai/auth?"));
expect(text).not.toHaveBeenCalled();
});
it("exposes stable auth choice metadata", () => {
expect(OPENROUTER_OAUTH_CHOICE_ID).toBe("openrouter-oauth");
});
});

View File

@@ -0,0 +1,429 @@
// OpenRouter OAuth support exchanges PKCE browser login codes for API keys.
import { createServer } from "node:http";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { ProviderAuthContext, ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry";
import {
buildApiKeyCredential,
generatePkceVerifierChallenge,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import { generateOAuthState } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
const PROVIDER_ID = "openrouter";
export const OPENROUTER_OAUTH_METHOD_ID = "oauth";
export const OPENROUTER_OAUTH_CHOICE_ID = "openrouter-oauth";
export const OPENROUTER_OAUTH_AUTHORIZE_URL = "https://openrouter.ai/auth";
export const OPENROUTER_OAUTH_TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
export const OPENROUTER_OAUTH_CALLBACK_HOST = "localhost";
export const OPENROUTER_OAUTH_CALLBACK_PORT = 3000;
export const OPENROUTER_OAUTH_CALLBACK_PATH = "/openrouter-oauth/callback";
export const OPENROUTER_OAUTH_REDIRECT_URI = `http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}${OPENROUTER_OAUTH_CALLBACK_PATH}`;
export const OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD = "S256";
const OPENROUTER_OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
const OPENROUTER_OAUTH_FETCH_TIMEOUT_MS = 30 * 1000;
const OPENROUTER_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const OPENROUTER_OAUTH_PROFILE_ID = "openrouter:default";
type OpenRouterOAuthCallbackResult = {
code: string;
state: string;
};
type OpenRouterOAuthKeyResult = {
key: string;
userId?: string;
};
type OpenRouterOAuthLoginOptions = {
createPkce?: () => { verifier: string; challenge: string };
createState?: () => string;
fetchImpl?: typeof fetch;
waitForCallback?: typeof waitForOpenRouterOAuthCallback;
};
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function extractOpenRouterError(value: unknown): string | undefined {
if (typeof value === "string") {
return value.trim() || undefined;
}
if (!isRecord(value)) {
return undefined;
}
const direct = readString(value.message) ?? readString(value.error_description);
if (direct) {
return direct;
}
const error = value.error;
if (typeof error === "string") {
return error.trim() || undefined;
}
if (isRecord(error)) {
return readString(error.message) ?? readString(error.code);
}
return undefined;
}
async function readResponseBody(response: Response): Promise<unknown> {
const text = response.ok
? await response.text()
: await readResponseTextLimited(response, OPENROUTER_OAUTH_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
if (!text.trim()) {
return null;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function parseOpenRouterKeyResponse(value: unknown): OpenRouterOAuthKeyResult {
if (!isRecord(value)) {
throw new Error("OpenRouter OAuth key exchange returned an unexpected response.");
}
const key = readString(value.key);
if (!key) {
throw new Error("OpenRouter OAuth key exchange returned no API key.");
}
const userId = readString(value.user_id) ?? readString(value.userId);
return {
key,
...(userId ? { userId } : {}),
};
}
export function buildOpenRouterOAuthRedirectUri(params: { state: string }): string {
const url = new URL(OPENROUTER_OAUTH_REDIRECT_URI);
url.searchParams.set("state", params.state);
return url.toString();
}
export function buildOpenRouterOAuthAuthorizeUrl(params: {
codeChallenge: string;
state: string;
}): string {
const url = new URL(OPENROUTER_OAUTH_AUTHORIZE_URL);
url.searchParams.set("callback_url", buildOpenRouterOAuthRedirectUri({ state: params.state }));
url.searchParams.set("code_challenge", params.codeChallenge);
url.searchParams.set("code_challenge_method", OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD);
return url.toString();
}
function requireOpenRouterOAuthState(state: string | undefined, expectedState: string): string {
if (!state) {
throw new Error("Missing OpenRouter OAuth state. Paste the full redirect URL.");
}
if (state !== expectedState) {
throw new Error("OpenRouter OAuth state mismatch. Please retry login.");
}
return state;
}
export function parseOpenRouterOAuthCallbackInput(
input: string,
expectedState: string,
): OpenRouterOAuthCallbackResult {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("No input provided.");
}
const parseParams = (params: URLSearchParams): OpenRouterOAuthCallbackResult => {
const code = readString(params.get("code"));
if (!code) {
throw new Error("Missing 'code' parameter in redirect URL.");
}
const state = requireOpenRouterOAuthState(readString(params.get("state")), expectedState);
return { code, state };
};
try {
const url = new URL(trimmed);
return parseParams(url.searchParams);
} catch (err) {
if (err instanceof TypeError) {
if (trimmed.includes("code=")) {
return parseParams(new URLSearchParams(trimmed));
}
throw new Error("Paste the full OpenRouter redirect URL, not just the code.", {
cause: err,
});
}
throw err;
}
}
export async function exchangeOpenRouterOAuthCode(params: {
code: string;
codeVerifier: string;
fetchImpl?: typeof fetch;
}): Promise<OpenRouterOAuthKeyResult> {
const fetchImpl = params.fetchImpl ?? fetch;
const response = await fetchImpl(OPENROUTER_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
code: params.code,
code_verifier: params.codeVerifier,
code_challenge_method: OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
}),
signal: AbortSignal.timeout(OPENROUTER_OAUTH_FETCH_TIMEOUT_MS),
});
const body = await readResponseBody(response);
if (!response.ok) {
const message = extractOpenRouterError(body);
throw new Error(
`OpenRouter OAuth key exchange failed (${response.status})${message ? `: ${message}` : ""}`,
);
}
return parseOpenRouterKeyResponse(body);
}
export async function waitForOpenRouterOAuthCallback(params: {
expectedState: string;
timeoutMs?: number;
onProgress?: (message: string) => void;
}): Promise<OpenRouterOAuthCallbackResult> {
const timeoutMs = params.timeoutMs ?? OPENROUTER_OAUTH_TIMEOUT_MS;
return new Promise<OpenRouterOAuthCallbackResult>((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
finish(new Error("OpenRouter OAuth callback timeout"));
}, timeoutMs);
const server = createServer((req, res) => {
try {
const requestUrl = new URL(
req.url ?? "/",
`http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}`,
);
if (requestUrl.pathname !== OPENROUTER_OAUTH_CALLBACK_PATH) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain");
res.end("Not found");
return;
}
if (req.method !== "GET") {
res.statusCode = 405;
res.setHeader("Allow", "GET");
res.setHeader("Content-Type", "text/plain");
res.end("Method not allowed");
return;
}
const error = readString(requestUrl.searchParams.get("error"));
if (error) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end(`OpenRouter authentication failed: ${error}`);
finish(new Error(`OpenRouter OAuth error: ${error}`));
return;
}
const code = readString(requestUrl.searchParams.get("code"));
if (!code) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Missing OAuth code");
finish(new Error("Missing OpenRouter OAuth code"));
return;
}
const state = readString(requestUrl.searchParams.get("state"));
try {
requireOpenRouterOAuthState(state, params.expectedState);
} catch (err) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Invalid OAuth state");
finish(err instanceof Error ? err : new Error("OpenRouter OAuth state mismatch"));
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
"<body><h2>OpenRouter OAuth complete</h2>" +
"<p>You can close this window and return to OpenClaw.</p></body></html>",
);
finish(undefined, { code, state: params.expectedState });
} catch (err) {
finish(err instanceof Error ? err : new Error("OpenRouter OAuth callback failed"));
}
});
const finish = (err?: Error, result?: OpenRouterOAuthCallbackResult) => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
try {
server.close();
} catch {
// Best-effort cleanup after callback completion or timeout.
}
if (err) {
reject(err);
return;
}
if (result) {
resolve(result);
}
};
server.once("error", (err) => {
finish(err instanceof Error ? err : new Error("OpenRouter OAuth callback server error"));
});
server.listen(OPENROUTER_OAUTH_CALLBACK_PORT, OPENROUTER_OAUTH_CALLBACK_HOST, () => {
params.onProgress?.(
`Waiting for OpenRouter OAuth callback on ${OPENROUTER_OAUTH_REDIRECT_URI}...`,
);
});
});
}
async function promptForOpenRouterRedirect(
ctx: ProviderAuthContext,
expectedState: string,
): Promise<string> {
const input = await ctx.prompter.text({
message: "Paste the OpenRouter redirect URL",
placeholder: `${OPENROUTER_OAUTH_REDIRECT_URI}?state=...&code=...`,
validate: (value: string) => (value.trim().length > 0 ? undefined : "Required"),
});
return parseOpenRouterOAuthCallbackInput(input, expectedState).code;
}
async function resolveOpenRouterOAuthCode(
ctx: ProviderAuthContext,
params: {
authorizeUrl: string;
state: string;
waitForCallback: typeof waitForOpenRouterOAuthCallback;
onProgress: (message: string) => void;
},
): Promise<string> {
await ctx.prompter.note(
ctx.isRemote
? [
"Open this URL in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
"",
`Redirect URI: ${OPENROUTER_OAUTH_REDIRECT_URI}`,
].join("\n")
: [
"Browser will open for OpenRouter authentication.",
"If the callback does not auto-complete, paste the redirect URL.",
"",
`Redirect URI: ${OPENROUTER_OAUTH_REDIRECT_URI}`,
].join("\n"),
"OpenRouter OAuth",
);
if (ctx.isRemote) {
ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${params.authorizeUrl}\n`);
return await promptForOpenRouterRedirect(ctx, params.state);
}
const callbackPromise = params
.waitForCallback({ expectedState: params.state, onProgress: params.onProgress })
.catch(async () => {
params.onProgress("OAuth callback not detected; waiting for redirect URL...");
return { code: await promptForOpenRouterRedirect(ctx, params.state), state: params.state };
});
void callbackPromise.catch(() => undefined);
try {
await ctx.openUrl(params.authorizeUrl);
ctx.runtime.log(`Open: ${params.authorizeUrl}`);
} catch {
ctx.runtime.log(`Open manually: ${params.authorizeUrl}`);
}
return (await callbackPromise).code;
}
export async function loginOpenRouterOAuth(
ctx: ProviderAuthContext,
options: OpenRouterOAuthLoginOptions = {},
): Promise<ProviderAuthResult> {
const progress = ctx.prompter.progress("Starting OpenRouter OAuth...");
try {
const pkce = options.createPkce?.() ?? generatePkceVerifierChallenge();
const state = options.createState?.() ?? generateOAuthState();
const authorizeUrl = buildOpenRouterOAuthAuthorizeUrl({
codeChallenge: pkce.challenge,
state,
});
const code = await resolveOpenRouterOAuthCode(ctx, {
authorizeUrl,
state,
waitForCallback: options.waitForCallback ?? waitForOpenRouterOAuthCallback,
onProgress: (message) => progress.update(message),
});
progress.update("Exchanging OpenRouter OAuth code...");
const token = await exchangeOpenRouterOAuthCode({
code,
codeVerifier: pkce.verifier,
fetchImpl: options.fetchImpl,
});
progress.stop("OpenRouter OAuth complete");
const metadata = {
authFlow: "oauth-pkce",
...(token.userId ? { userId: token.userId } : {}),
};
const credential = {
...buildApiKeyCredential(PROVIDER_ID, token.key, metadata),
displayName: token.userId ? `OpenRouter ${token.userId}` : "OpenRouter OAuth",
};
return {
profiles: [{ profileId: OPENROUTER_OAUTH_PROFILE_ID, credential }],
configPatch: applyOpenrouterConfig(ctx.config),
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
notes: [
"OpenRouter OAuth issued an OpenRouter API key and stored it in the default OpenRouter auth profile.",
"Re-run OpenRouter OAuth to rotate that key or use the API-key setup path for a key you manage manually.",
],
};
} catch (err) {
progress.stop("OpenRouter OAuth failed");
throw new Error(`OpenRouter OAuth failed: ${formatErrorMessage(err)}`, { cause: err });
}
}
export function createOpenRouterOAuthAuthMethod(): ProviderAuthMethod {
return {
id: OPENROUTER_OAUTH_METHOD_ID,
label: "OpenRouter OAuth",
hint: "Browser sign-in",
kind: "oauth",
wizard: {
choiceId: OPENROUTER_OAUTH_CHOICE_ID,
choiceLabel: "OpenRouter OAuth",
choiceHint: "Browser sign-in",
groupId: PROVIDER_ID,
groupLabel: "OpenRouter",
groupHint: "OAuth or API key",
methodId: OPENROUTER_OAUTH_METHOD_ID,
onboardingScopes: ["text-inference", "music-generation"],
onboardingFeatured: true,
},
run: async (ctx) => await loginOpenRouterOAuth(ctx),
};
}

View File

@@ -0,0 +1,28 @@
// Openrouter tests cover onboard plugin behavior.
import {
expectProviderOnboardAllowlistAlias,
expectProviderOnboardPrimaryAndFallbacks,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, it } from "vitest";
import {
applyOpenrouterConfig,
applyOpenrouterProviderConfig,
OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";
describe("openrouter onboard", () => {
it("adds allowlist entry and preserves alias", () => {
expectProviderOnboardAllowlistAlias({
applyProviderConfig: applyOpenrouterProviderConfig,
modelRef: OPENROUTER_DEFAULT_MODEL_REF,
alias: "Router",
});
});
it("sets primary model and preserves existing model fallbacks", () => {
expectProviderOnboardPrimaryAndFallbacks({
applyConfig: applyOpenrouterConfig,
modelRef: OPENROUTER_DEFAULT_MODEL_REF,
});
});
});

View File

@@ -0,0 +1,33 @@
// Openrouter setup module handles plugin onboarding behavior.
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[OPENROUTER_DEFAULT_MODEL_REF] = {
...models[OPENROUTER_DEFAULT_MODEL_REF],
alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyOpenrouterProviderConfig(cfg),
OPENROUTER_DEFAULT_MODEL_REF,
);
}

View File

@@ -0,0 +1,99 @@
{
"id": "openrouter",
"icon": "https://cdn.simpleicons.org/openrouter",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"providers": ["openrouter"],
"modelIdNormalization": {
"providers": {
"openrouter": {
"prefixWhenBare": "openrouter"
}
}
},
"modelPricing": {
"providers": {
"openrouter": {
"openRouter": {
"passthroughProviderModel": true
},
"liteLLM": false
}
}
},
"providerEndpoints": [
{
"endpointClass": "openrouter",
"hostSuffixes": ["openrouter.ai"]
}
],
"providerRequest": {
"providers": {
"openrouter": {
"family": "openrouter"
}
}
},
"setup": {
"providers": [
{
"id": "openrouter",
"envVars": ["OPENROUTER_API_KEY"]
}
]
},
"providerAuthChoices": [
{
"provider": "openrouter",
"method": "api-key",
"choiceId": "openrouter-api-key",
"choiceLabel": "OpenRouter API key",
"groupId": "openrouter",
"groupLabel": "OpenRouter",
"groupHint": "OAuth or API key",
"onboardingScopes": ["text-inference", "music-generation"],
"optionKey": "openrouterApiKey",
"cliFlag": "--openrouter-api-key",
"cliOption": "--openrouter-api-key <key>",
"cliDescription": "OpenRouter API key"
},
{
"provider": "openrouter",
"method": "oauth",
"choiceId": "openrouter-oauth",
"choiceLabel": "OpenRouter OAuth",
"choiceHint": "Browser sign-in",
"groupId": "openrouter",
"groupLabel": "OpenRouter",
"groupHint": "OAuth or API key",
"onboardingScopes": ["text-inference", "music-generation"],
"onboardingFeatured": true
}
],
"contracts": {
"mediaUnderstandingProviders": ["openrouter"],
"imageGenerationProviders": ["openrouter"],
"musicGenerationProviders": ["openrouter"],
"videoGenerationProviders": ["openrouter"],
"speechProviders": ["openrouter"]
},
"mediaUnderstandingProviderMetadata": {
"openrouter": {
"capabilities": ["image", "audio"],
"defaultModels": {
"image": "auto",
"audio": "openai/whisper-large-v3-turbo"
},
"autoPriority": {
"audio": 35
}
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,218 @@
// Openrouter tests cover openrouter plugin behavior.
import OpenAI from "openai";
import { AuthStorage, ModelRegistry } from "openclaw/plugin-sdk/agent-sessions";
import {
registerProviderPlugin,
requireRegisteredProvider,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { normalizeOpenRouterApiModelId } from "./models.js";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const OPENROUTER_MISTRAL_PROVIDER_PREFIX = "mistralai/";
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? "";
const LIVE_MODEL_REF =
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() ||
"openrouter/anthropic/claude-sonnet-4.6";
const LIVE_MODEL_ID = LIVE_MODEL_REF.startsWith("openrouter/")
? LIVE_MODEL_REF
: `openrouter/${LIVE_MODEL_REF}`;
const LIVE_CACHE_MODEL_ID =
process.env.OPENCLAW_LIVE_OPENROUTER_CACHE_MODEL?.trim() || "deepseek/deepseek-v3.2";
const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
const liveCatalogEnabled = process.env.OPENCLAW_LIVE_TEST === "1";
const describeLive = liveEnabled ? describe : describe.skip;
const describeCatalogLive = liveCatalogEnabled ? describe : describe.skip;
const describeCacheLive =
liveEnabled && process.env.OPENCLAW_LIVE_CACHE_TEST === "1" ? describe : describe.skip;
const ModelRegistryCtor = ModelRegistry as unknown as {
new (authStorage: AuthStorage, modelsJsonPath?: string): ModelRegistry;
};
type OpenRouterModelsResponse = {
data?: Array<{ id?: unknown }>;
};
const registerOpenRouterPlugin = async () =>
registerProviderPlugin({
plugin,
id: "openrouter",
name: "OpenRouter Provider",
});
function buildStableCachePrefix(): string {
return Array.from(
{ length: 700 },
(_, index) =>
`Stable OpenRouter cache probe sentence ${
index % 20
}: this prefix must stay byte-identical across repeated requests.`,
).join("\n");
}
async function completeOpenRouterChat(params: {
client: OpenAI;
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[];
model: string;
}) {
return params.client.chat.completions.create({
model: params.model,
messages: params.messages,
max_tokens: 8,
});
}
async function expectWeatherToolCall(client: OpenAI, model: string): Promise<void> {
const response = await client.chat.completions.create({
model,
messages: [{ role: "user", content: "Call get_weather for Paris." }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get the weather for a city.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
additionalProperties: false,
},
},
},
],
tool_choice: {
type: "function",
function: { name: "get_weather" },
},
max_tokens: 64,
});
const toolCall = response.choices[0]?.message?.tool_calls?.find(
(call) => call.type === "function",
);
expect(toolCall?.type).toBe("function");
expect(toolCall?.function.name).toBe("get_weather");
expect(JSON.parse(toolCall?.function.arguments ?? "{}")).toMatchObject({ city: "Paris" });
}
async function fetchOpenRouterModelIds(): Promise<string[]> {
const response = await fetch(OPENROUTER_MODELS_URL, {
headers: { "accept-encoding": "identity" },
});
expect(response.ok).toBe(true);
const json = (await response.json()) as OpenRouterModelsResponse;
return (json.data ?? [])
.map((model) => model.id)
.filter((id): id is string => typeof id === "string" && id.length > 0);
}
describeLive("openrouter plugin live", () => {
it("normalizes a prefixed OpenRouter model and completes a live tool call", async () => {
const { providers } = await registerOpenRouterPlugin();
const provider = requireRegisteredProvider(providers, "openrouter");
const resolved = provider.resolveDynamicModel?.({
provider: "openrouter",
modelId: LIVE_MODEL_ID,
modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()),
});
if (!resolved) {
throw new Error(`openrouter provider did not resolve ${LIVE_MODEL_ID}`);
}
expect(resolved.provider).toBe("openrouter");
expect(resolved.id).toBe(LIVE_MODEL_ID);
expect(resolved.api).toBe("openai-completions");
expect(resolved.baseUrl).toBe("https://openrouter.ai/api/v1");
const normalized =
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: resolved.id,
model: resolved,
}) ?? resolved;
expect(normalized.id).toBe(normalizeOpenRouterApiModelId(LIVE_MODEL_ID));
const client = new OpenAI({
apiKey: OPENROUTER_API_KEY,
baseURL: normalized.baseUrl,
});
const autoResolved = provider.resolveDynamicModel?.({
provider: "openrouter",
modelId: "openrouter/auto",
modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()),
});
if (!autoResolved) {
throw new Error("openrouter provider did not resolve openrouter/auto");
}
const autoModel =
provider.normalizeResolvedModel?.({
provider: "openrouter",
modelId: autoResolved.id,
model: autoResolved,
}) ?? autoResolved;
expect(autoModel.id).toBe("openrouter/auto");
await expectWeatherToolCall(client, autoModel.id);
await expectWeatherToolCall(client, normalized.id);
}, 30_000);
});
describeCatalogLive("openrouter plugin live model catalog", () => {
it("applies strict9 replay policy to current OpenRouter Mistral-family routes", async () => {
const liveMistralModelIds = (await fetchOpenRouterModelIds()).filter((id) =>
id.startsWith(OPENROUTER_MISTRAL_PROVIDER_PREFIX),
);
expect(liveMistralModelIds.length).toBeGreaterThan(0);
const { providers } = await registerOpenRouterPlugin();
const provider = requireRegisteredProvider(providers, "openrouter");
for (const modelId of liveMistralModelIds) {
const policy = provider.buildReplayPolicy?.({
provider: "openrouter",
modelApi: "openai-completions",
modelId,
} as never);
expect.soft(policy?.sanitizeToolCallIds, modelId).toBe(true);
expect.soft(policy?.toolCallIdMode, modelId).toBe("strict9");
}
}, 30_000);
});
describeCacheLive("openrouter plugin live cache", () => {
it("observes automatic cache reads for DeepSeek model refs after cache construction", async () => {
const { providers } = await registerOpenRouterPlugin();
const provider = requireRegisteredProvider(providers, "openrouter");
const resolved = provider.resolveDynamicModel?.({
provider: "openrouter",
modelId: LIVE_CACHE_MODEL_ID,
modelRegistry: new ModelRegistryCtor(AuthStorage.inMemory()),
});
if (!resolved) {
throw new Error(`openrouter provider did not resolve ${LIVE_CACHE_MODEL_ID}`);
}
const client = new OpenAI({
apiKey: OPENROUTER_API_KEY,
baseURL: resolved.baseUrl,
});
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "system",
content: `You are testing prompt caching.\n${buildStableCachePrefix()}`,
},
{ role: "user", content: "Reply with exactly OK." },
];
await completeOpenRouterChat({ client, model: resolved.id, messages });
await new Promise((resolve) => {
setTimeout(resolve, 2_000);
});
const cached = await completeOpenRouterChat({ client, model: resolved.id, messages });
const cachedTokens = cached.usage?.prompt_tokens_details?.cached_tokens ?? 0;
expect(cached.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
expect(cachedTokens).toBeGreaterThan(1024);
}, 60_000);
});

View File

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

View File

@@ -0,0 +1,89 @@
// Openrouter provider module implements model/runtime integration.
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const OPENROUTER_LEGACY_BASE_URL = "https://openrouter.ai/v1";
const OPENROUTER_DEFAULT_MODEL_ID = "openrouter/auto";
const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000;
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
const OPENROUTER_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const OPENROUTER_PROXY_REASONING_UNSUPPORTED_MODEL_IDS = new Set(["openrouter/hunter-alpha"]);
const OPENROUTER_KIMI_K2_6_COST = {
input: 0.8,
output: 3.5,
cacheRead: 0.2,
cacheWrite: 0,
};
const OPENROUTER_KIMI_K2_5_COST = {
input: 0.44,
output: 2,
cacheRead: 0.22,
cacheWrite: 0,
};
function normalizeBaseUrl(baseUrl: string | undefined): string {
return (baseUrl ?? "").trim().replace(/\/+$/, "");
}
export function normalizeOpenRouterBaseUrl(baseUrl: string | undefined): string | undefined {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) {
return undefined;
}
if (normalized === OPENROUTER_BASE_URL || normalized === OPENROUTER_LEGACY_BASE_URL) {
return OPENROUTER_BASE_URL;
}
return undefined;
}
export function isOpenRouterProxyReasoningUnsupportedModel(modelId: string | undefined): boolean {
const normalized = (modelId ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
return (
OPENROUTER_PROXY_REASONING_UNSUPPORTED_MODEL_IDS.has(normalized) ||
normalized.startsWith("openrouter/hunter-alpha:")
);
}
export function buildOpenrouterProvider(): ModelProviderConfig {
return {
baseUrl: OPENROUTER_BASE_URL,
api: "openai-completions",
models: [
{
id: OPENROUTER_DEFAULT_MODEL_ID,
name: "OpenRouter Auto",
reasoning: false,
input: ["text", "image"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
},
{
id: "moonshotai/kimi-k2.6",
name: "MoonshotAI: Kimi K2.6",
reasoning: true,
input: ["text", "image"],
cost: OPENROUTER_KIMI_K2_6_COST,
contextWindow: 262144,
maxTokens: 262144,
},
{
id: "moonshotai/kimi-k2.5",
name: "MoonshotAI: Kimi K2.5",
reasoning: true,
input: ["text", "image"],
cost: OPENROUTER_KIMI_K2_5_COST,
contextWindow: 262144,
maxTokens: 262144,
},
],
};
}

View File

@@ -0,0 +1,46 @@
// Openrouter API module exposes the plugin public contract.
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
export function createOpenrouterProvider(): ProviderPlugin {
return {
id: "openrouter",
label: "OpenRouter",
docsPath: "/providers/models",
envVars: ["OPENROUTER_API_KEY"],
auth: [
{
id: "api-key",
kind: "api_key",
label: "OpenRouter API key",
hint: "API key",
run: async () => ({ profiles: [] }),
wizard: {
choiceId: "openrouter-api-key",
choiceLabel: "OpenRouter API key",
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "OAuth or API key",
onboardingScopes: ["text-inference", "music-generation"],
},
},
{
id: "oauth",
kind: "oauth",
label: "OpenRouter OAuth",
hint: "Browser sign-in",
run: async () => ({ profiles: [] }),
wizard: {
choiceId: "openrouter-oauth",
choiceLabel: "OpenRouter OAuth",
choiceHint: "Browser sign-in",
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "OAuth or API key",
methodId: "oauth",
onboardingScopes: ["text-inference", "music-generation"],
onboardingFeatured: true,
},
},
],
};
}

View File

@@ -0,0 +1,6 @@
// Openrouter API module exposes the plugin public contract.
import { resolveOpenRouterThinkingProfile } from "./thinking-policy.js";
export function resolveThinkingProfile(params: { provider?: string; modelId: string }) {
return resolveOpenRouterThinkingProfile(params.modelId);
}

View File

@@ -0,0 +1,88 @@
// Openrouter provider module implements model/runtime integration.
type OpenRouterExtraParamsContext = {
config?: {
models?: {
providers?: Record<
string,
{
params?: Record<string, unknown>;
}
>;
};
};
extraParams: Record<string, unknown>;
provider: string;
model?: {
params?: Record<string, unknown>;
};
};
const BLOCKED_RECORD_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function sanitizeJsonLikeValue(value: unknown): unknown {
if (value === undefined) {
return undefined;
}
if (Array.isArray(value)) {
return value.map(sanitizeJsonLikeValue).filter((entry) => entry !== undefined);
}
if (!value || typeof value !== "object") {
return value;
}
return sanitizeRecord(value as Record<string, unknown>);
}
function sanitizeRecord(value: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(value)
.filter(([key, entry]) => !BLOCKED_RECORD_KEYS.has(key) && entry !== undefined)
.map(([key, entry]) => [key, sanitizeJsonLikeValue(entry)]),
);
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const sanitized = sanitizeRecord(value as Record<string, unknown>);
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
}
function mergeOpenRouterProviderRouting(params: {
providerParams?: Record<string, unknown>;
modelParams?: Record<string, unknown>;
extraParams: Record<string, unknown>;
}): Record<string, unknown> | undefined {
const providerRouting = readRecord(params.providerParams?.provider);
const modelRouting = readRecord(params.modelParams?.provider);
const extraRouting = readRecord(params.extraParams.provider);
const merged = {
...providerRouting,
...modelRouting,
...extraRouting,
};
return Object.keys(merged).length > 0 ? merged : undefined;
}
export function resolveOpenRouterExtraParamsForTransport(
ctx: OpenRouterExtraParamsContext,
): { patch?: Record<string, unknown> } | undefined {
const providerConfigParams = readRecord(ctx.config?.models?.providers?.[ctx.provider]?.params);
const modelParams = readRecord(ctx.model?.params);
const providerRouting = mergeOpenRouterProviderRouting({
providerParams: providerConfigParams,
modelParams,
extraParams: ctx.extraParams,
});
if (!providerConfigParams && !modelParams && !providerRouting) {
return undefined;
}
return {
patch: {
...providerConfigParams,
...modelParams,
...ctx.extraParams,
...(providerRouting ? { provider: providerRouting } : {}),
},
};
}

View File

@@ -0,0 +1,4 @@
// Openrouter tests cover provider runtime.contract plugin behavior.
import { describeOpenRouterProviderRuntimeContract } from "openclaw/plugin-sdk/provider-test-contracts";
describeOpenRouterProviderRuntimeContract(() => import("./index.js"));

View File

@@ -0,0 +1,219 @@
// Openrouter tests cover speech provider plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildOpenRouterSpeechProvider } from "./speech-provider.js";
const {
assertOkOrThrowHttpErrorMock,
postJsonRequestMock,
readProviderBinaryResponseMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
postJsonRequestMock: vi.fn(),
readProviderBinaryResponseMock: vi.fn(async (response: Response) => {
return new Uint8Array(await response.arrayBuffer());
}),
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,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
readProviderBinaryResponse: readProviderBinaryResponseMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
function requireOpenRouterConfigRequest(): Record<string, unknown> {
const [call] = resolveProviderHttpRequestConfigMock.mock.calls;
if (!call) {
throw new Error("expected OpenRouter speech config request");
}
const [request] = call;
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected OpenRouter speech config request");
}
return request;
}
function requireOpenRouterPostRequest(): Record<string, unknown> {
const [call] = postJsonRequestMock.mock.calls;
if (!call) {
throw new Error("expected OpenRouter speech request");
}
const [request] = call;
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected OpenRouter speech request");
}
return request as Record<string, unknown>;
}
function requireHeaders(value: unknown): Headers {
if (!(value instanceof Headers)) {
throw new Error("expected OpenRouter speech request headers");
}
return value;
}
describe("openrouter speech provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
postJsonRequestMock.mockReset();
readProviderBinaryResponseMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
vi.unstubAllEnvs();
});
it("normalizes provider-owned speech config", () => {
const provider = buildOpenRouterSpeechProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as never,
timeoutMs: 30_000,
rawConfig: {
providers: {
openrouter: {
apiKey: "sk-test",
baseUrl: "https://openrouter.ai/v1/",
modelId: "google/gemini-3.1-flash-tts-preview",
voiceId: "Kore",
speed: 1.1,
responseFormat: " MP3 ",
provider: {
options: {
openai: {
instructions: "Speak warmly.",
},
},
},
},
},
},
});
expect(resolved).toEqual({
apiKey: "sk-test",
baseUrl: "https://openrouter.ai/api/v1",
model: "google/gemini-3.1-flash-tts-preview",
voice: "Kore",
speed: 1.1,
responseFormat: "mp3",
provider: {
options: {
openai: {
instructions: "Speak warmly.",
},
},
},
});
});
it("synthesizes OpenAI-compatible speech through OpenRouter", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
release,
});
const provider = buildOpenRouterSpeechProvider();
const result = await provider.synthesize({
text: "hello",
cfg: {
models: {
providers: {
openrouter: {
apiKey: "sk-openrouter",
baseUrl: "https://openrouter.ai/v1/",
},
},
},
} as never,
providerConfig: {
model: "openai/gpt-4o-mini-tts-2025-12-15",
voice: "nova",
speed: 1.2,
},
target: "voice-note",
timeoutMs: 12_345,
});
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledOnce();
expect(requireOpenRouterConfigRequest()).toEqual({
baseUrl: "https://openrouter.ai/api/v1",
defaultBaseUrl: "https://openrouter.ai/api/v1",
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: "Bearer sk-openrouter",
"Content-Type": "application/json",
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
provider: "openrouter",
capability: "audio",
transport: "http",
});
expect(postJsonRequestMock).toHaveBeenCalledOnce();
const request = requireOpenRouterPostRequest();
const headers = requireHeaders(request.headers);
expect(Object.fromEntries(headers.entries())).toEqual({
authorization: "Bearer sk-openrouter",
"content-type": "application/json",
"http-referer": "https://openclaw.ai",
"x-openrouter-title": "OpenClaw",
});
expect(request).toEqual({
url: "https://openrouter.ai/api/v1/audio/speech",
headers,
body: {
model: "openai/gpt-4o-mini-tts-2025-12-15",
input: "hello",
voice: "nova",
response_format: "mp3",
speed: 1.2,
},
timeoutMs: 12_345,
fetchFn: fetch,
allowPrivateNetwork: false,
dispatcherPolicy: undefined,
});
expect(result.audioBuffer).toEqual(Buffer.from([1, 2, 3]));
expect(readProviderBinaryResponseMock).toHaveBeenCalledWith(
expect.any(Response),
"OpenRouter TTS API error",
"audio",
);
expect(result.outputFormat).toBe("mp3");
expect(result.fileExtension).toBe(".mp3");
expect(result.voiceCompatible).toBe(true);
expect(release).toHaveBeenCalledOnce();
});
it("defaults to a live-proven OpenRouter TTS model", () => {
const provider = buildOpenRouterSpeechProvider();
expect(
provider.resolveConfig?.({ cfg: {} as never, rawConfig: {}, timeoutMs: 30_000 }),
).toEqual({
model: "hexgrad/kokoro-82m",
voice: "af_alloy",
responseFormat: undefined,
provider: undefined,
});
});
it("uses OPENROUTER_API_KEY when provider config omits apiKey", () => {
vi.stubEnv("OPENROUTER_API_KEY", "sk-env");
const provider = buildOpenRouterSpeechProvider();
expect(
provider.isConfigured({
cfg: {} as never,
providerConfig: {},
timeoutMs: 30_000,
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,47 @@
// Openrouter provider module implements model/runtime integration.
import {
asObject,
createOpenAiCompatibleSpeechProvider,
type SpeechProviderPlugin,
} from "openclaw/plugin-sdk/speech";
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
const DEFAULT_OPENROUTER_TTS_MODEL = "hexgrad/kokoro-82m";
const DEFAULT_OPENROUTER_TTS_VOICE = "af_alloy";
const OPENROUTER_TTS_MODELS = [
DEFAULT_OPENROUTER_TTS_MODEL,
"google/gemini-3.1-flash-tts-preview",
"mistralai/voxtral-mini-tts-2603",
"elevenlabs/eleven-turbo-v2",
] as const;
const OPENROUTER_TTS_RESPONSE_FORMATS = ["mp3", "pcm"] as const;
type OpenRouterTtsExtraConfig = {
provider?: Record<string, unknown>;
};
export function buildOpenRouterSpeechProvider(): SpeechProviderPlugin {
return createOpenAiCompatibleSpeechProvider<OpenRouterTtsExtraConfig>({
id: "openrouter",
label: "OpenRouter",
autoSelectOrder: 35,
models: OPENROUTER_TTS_MODELS,
voices: [DEFAULT_OPENROUTER_TTS_VOICE],
defaultModel: DEFAULT_OPENROUTER_TTS_MODEL,
defaultVoice: DEFAULT_OPENROUTER_TTS_VOICE,
defaultBaseUrl: OPENROUTER_BASE_URL,
envKey: "OPENROUTER_API_KEY",
responseFormats: OPENROUTER_TTS_RESPONSE_FORMATS,
defaultResponseFormat: "mp3",
voiceCompatibleResponseFormats: ["mp3"],
baseUrlPolicy: { kind: "canonical", aliases: ["https://openrouter.ai/v1"] },
extraHeaders: {
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
apiErrorLabel: "OpenRouter TTS API error",
missingApiKeyError: "OpenRouter API key missing",
readExtraConfig: (raw) => ({ provider: asObject(raw?.provider) }),
extraJsonBodyFields: [{ configKey: "provider" }],
});
}

View File

@@ -0,0 +1,484 @@
// Openrouter plugin module implements stream behavior.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import {
createAssistantMessageEventStream,
type AssistantMessage,
type AssistantMessageEvent,
} from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import {
assertOkOrThrowHttpError,
fetchWithTimeoutGuarded,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import { OPENROUTER_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
import { createPayloadPatchStreamWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { isOpenRouterDeepSeekV4ModelId } from "./models.js";
import {
isOpenRouterProxyReasoningUnsupportedModel,
normalizeOpenRouterBaseUrl,
OPENROUTER_BASE_URL,
} from "./provider-catalog.js";
const log = createSubsystemLogger("openrouter-stream");
const OPENROUTER_GENERATION_LOOKUP_TIMEOUT_MS = 2_000;
type OpenRouterGenerationResponse = {
data?: {
total_cost?: unknown;
};
};
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() : undefined;
}
function isOpenRouterAnthropicModelId(modelId: unknown): boolean {
const normalized = readString(modelId)?.toLowerCase();
return (
normalized?.startsWith("anthropic/") === true ||
normalized?.startsWith("openrouter/anthropic/") === true
);
}
function isVerifiedOpenRouterRoute(model: Parameters<StreamFn>[0]): boolean {
const provider = readString(model.provider)?.toLowerCase();
const baseUrl = readString(model.baseUrl);
if (baseUrl) {
return normalizeOpenRouterBaseUrl(baseUrl) === OPENROUTER_BASE_URL;
}
return provider === "openrouter";
}
function shouldPatchAnthropicOpenRouterPayload(model: Parameters<StreamFn>[0]): boolean {
const api = readString(model.api);
return (
(api === undefined || api === "openai-completions") &&
isOpenRouterAnthropicModelId(model.id) &&
isVerifiedOpenRouterRoute(model)
);
}
function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters<StreamFn>[0]): boolean {
const api = readString(model.api);
return (
(api === undefined || api === "openai-completions") &&
isOpenRouterDeepSeekV4ModelId(model.id) &&
isVerifiedOpenRouterRoute(model)
);
}
function shouldPatchOpenRouterRoutingPayload(model: Parameters<StreamFn>[0]): boolean {
const api = readString(model.api);
return (api === undefined || api === "openai-completions") && isVerifiedOpenRouterRoute(model);
}
function resolveOpenRouterGenerationUrl(
model: Parameters<StreamFn>[0],
responseId: string,
): string {
const baseUrl = readString(model.baseUrl) || OPENROUTER_BASE_URL;
const url = new URL("generation", `${baseUrl.replace(/\/$/, "")}/`);
url.searchParams.set("id", responseId);
return url.href;
}
function readOpenRouterTotalCost(payload: OpenRouterGenerationResponse): number | undefined {
const totalCost = payload.data?.total_cost;
if (typeof totalCost !== "number" || !Number.isFinite(totalCost) || totalCost < 0) {
return undefined;
}
return totalCost;
}
function isDoneEvent(
event: AssistantMessageEvent,
): event is Extract<AssistantMessageEvent, { type: "done" }> {
return event.type === "done";
}
async function fetchOpenRouterGenerationTotalCost(params: {
apiKey: string;
model: Parameters<StreamFn>[0];
responseId: string;
}): Promise<number | undefined> {
const url = resolveOpenRouterGenerationUrl(params.model, params.responseId);
const { response, release } = await fetchWithTimeoutGuarded(
url,
{
method: "GET",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
},
OPENROUTER_GENERATION_LOOKUP_TIMEOUT_MS,
fetch,
{ auditContext: "openrouter-generation-cost" },
);
try {
await assertOkOrThrowHttpError(response, "OpenRouter generation metadata request failed");
return readOpenRouterTotalCost(
await readProviderJsonResponse<OpenRouterGenerationResponse>(
response,
"openrouter.generation-cost",
),
);
} finally {
await release();
}
}
async function applyOpenRouterBilledCost(params: {
apiKey: string | undefined;
message: AssistantMessage;
model: Parameters<StreamFn>[0];
}): Promise<void> {
const apiKey = readString(params.apiKey);
const responseId = readString((params.message as { responseId?: unknown }).responseId);
if (!apiKey || !responseId || !params.message.usage?.cost) {
return;
}
try {
const totalCost = await fetchOpenRouterGenerationTotalCost({
apiKey,
model: params.model,
responseId,
});
if (totalCost !== undefined) {
params.message.usage.cost.total = totalCost;
}
} catch (error) {
log.debug?.(
`kept streamed OpenRouter cost estimate because generation metadata lookup failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function createOpenRouterBilledCostWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {
return baseStreamFn;
}
return async (model, context, options) => {
const source = await baseStreamFn(model, context, options);
if (!isVerifiedOpenRouterRoute(model)) {
return source;
}
const output = createAssistantMessageEventStream();
const stream = output as unknown as { push(event: unknown): void; end(): void };
void (async () => {
try {
for await (const event of source as AsyncIterable<AssistantMessageEvent>) {
if (isDoneEvent(event)) {
await applyOpenRouterBilledCost({
apiKey: options?.apiKey,
message: event.message,
model,
});
}
stream.push(event);
}
} catch (error) {
stream.push({
type: "error",
reason: "error",
error: {
role: "assistant",
content: [],
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
},
});
} finally {
stream.end();
}
})();
return output as ReturnType<StreamFn>;
};
}
function mergeOpenRouterAuthHeaders(options: Parameters<StreamFn>[2]): Parameters<StreamFn>[2] {
const apiKey = readString(options?.apiKey);
if (!apiKey) {
return options;
}
const headers = new Headers((options as { headers?: HeadersInit } | undefined)?.headers);
if (!headers.has("authorization")) {
headers.set("Authorization", `Bearer ${apiKey}`);
}
if (!headers.has("http-referer")) {
headers.set("HTTP-Referer", "https://openclaw.ai");
}
if (!headers.has("x-openrouter-title")) {
headers.set("X-OpenRouter-Title", "OpenClaw");
}
return {
...options,
headers: Object.fromEntries(headers.entries()),
} as Parameters<StreamFn>[2];
}
function createOpenRouterAuthHeaderWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
if (!baseStreamFn) {
return baseStreamFn;
}
return (model, context, options) =>
baseStreamFn(
model,
context,
isVerifiedOpenRouterRoute(model) ? mergeOpenRouterAuthHeaders(options) : options,
);
}
function assistantMessageHasOpenAIToolCalls(message: Record<string, unknown>): boolean {
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
}
function isAnthropicToolCallContentBlock(value: unknown): boolean {
return (
value !== null &&
typeof value === "object" &&
((value as { type?: unknown }).type === "tool_use" ||
(value as { type?: unknown }).type === "toolCall")
);
}
function assistantMessageHasAnthropicToolUse(message: Record<string, unknown>): boolean {
const content = message.content;
return Array.isArray(content) && content.some(isAnthropicToolCallContentBlock);
}
function shouldStripOpenRouterTrailingMessage(value: unknown): boolean {
if (!value || typeof value !== "object") {
return false;
}
const message = value as Record<string, unknown>;
return (
message.role === "assistant" &&
!assistantMessageHasOpenAIToolCalls(message) &&
!assistantMessageHasAnthropicToolUse(message)
);
}
function stripTrailingOpenRouterAssistantPrefillMessages(payload: Record<string, unknown>): number {
const messages = payload.messages;
if (!Array.isArray(messages)) {
return 0;
}
let keep = messages.length;
while (keep > 0 && shouldStripOpenRouterTrailingMessage(messages[keep - 1])) {
keep -= 1;
}
if (keep === messages.length) {
return 0;
}
const stripped = messages.length - keep;
messages.splice(keep);
return stripped;
}
function isEnabledReasoningValue(value: unknown): boolean {
if (value === undefined || value === null || value === false) {
return false;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return normalized !== "" && normalized !== "off" && normalized !== "none";
}
if (typeof value === "object" && !Array.isArray(value)) {
const effort = (value as Record<string, unknown>).effort;
if (typeof effort === "string") {
const normalized = effort.trim().toLowerCase();
return normalized !== "" && normalized !== "off" && normalized !== "none";
}
}
return true;
}
function isOpenRouterReasoningPayloadEnabled(payload: Record<string, unknown>): boolean {
return (
isEnabledReasoningValue(payload.reasoning) || isEnabledReasoningValue(payload.reasoning_effort)
);
}
function stripOpenRouterDeepSeekV4ReasoningContent(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.messages)) {
return;
}
for (const message of payload.messages) {
if (!message || typeof message !== "object") {
continue;
}
delete (message as Record<string, unknown>).reasoning_content;
}
}
function backfillOpenRouterDeepSeekV4ReasoningContent(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.messages)) {
return;
}
for (const message of payload.messages) {
if (!message || typeof message !== "object") {
continue;
}
const record = message as Record<string, unknown>;
if (
record.role === "assistant" &&
!assistantMessageHasOpenAIToolCalls(record) &&
!("reasoning_content" in record)
) {
record.reasoning_content = "";
}
}
}
function injectOpenRouterRouting(
baseStreamFn: StreamFn | undefined,
providerRouting?: Record<string, unknown>,
): StreamFn | undefined {
if (!providerRouting) {
return baseStreamFn;
}
const routedStreamFn: StreamFn = (model, context, options) =>
(
baseStreamFn ??
((nextModel) => {
throw new Error(
`OpenRouter routing wrapper requires an underlying streamFn for ${nextModel.id}.`,
);
})
)(
{
...model,
compat: { ...model.compat, openRouterRouting: providerRouting },
} as typeof model,
context,
options,
);
return createPayloadPatchStreamWrapper(
routedStreamFn,
({ payload }) => {
if (payload.provider === undefined) {
payload.provider = providerRouting;
}
},
{
shouldPatch: ({ model }) => shouldPatchOpenRouterRoutingPayload(model),
},
);
}
function createOpenRouterAnthropicPrefillWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
return createPayloadPatchStreamWrapper(
baseStreamFn,
({ payload }) => {
if (!isOpenRouterReasoningPayloadEnabled(payload)) {
return;
}
const stripped = stripTrailingOpenRouterAssistantPrefillMessages(payload);
if (stripped > 0) {
log.warn(
`removed ${stripped} trailing assistant prefill message${stripped === 1 ? "" : "s"} because OpenRouter-routed Anthropic reasoning requires conversations to end with a user turn`,
);
}
},
{
shouldPatch: ({ model }) => shouldPatchAnthropicOpenRouterPayload(model),
},
);
}
function resolveOpenRouterDeepSeekV4ReasoningEffort(
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
): "high" | "xhigh" | undefined {
if (thinkingLevel === "off") {
return undefined;
}
if (thinkingLevel === "xhigh" || thinkingLevel === "max") {
return "xhigh";
}
return "high";
}
function applyOpenRouterDeepSeekV4ReasoningEffort(
payload: Record<string, unknown>,
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
): boolean {
const effort = resolveOpenRouterDeepSeekV4ReasoningEffort(thinkingLevel);
if (!effort) {
delete payload.reasoning;
return false;
}
const reasoning =
payload.reasoning && typeof payload.reasoning === "object" && !Array.isArray(payload.reasoning)
? (payload.reasoning as Record<string, unknown>)
: {};
reasoning.effort = effort;
payload.reasoning = reasoning;
return true;
}
function createOpenRouterDeepSeekV4ReplayWrapper(
baseStreamFn: StreamFn | undefined,
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
): StreamFn {
return createPayloadPatchStreamWrapper(
baseStreamFn,
({ payload }) => {
delete payload.thinking;
delete payload.reasoning_effort;
if (!applyOpenRouterDeepSeekV4ReasoningEffort(payload, thinkingLevel)) {
stripOpenRouterDeepSeekV4ReasoningContent(payload);
return;
}
backfillOpenRouterDeepSeekV4ReasoningContent(payload);
},
{
shouldPatch: ({ model }) => shouldPatchDeepSeekV4OpenRouterPayload(model),
},
);
}
export function wrapOpenRouterProviderStream(
ctx: ProviderWrapStreamFnContext,
): StreamFn | null | undefined {
const providerRouting =
ctx.extraParams?.provider != null && typeof ctx.extraParams.provider === "object"
? (ctx.extraParams.provider as Record<string, unknown>)
: undefined;
const routedStreamFn = providerRouting
? injectOpenRouterRouting(ctx.streamFn, providerRouting)
: ctx.streamFn;
const wrapStreamFn = OPENROUTER_THINKING_STREAM_HOOKS.wrapStreamFn ?? undefined;
if (!wrapStreamFn) {
return createOpenRouterBilledCostWrapper(
createOpenRouterAnthropicPrefillWrapper(
createOpenRouterAuthHeaderWrapper(
createOpenRouterDeepSeekV4ReplayWrapper(routedStreamFn, ctx.thinkingLevel),
),
),
);
}
const wrappedStreamFn =
wrapStreamFn({
...ctx,
streamFn: routedStreamFn,
thinkingLevel: isOpenRouterProxyReasoningUnsupportedModel(ctx.modelId)
? undefined
: ctx.thinkingLevel,
}) ?? undefined;
return createOpenRouterBilledCostWrapper(
createOpenRouterAnthropicPrefillWrapper(
createOpenRouterAuthHeaderWrapper(
createOpenRouterDeepSeekV4ReplayWrapper(wrappedStreamFn, ctx.thinkingLevel),
),
),
);
}

View File

@@ -0,0 +1,5 @@
// Openrouter API module exposes the plugin public contract.
export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
export { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
export { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
export { buildOpenRouterSpeechProvider } from "./speech-provider.js";

View File

@@ -0,0 +1,35 @@
// Openrouter plugin module implements thinking policy behavior.
import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
import { isOpenRouterDeepSeekV4ModelId } from "./models.js";
const OPENROUTER_DEEPSEEK_V4_THINKING_LEVEL_IDS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
] as const;
function buildOpenRouterDeepSeekV4ThinkingLevel(
id: (typeof OPENROUTER_DEEPSEEK_V4_THINKING_LEVEL_IDS)[number],
) {
return { id };
}
const OPENROUTER_DEEPSEEK_V4_THINKING_PROFILE = {
levels: OPENROUTER_DEEPSEEK_V4_THINKING_LEVEL_IDS.map(buildOpenRouterDeepSeekV4ThinkingLevel),
defaultLevel: "high",
} satisfies ProviderThinkingProfile;
export function supportsOpenRouterXHighThinking(modelId: string): boolean {
return isOpenRouterDeepSeekV4ModelId(modelId);
}
export function resolveOpenRouterThinkingProfile(
modelId: string,
): ProviderThinkingProfile | undefined {
return isOpenRouterDeepSeekV4ModelId(modelId)
? OPENROUTER_DEEPSEEK_V4_THINKING_PROFILE
: undefined;
}

View File

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

View File

@@ -0,0 +1,841 @@
// Openrouter tests cover video generation provider plugin behavior.
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
expectExplicitVideoGenerationCapabilities,
expectUnifiedModelCatalogEntries,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildOpenRouterVideoGenerationProvider,
listOpenRouterVideoModelCatalog,
} from "./video-generation-provider.js";
const SUPPORTED_DURATIONS_HINT = Symbol.for("openclaw.videoGeneration.supportedDurations");
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: new Response(JSON.stringify(value), {
status: 200,
headers: { "content-type": "application/json" },
}),
release: vi.fn(async () => {}),
};
}
function releasedOversizedJsonStream() {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16 * 1024 * 1024 + 1));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
release: vi.fn(async () => {}),
wasCanceled: () => canceled,
};
}
function releasedVideo(params: { contentType: string; bytes: string }) {
return {
response: new Response(Buffer.from(params.bytes), {
status: 200,
headers: { "content-type": params.contentType },
}),
release: vi.fn(async () => {}),
};
}
type OpenRouterVideoProvider = ReturnType<typeof buildOpenRouterVideoGenerationProvider>;
type OpenRouterVideoResult = Awaited<ReturnType<OpenRouterVideoProvider["generateVideo"]>>;
function requireGenerateCapabilities(provider: OpenRouterVideoProvider) {
const capabilities = provider.capabilities.generate;
if (!capabilities) {
throw new Error("expected OpenRouter generate capabilities");
}
return capabilities;
}
function requireFetchCallHeaders(index: number): Headers {
const call = fetchWithTimeoutGuardedMock.mock.calls[index];
if (!call) {
throw new Error(`expected OpenRouter fetch call ${index + 1}`);
}
const init = call[1] as { headers?: HeadersInit } | undefined;
if (!init) {
throw new Error(`expected OpenRouter fetch call ${index + 1} init`);
}
return new Headers(init.headers);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null) {
throw new Error(`${label} was not an object`);
}
return value as Record<string, unknown>;
}
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
for (const [key, value] of Object.entries(fields)) {
expect(record[key]).toEqual(value);
}
}
function expectOpenRouterFetchCall(index: number, url: string, auditContext: string) {
const call = fetchWithTimeoutGuardedMock.mock.calls[index];
if (!call) {
throw new Error(`expected OpenRouter fetch call ${index + 1}`);
}
const [actualUrl, init, timeoutMs, fetchFn, guardOptions] = call;
expect(actualUrl).toBe(url);
expect(requireRecord(init, "OpenRouter fetch init").method).toBe("GET");
expect(Number.isInteger(timeoutMs)).toBe(true);
expect(timeoutMs).toBeGreaterThan(0);
expect(fetchFn).toBe(fetch);
expect(requireRecord(guardOptions, "OpenRouter fetch guard options").auditContext).toBe(
auditContext,
);
}
function requirePostJsonParams(index = 0): Record<string, unknown> {
const call = postJsonRequestMock.mock.calls[index] as unknown[] | undefined;
if (!call) {
throw new Error(`expected OpenRouter POST JSON call ${index + 1}`);
}
return requireRecord(call[0], "OpenRouter POST JSON params");
}
function requireMockCallArg(
mockCalls: unknown[][],
index: number,
argIndex: number,
label: string,
) {
const call = mockCalls.at(index);
if (!call) {
throw new Error(`expected ${label} call ${index + 1}`);
}
return call.at(argIndex);
}
function requireGeneratedVideo(result: OpenRouterVideoResult, index: number) {
const video = result.videos[index];
if (!video) {
throw new Error(`expected OpenRouter generated video at index ${index}`);
}
return video;
}
function requireGeneratedVideoBuffer(result: OpenRouterVideoResult, index: number) {
const video = requireGeneratedVideo(result, index);
expect(video.buffer).toBeInstanceOf(Buffer);
if (!video.buffer) {
throw new Error(`expected OpenRouter generated video ${index} buffer`);
}
return { video, buffer: video.buffer };
}
describe("openrouter video generation provider", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
});
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");
const generateCapabilities = requireGenerateCapabilities(provider);
expect(generateCapabilities.supportsAudio).toBe(true);
expect(generateCapabilities.supportedDurationSeconds).toEqual([4, 6, 8]);
expect(generateCapabilities.resolutions).toEqual(["720P", "1080P"]);
expect(generateCapabilities.aspectRatios).toEqual(["16:9", "9:16"]);
expect(provider.capabilities.imageToVideo?.enabled).toBe(true);
expect(provider.capabilities.videoToVideo?.enabled).toBe(false);
});
it("maps OpenRouter video model discovery into unified catalog rows", async () => {
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(
releasedJson({
data: [
{
id: "google/veo-3.1",
name: "Veo 3.1",
canonical_slug: "google/veo-3.1",
description: "Google video generation model",
created: 1_700_000_000,
generate_audio: true,
supported_aspect_ratios: ["16:9"],
supported_durations: [5, 8],
supported_frame_images: ["first_frame", "last_frame"],
supported_resolutions: ["720p"],
supported_sizes: ["1280x720"],
allowed_passthrough_parameters: ["provider"],
pricing_skus: { generate: "0.50" },
},
],
}),
);
const rows = await listOpenRouterVideoModelCatalog({
config: {
models: {
providers: {
openrouter: {
baseUrl: "https://custom.openrouter.test/openrouter/api/v1",
},
},
},
} as never,
env: {},
resolveProviderApiKey: () => ({
apiKey: "OPENROUTER_API_KEY",
discoveryApiKey: "resolved-openrouter-key",
}),
resolveProviderAuth: () => ({
apiKey: "OPENROUTER_API_KEY",
discoveryApiKey: "resolved-openrouter-key",
mode: "api_key",
source: "env",
}),
});
expectRecordFields(
requireRecord(
requireMockCallArg(resolveProviderHttpRequestConfigMock.mock.calls, 0, 0, "request config"),
"request config",
),
{
baseUrl: "https://custom.openrouter.test/openrouter/api/v1",
defaultBaseUrl: "https://openrouter.ai/api/v1",
provider: "openrouter",
capability: "video",
},
);
expectOpenRouterFetchCall(
0,
"https://custom.openrouter.test/openrouter/api/v1/videos/models",
"openrouter-video-models",
);
expect(requireFetchCallHeaders(0).get("authorization")).toBe("Bearer resolved-openrouter-key");
expectUnifiedModelCatalogEntries(rows, {
provider: "openrouter",
kind: "video_generation",
});
expect(rows).toHaveLength(1);
const row = rows[0];
if (!row) {
throw new Error("expected OpenRouter catalog row");
}
expectRecordFields(row as unknown as Record<string, unknown>, {
kind: "video_generation",
provider: "openrouter",
model: "google/veo-3.1",
label: "Veo 3.1",
source: "live",
});
const capabilities = requireRecord(row.capabilities, "catalog row capabilities");
expectRecordFields(capabilities, {
canonicalSlug: "google/veo-3.1",
description: "Google video generation model",
created: 1_700_000_000,
pricingSkus: { generate: "0.50" },
allowedPassthroughParameters: ["provider"],
videoToVideo: { enabled: false },
});
expectRecordFields(requireRecord(capabilities.generate, "generate capabilities"), {
supportsAudio: true,
supportedDurationSeconds: [5, 8],
aspectRatios: ["16:9"],
resolutions: ["720P"],
sizes: ["1280x720"],
});
expectRecordFields(requireRecord(capabilities.imageToVideo, "image-to-video capabilities"), {
enabled: true,
maxInputImages: 2,
});
});
it("cancels oversized OpenRouter video catalog success bodies", async () => {
const oversized = releasedOversizedJsonStream();
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(oversized);
await expect(
listOpenRouterVideoModelCatalog({
config: {
models: {
providers: {
openrouter: {
baseUrl: "https://custom.openrouter.test/openrouter/api/v1",
},
},
},
} as never,
env: {},
resolveProviderApiKey: () => ({
apiKey: "OPENROUTER_API_KEY",
discoveryApiKey: "resolved-openrouter-key",
}),
resolveProviderAuth: () => ({
apiKey: "OPENROUTER_API_KEY",
discoveryApiKey: "resolved-openrouter-key",
mode: "api_key",
source: "env",
}),
}),
).rejects.toThrow(
"OpenRouter video models request failed: JSON response exceeds 16777216 bytes",
);
expect(oversized.wasCanceled()).toBe(true);
expect(oversized.release).toHaveBeenCalledOnce();
});
it("skips live OpenRouter video catalog discovery without an API key", async () => {
await expect(
listOpenRouterVideoModelCatalog({
config: {} as never,
env: {},
resolveProviderApiKey: () => ({ apiKey: "OPENROUTER_API_KEY" }),
resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }),
}),
).resolves.toBeNull();
expect(fetchWithTimeoutGuardedMock).not.toHaveBeenCalled();
});
it("resolves live per-model capabilities for runtime overlays", async () => {
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(
releasedJson({
data: [
{
id: "google/veo-3.1",
name: "Veo 3.1",
generate_audio: false,
supported_durations: [5],
supported_resolutions: ["720p"],
allowed_passthrough_parameters: ["seed"],
},
],
}),
);
const provider = buildOpenRouterVideoGenerationProvider();
const capabilities = await provider.resolveModelCapabilities?.({
provider: "openrouter",
model: "google/veo-3.1",
cfg: {
models: {
providers: {
openrouter: {
baseUrl: "https://custom.openrouter.test/openrouter/api/v1",
},
},
},
} as never,
timeoutMs: 12_345,
});
expect(
requireRecord(
requireMockCallArg(resolveApiKeyForProviderMock.mock.calls, 0, 0, "API key request"),
"API key request",
).provider,
).toBe("openrouter");
expectOpenRouterFetchCall(
0,
"https://custom.openrouter.test/openrouter/api/v1/videos/models",
"openrouter-video-models",
);
expect(requireMockCallArg(fetchWithTimeoutGuardedMock.mock.calls, 0, 2, "fetch")).toBe(12_345);
expect(requireMockCallArg(fetchWithTimeoutGuardedMock.mock.calls, 0, 3, "fetch")).toBeTypeOf(
"function",
);
const resolvedCapabilities = requireRecord(capabilities, "resolved capabilities");
expect(resolvedCapabilities.providerOptions).toEqual({
callback_url: "string",
seed: "number",
});
expectRecordFields(requireRecord(resolvedCapabilities.generate, "generate capabilities"), {
supportsAudio: false,
supportedDurationSeconds: [5],
resolutions: ["720P"],
});
});
it("clamps direct exact integer durations to static OpenRouter supported values", async () => {
postJsonRequestMock.mockResolvedValue(
releasedJson({
id: "job-123",
polling_url: "/api/v1/videos/job-123",
status: "completed",
unsigned_urls: ["/api/v1/videos/job-123/content?index=0"],
}),
);
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(
releasedVideo({ contentType: "video/mp4", bytes: "mp4-bytes" }),
);
const provider = buildOpenRouterVideoGenerationProvider();
await provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "A brushed steel logo rotates against a clean white backdrop",
durationSeconds: 7,
cfg: {} as never,
});
expect(requireRecord(requirePostJsonParams().body, "OpenRouter request body").duration).toBe(8);
});
it("preserves runtime-normalized live catalog durations in request bodies", async () => {
postJsonRequestMock.mockResolvedValue(
releasedJson({
id: "job-123",
polling_url: "/api/v1/videos/job-123",
status: "completed",
unsigned_urls: ["/api/v1/videos/job-123/content?index=0"],
}),
);
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(
releasedVideo({ contentType: "video/mp4", bytes: "mp4-bytes" }),
);
const provider = buildOpenRouterVideoGenerationProvider();
await provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "A brushed steel logo rotates against a clean white backdrop",
durationSeconds: 5,
cfg: {} as never,
[SUPPORTED_DURATIONS_HINT]: [5],
} as Parameters<typeof provider.generateVideo>[0] & {
[SUPPORTED_DURATIONS_HINT]: readonly number[];
});
expect(requireRecord(requirePostJsonParams().body, "OpenRouter request body").duration).toBe(5);
});
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(
requireRecord(
requireMockCallArg(resolveApiKeyForProviderMock.mock.calls, 0, 0, "API key request"),
"API key request",
).provider,
).toBe("openrouter");
expectRecordFields(
requireRecord(
requireMockCallArg(resolveProviderHttpRequestConfigMock.mock.calls, 0, 0, "request config"),
"request config",
),
{
provider: "openrouter",
capability: "video",
baseUrl: "https://custom.openrouter.test/api/v1",
allowPrivateNetwork: false,
request: requestOverrides,
},
);
const postParams = requirePostJsonParams();
expect(postParams.url).toBe("https://custom.openrouter.test/api/v1/videos");
expect(postParams.body).toEqual({
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,
});
expectOpenRouterFetchCall(
0,
"https://custom.openrouter.test/api/v1/videos/job-123",
"openrouter-video-status",
);
expect(requireFetchCallHeaders(0).get("authorization")).toBe("Bearer openrouter-key");
expectOpenRouterFetchCall(
1,
"https://custom.openrouter.test/api/v1/videos/job-123/content?index=0",
"openrouter-video-download",
);
expect(requireFetchCallHeaders(1).get("authorization")).toBe("Bearer openrouter-key");
const { video, buffer } = requireGeneratedVideoBuffer(result, 0);
expect(buffer.toString()).toBe("mp4-bytes");
expect(video.mimeType).toBe("video/mp4");
expect(result.metadata).toEqual({
jobId: "job-123",
status: "completed",
generationId: "gen-123",
usage: { cost: 0.25, is_byok: false },
});
});
it("returns unsigned URL-only videos when downloads exceed the configured media cap", async () => {
postJsonRequestMock.mockResolvedValue(
releasedJson({
id: "job-123",
polling_url: "/api/v1/videos/job-123",
status: "completed",
unsigned_urls: ["https://cdn.openrouter.test/video.mp4"],
}),
);
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(
releasedVideo({ contentType: "video/mp4", bytes: "too-large" }),
);
const provider = buildOpenRouterVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "A glass cube reflects a neon skyline",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } } as never,
});
expect(result.videos).toEqual([
{
url: "https://cdn.openrouter.test/video.mp4",
mimeType: "video/mp4",
fileName: "video-1.mp4",
},
]);
});
it("rejects malformed numeric seed values before submitting video jobs", async () => {
const provider = buildOpenRouterVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "A glass cube reflects a neon skyline",
cfg: {} as never,
providerOptions: {
seed: 42.9,
},
}),
).rejects.toThrow("OpenRouter video seed must be an integer");
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
it("wraps malformed successful OpenRouter submit responses", async () => {
postJsonRequestMock.mockResolvedValue(releasedJson([]));
const provider = buildOpenRouterVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "bad shape",
cfg: {} as never,
}),
).rejects.toThrow("OpenRouter video generation response malformed");
});
it("wraps non-JSON successful OpenRouter submit responses", async () => {
postJsonRequestMock.mockResolvedValue({
response: new Response("<html></html>", {
status: 200,
headers: { "content-type": "text/html" },
}),
release: vi.fn(async () => {}),
});
const provider = buildOpenRouterVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "html body",
cfg: {} as never,
}),
).rejects.toThrow("OpenRouter video generation response malformed");
});
it("bounds oversized successful OpenRouter submit responses", async () => {
const oversized = releasedOversizedJsonStream();
postJsonRequestMock.mockResolvedValue(oversized);
const provider = buildOpenRouterVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "oversized body",
cfg: {} as never,
}),
).rejects.toThrow("OpenRouter video generation: JSON response exceeds 16777216 bytes");
expect(oversized.wasCanceled()).toBe(true);
});
it("rejects unknown OpenRouter poll statuses without waiting for timeout", async () => {
postJsonRequestMock.mockResolvedValue(
releasedJson({
id: "job-123",
polling_url: "/api/v1/videos/job-123",
status: "pending",
}),
);
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(
releasedJson({
id: "job-123",
status: "nearly_done",
}),
);
const provider = buildOpenRouterVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "bad status",
cfg: {} as never,
}),
).rejects.toThrow("OpenRouter video generation response malformed");
expect(waitProviderOperationPollIntervalMock).not.toHaveBeenCalled();
});
it("rejects malformed OpenRouter completed output URL arrays", async () => {
postJsonRequestMock.mockResolvedValue(
releasedJson({
id: "job-123",
polling_url: "/api/v1/videos/job-123",
status: "completed",
unsigned_urls: { 0: "/api/v1/videos/job-123/content?index=0" },
}),
);
const provider = buildOpenRouterVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "openrouter",
model: "google/veo-3.1",
prompt: "bad urls",
cfg: {} as never,
}),
).rejects.toThrow("OpenRouter video generation response malformed");
});
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,
});
expectOpenRouterFetchCall(
0,
"https://polling.example.test/videos/job-123",
"openrouter-video-status",
);
expect(requireFetchCallHeaders(0).get("authorization")).toBeNull();
expectOpenRouterFetchCall(
1,
"https://cdn.openrouter.test/video.mp4",
"openrouter-video-download",
);
expect(requireFetchCallHeaders(1).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/webm", bytes: "webm-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,
});
expectOpenRouterFetchCall(
0,
"https://openrouter.ai/api/v1/videos/job-123/content?index=0",
"openrouter-video-download",
);
const { video, buffer } = requireGeneratedVideoBuffer(result, 0);
expect(buffer.toString()).toBe("webm-bytes");
expect(video.fileName).toBe("video-1.webm");
});
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");
});
});

View File

@@ -0,0 +1,590 @@
// Openrouter provider module implements model/runtime integration.
import { toImageDataUrl } from "openclaw/plugin-sdk/image-generation";
import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
postJsonRequest,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
resolveProviderOperationTimeoutMs,
sanitizeConfiguredModelProviderRequest,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
GeneratedVideoAsset,
VideoGenerationProvider,
VideoGenerationRequest,
VideoGenerationSourceAsset,
} from "openclaw/plugin-sdk/video-generation";
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
import {
fetchOpenRouterVideoGet,
resolveOpenRouterVideoUrl,
type OpenRouterVideoDispatcherPolicy,
} from "./video-http.js";
import { resolveOpenRouterVideoModelCapabilities } from "./video-model-catalog.js";
export { listOpenRouterVideoModelCatalog } from "./video-model-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 DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024;
const SUPPORTED_ASPECT_RATIOS = ["16:9", "9:16"] as const;
const OPENROUTER_VIDEO_MALFORMED_RESPONSE = "OpenRouter video generation response malformed";
const SUPPORTED_DURATION_SECONDS = [4, 6, 8] as const;
// Runtime sets this after normalizing against live model capabilities.
const SUPPORTED_DURATIONS_HINT = Symbol.for("openclaw.videoGeneration.supportedDurations");
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";
};
async function readOpenRouterVideoJson(response: Response): Promise<Record<string, unknown>> {
let payload: unknown;
try {
payload = await readProviderJsonResponse<unknown>(response, "OpenRouter video generation");
} catch (error) {
if (error instanceof Error && error.message.endsWith(": malformed JSON response")) {
throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE, { cause: error });
}
throw error;
}
if (!isRecord(payload)) {
throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE);
}
return payload;
}
function readOpenRouterVideoResponse(payload: Record<string, unknown>): OpenRouterVideoResponse {
const unsignedUrls = payload.unsigned_urls;
if (unsignedUrls !== undefined && unsignedUrls !== null && !Array.isArray(unsignedUrls)) {
throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE);
}
const usage = payload.usage;
if (usage !== undefined && usage !== null && !isRecord(usage)) {
throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE);
}
return {
id: normalizeOptionalString(payload.id),
generation_id: normalizeOptionalString(payload.generation_id) ?? null,
polling_url: normalizeOptionalString(payload.polling_url),
status: normalizeOptionalString(payload.status),
unsigned_urls: Array.isArray(unsignedUrls)
? unsignedUrls.map((url) => {
const normalized = normalizeOptionalString(url);
if (!normalized) {
throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE);
}
return normalized;
})
: undefined,
error: normalizeOptionalString(payload.error) ?? null,
model: normalizeOptionalString(payload.model) ?? null,
usage: isRecord(usage)
? {
cost: typeof usage.cost === "number" ? usage.cost : null,
is_byok: typeof usage.is_byok === "boolean" ? usage.is_byok : undefined,
}
: undefined,
};
}
function toDataUrl(asset: VideoGenerationSourceAsset): string {
if (asset.buffer) {
return toImageDataUrl({ ...asset, buffer: asset.buffer, defaultMimeType: "image/png" });
}
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,
supportedDurations: readonly number[] = SUPPORTED_DURATION_SECONDS,
): number | undefined {
if (typeof durationSeconds !== "number" || !Number.isFinite(durationSeconds)) {
return undefined;
}
const effectiveDurations =
supportedDurations.length > 0 ? supportedDurations : SUPPORTED_DURATION_SECONDS;
const rounded = Math.max(1, Math.round(durationSeconds));
if (durationSeconds === rounded && effectiveDurations.includes(rounded)) {
return rounded;
}
return effectiveDurations.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 resolveSeed(seed: unknown): number | undefined {
if (seed === undefined) {
return undefined;
}
if (typeof seed !== "number") {
return undefined;
}
if (!Number.isSafeInteger(seed)) {
throw new Error("OpenRouter video seed must be an integer");
}
return seed;
}
function buildRequestBody(req: VideoGenerationRequest, model: string): Record<string, unknown> {
const { frameImages, inputReferences } = buildImageInputs(req.inputImages);
const supportedDurations =
(req as VideoGenerationRequest & { [SUPPORTED_DURATIONS_HINT]?: readonly number[] })[
SUPPORTED_DURATIONS_HINT
] ?? SUPPORTED_DURATION_SECONDS;
const body: Record<string, unknown> = {
model,
prompt: req.prompt,
};
const duration = resolveDurationSeconds(req.durationSeconds, supportedDurations);
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 = resolveSeed(req.providerOptions?.seed);
if (seed !== undefined) {
body.seed = 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: OpenRouterVideoDispatcherPolicy;
errorContext: string;
auditContext: string;
}): Promise<OpenRouterVideoResponse> {
const { response, release } = await fetchOpenRouterVideoGet(params);
try {
await assertOkOrThrowHttpError(response, params.errorContext);
return readOpenRouterVideoResponse(await readOpenRouterVideoJson(response));
} finally {
await release();
}
}
async function pollOpenRouterVideo(params: {
pollingUrl: string;
baseUrl: string;
headers: Headers;
timeoutMs: number;
allowPrivateNetwork: boolean;
dispatcherPolicy: OpenRouterVideoDispatcherPolicy;
}): 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 ||
(!["queued", "pending", "processing", "running", "completed"].includes(status) &&
!isTerminalFailure(status))
) {
throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE);
}
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 resolveOpenRouterContentUrl(params: { baseUrl: string; jobId: string }): string {
return resolveOpenRouterVideoUrl(
`videos/${encodeURIComponent(params.jobId)}/content?index=0`,
params.baseUrl,
);
}
function resolveDeliverableOpenRouterVideoUrl(value: string | undefined): string | undefined {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return undefined;
}
try {
const url = new URL(normalized);
return url.protocol === "https:" || url.protocol === "http:" ? normalized : undefined;
} catch {
return undefined;
}
}
function resolveGeneratedVideoMaxBytes(req: VideoGenerationRequest): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * 1024 * 1024);
}
return DEFAULT_GENERATED_VIDEO_MAX_BYTES;
}
async function downloadOpenRouterVideo(params: {
url: string;
deliveryUrl?: string;
baseUrl: string;
headers: Headers;
timeoutMs: number;
allowPrivateNetwork: boolean;
dispatcherPolicy: OpenRouterVideoDispatcherPolicy;
maxBytes: number;
}): Promise<GeneratedVideoAsset> {
const { response, release } = await fetchOpenRouterVideoGet({
...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 fileName = `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`;
let exceededMaxBytes = false;
let buffer: Buffer;
try {
buffer = await readResponseWithLimit(response, params.maxBytes, {
onOverflow: ({ maxBytes }) => {
exceededMaxBytes = true;
return new Error(`OpenRouter generated video download exceeds ${maxBytes} bytes`);
},
});
} catch (error) {
if (exceededMaxBytes && params.deliveryUrl) {
return {
url: params.deliveryUrl,
mimeType,
fileName,
};
}
throw error;
}
return {
buffer,
mimeType,
fileName,
};
} finally {
await release();
}
}
export function buildOpenRouterVideoGenerationProvider(): VideoGenerationProvider {
return {
id: "openrouter",
label: "OpenRouter",
defaultModel: DEFAULT_MODEL,
models: [DEFAULT_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({ provider: "openrouter", agentDir }),
resolveModelCapabilities: resolveOpenRouterVideoModelCapabilities,
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 = readOpenRouterVideoResponse(await readOpenRouterVideoJson(response));
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 submittedStatus = normalizeOptionalString(submitted.status);
if (
submittedStatus &&
!["queued", "pending", "processing", "running", "completed"].includes(submittedStatus) &&
!isTerminalFailure(submittedStatus)
) {
throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE);
}
if (isTerminalFailure(submittedStatus)) {
throw new Error(
normalizeOptionalString(submitted.error) ??
`OpenRouter video generation ${submittedStatus}`,
);
}
const completed =
submittedStatus === "completed"
? submitted
: await pollOpenRouterVideo({
pollingUrl,
baseUrl,
headers,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
allowPrivateNetwork,
dispatcherPolicy,
});
const completedJobId = normalizeOptionalString(completed.id) ?? jobId;
const unsignedUrl = completed.unsigned_urls?.find((url) => normalizeOptionalString(url));
const videoUrl =
unsignedUrl ?? resolveOpenRouterContentUrl({ baseUrl, jobId: completedJobId });
const video = await downloadOpenRouterVideo({
url: videoUrl,
deliveryUrl: resolveDeliverableOpenRouterVideoUrl(unsignedUrl),
baseUrl,
headers,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
}),
allowPrivateNetwork,
dispatcherPolicy,
maxBytes: resolveGeneratedVideoMaxBytes(req),
});
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();
}
},
};
}

View File

@@ -0,0 +1,49 @@
// Openrouter plugin module implements video http behavior.
import { fetchWithTimeoutGuarded } from "openclaw/plugin-sdk/provider-http";
type GuardedFetchResult = Awaited<ReturnType<typeof fetchWithTimeoutGuarded>>;
type FetchGuardOptions = NonNullable<Parameters<typeof fetchWithTimeoutGuarded>[4]>;
export type OpenRouterVideoDispatcherPolicy = FetchGuardOptions["dispatcherPolicy"];
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;
}
export function resolveOpenRouterVideoUrl(url: string, baseUrl: string): string {
return new URL(url, `${baseUrl}/`).href;
}
export async function fetchOpenRouterVideoGet(params: {
url: string;
baseUrl: string;
headers: Headers;
timeoutMs: number;
allowPrivateNetwork: boolean;
dispatcherPolicy: OpenRouterVideoDispatcherPolicy;
auditContext: string;
}): Promise<GuardedFetchResult> {
const url = resolveOpenRouterVideoUrl(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,
},
);
}

View File

@@ -0,0 +1,300 @@
// Openrouter plugin module implements video model catalog behavior.
import type {
UnifiedModelCatalogEntry,
UnifiedModelCatalogProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
assertOkOrThrowHttpError,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
normalizeOptionalString,
normalizeTrimmedStringList,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
VideoGenerationModelCapabilitiesContext,
VideoGenerationProviderCapabilities,
VideoGenerationResolution,
} from "openclaw/plugin-sdk/video-generation";
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
import { fetchOpenRouterVideoGet, type OpenRouterVideoDispatcherPolicy } from "./video-http.js";
const DEFAULT_HTTP_TIMEOUT_MS = 60_000;
type OpenRouterVideoModel = {
allowed_passthrough_parameters?: unknown;
canonical_slug?: unknown;
created?: unknown;
description?: unknown;
generate_audio?: unknown;
id?: unknown;
name?: unknown;
pricing_skus?: unknown;
seed?: unknown;
supported_aspect_ratios?: unknown;
supported_durations?: unknown;
supported_frame_images?: unknown;
supported_resolutions?: unknown;
supported_sizes?: unknown;
};
type OpenRouterVideoModelsResponse = {
data?: OpenRouterVideoModel[];
};
export type OpenRouterVideoModelCatalogCapabilities = VideoGenerationProviderCapabilities & {
allowedPassthroughParameters?: readonly string[];
canonicalSlug?: string;
created?: number;
description?: string;
pricingSkus?: Readonly<Record<string, string>>;
};
function normalizeStringArray(value: unknown): string[] {
return normalizeTrimmedStringList(value);
}
function normalizeNumberArray(value: unknown): number[] {
return Array.isArray(value)
? value.filter((entry): entry is number => typeof entry === "number" && Number.isFinite(entry))
: [];
}
function normalizeResolutionArray(value: unknown): VideoGenerationResolution[] {
return normalizeStringArray(value).map(
(entry) => entry.toUpperCase() as VideoGenerationResolution,
);
}
function normalizeFrameImageRoles(value: unknown): Array<"first_frame" | "last_frame"> {
const seen = new Set<"first_frame" | "last_frame">();
for (const entry of normalizeStringArray(value)) {
if (entry === "first_frame" || entry === "last_frame") {
seen.add(entry);
}
}
return [...seen];
}
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
const normalized = normalizeOptionalString(raw);
if (normalized) {
record[key] = normalized;
}
}
return Object.keys(record).length > 0 ? record : undefined;
}
function buildOpenRouterVideoModeCapabilities(params: {
durations: number[];
aspectRatios: string[];
resolutions: VideoGenerationResolution[];
sizes: string[];
supportsAudio?: boolean;
}): NonNullable<VideoGenerationProviderCapabilities["generate"]> {
return {
maxVideos: 1,
...(params.durations.length > 0 ? { supportedDurationSeconds: params.durations } : {}),
...(params.aspectRatios.length > 0
? {
supportsAspectRatio: true,
aspectRatios: params.aspectRatios,
}
: {}),
...(params.resolutions.length > 0
? {
supportsResolution: true,
resolutions: params.resolutions,
}
: {}),
...(params.sizes.length > 0
? {
supportsSize: true,
sizes: params.sizes,
}
: {}),
...(params.supportsAudio === undefined ? {} : { supportsAudio: params.supportsAudio }),
};
}
function buildOpenRouterVideoModelCapabilities(
model: OpenRouterVideoModel,
): OpenRouterVideoModelCatalogCapabilities {
const aspectRatios = normalizeStringArray(model.supported_aspect_ratios);
const durations = normalizeNumberArray(model.supported_durations);
const frameImages = normalizeFrameImageRoles(model.supported_frame_images);
const resolutions = normalizeResolutionArray(model.supported_resolutions);
const sizes = normalizeStringArray(model.supported_sizes);
const allowedPassthroughParameters = normalizeStringArray(model.allowed_passthrough_parameters);
const supportsAudio =
typeof model.generate_audio === "boolean" ? model.generate_audio : undefined;
const modeCapabilities = buildOpenRouterVideoModeCapabilities({
durations,
aspectRatios,
resolutions,
sizes,
supportsAudio,
});
const base: VideoGenerationProviderCapabilities = {
providerOptions: {
callback_url: "string",
seed: "number",
},
generate: modeCapabilities,
imageToVideo: {
enabled: frameImages.length > 0,
...modeCapabilities,
...(frameImages.length > 0 ? { maxInputImages: frameImages.length } : {}),
},
videoToVideo: {
enabled: false,
},
};
const capabilities: OpenRouterVideoModelCatalogCapabilities = {
...base,
};
const canonicalSlug = normalizeOptionalString(model.canonical_slug);
if (canonicalSlug) {
capabilities.canonicalSlug = canonicalSlug;
}
const description = normalizeOptionalString(model.description);
if (description) {
capabilities.description = description;
}
if (typeof model.created === "number" && Number.isFinite(model.created)) {
capabilities.created = model.created;
}
const pricingSkus = normalizeStringRecord(model.pricing_skus);
if (pricingSkus) {
capabilities.pricingSkus = pricingSkus;
}
if (allowedPassthroughParameters.length > 0) {
capabilities.allowedPassthroughParameters = allowedPassthroughParameters;
}
return capabilities;
}
function projectOpenRouterVideoModelsToCatalogEntries(
payload: OpenRouterVideoModelsResponse,
): Array<UnifiedModelCatalogEntry<OpenRouterVideoModelCatalogCapabilities>> {
const entries: Array<UnifiedModelCatalogEntry<OpenRouterVideoModelCatalogCapabilities>> = [];
const seen = new Set<string>();
for (const model of payload.data ?? []) {
const id = normalizeOptionalString(model.id);
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
const entry: UnifiedModelCatalogEntry<OpenRouterVideoModelCatalogCapabilities> = {
kind: "video_generation",
provider: "openrouter",
model: id,
source: "live",
capabilities: buildOpenRouterVideoModelCapabilities(model),
};
const name = normalizeOptionalString(model.name);
if (name) {
entry.label = name;
}
entries.push(entry);
}
return entries;
}
async function fetchOpenRouterVideoModels(params: {
baseUrl: string;
apiKey: string;
timeoutMs: number;
allowPrivateNetwork: boolean;
dispatcherPolicy: OpenRouterVideoDispatcherPolicy;
}): Promise<OpenRouterVideoModelsResponse> {
return await getCachedLiveCatalogValue({
keyParts: ["openrouter", "video-models", params.baseUrl, params.apiKey],
load: async () => {
const headers = new Headers({
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
});
const { response, release } = await fetchOpenRouterVideoGet({
url: "videos/models",
baseUrl: params.baseUrl,
headers,
timeoutMs: params.timeoutMs,
allowPrivateNetwork: params.allowPrivateNetwork,
dispatcherPolicy: params.dispatcherPolicy,
auditContext: "openrouter-video-models",
});
try {
await assertOkOrThrowHttpError(response, "OpenRouter video models request failed");
return await readProviderJsonResponse<OpenRouterVideoModelsResponse>(
response,
"OpenRouter video models request failed",
);
} finally {
await release();
}
},
});
}
export async function listOpenRouterVideoModelCatalog(
ctx: UnifiedModelCatalogProviderContext,
): Promise<Array<UnifiedModelCatalogEntry<OpenRouterVideoModelCatalogCapabilities>> | null> {
const { discoveryApiKey: apiKey } = ctx.resolveProviderApiKey("openrouter");
if (!apiKey) {
return null;
}
const { baseUrl, allowPrivateNetwork, dispatcherPolicy } = resolveProviderHttpRequestConfig({
provider: "openrouter",
capability: "video",
baseUrl: ctx.config.models?.providers?.openrouter?.baseUrl,
defaultBaseUrl: OPENROUTER_BASE_URL,
});
const payload = await fetchOpenRouterVideoModels({
baseUrl,
apiKey,
timeoutMs: ctx.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS,
allowPrivateNetwork,
dispatcherPolicy,
});
return projectOpenRouterVideoModelsToCatalogEntries(payload);
}
export async function resolveOpenRouterVideoModelCapabilities(
ctx: VideoGenerationModelCapabilitiesContext,
): Promise<VideoGenerationProviderCapabilities | undefined> {
const auth = await resolveApiKeyForProvider({
provider: "openrouter",
cfg: ctx.cfg,
agentDir: ctx.agentDir,
store: ctx.authStore,
});
if (!auth.apiKey) {
return undefined;
}
const { baseUrl, allowPrivateNetwork, dispatcherPolicy } = resolveProviderHttpRequestConfig({
provider: "openrouter",
capability: "video",
baseUrl: ctx.cfg?.models?.providers?.openrouter?.baseUrl,
defaultBaseUrl: OPENROUTER_BASE_URL,
});
const payload = await fetchOpenRouterVideoModels({
baseUrl,
apiKey: auth.apiKey,
timeoutMs: ctx.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS,
allowPrivateNetwork,
dispatcherPolicy,
});
return projectOpenRouterVideoModelsToCatalogEntries(payload).find(
(entry) => entry.model === ctx.model,
)?.capabilities;
}