Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
13
extensions/openrouter/api.ts
Normal file
13
extensions/openrouter/api.ts
Normal 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";
|
||||
364
extensions/openrouter/image-generation-provider.test.ts
Normal file
364
extensions/openrouter/image-generation-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
362
extensions/openrouter/image-generation-provider.ts
Normal file
362
extensions/openrouter/image-generation-provider.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
1420
extensions/openrouter/index.test.ts
Normal file
1420
extensions/openrouter/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
373
extensions/openrouter/index.ts
Normal file
373
extensions/openrouter/index.ts
Normal 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());
|
||||
},
|
||||
});
|
||||
291
extensions/openrouter/media-understanding-provider.test.ts
Normal file
291
extensions/openrouter/media-understanding-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
181
extensions/openrouter/media-understanding-provider.ts
Normal file
181
extensions/openrouter/media-understanding-provider.ts
Normal 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,
|
||||
};
|
||||
70
extensions/openrouter/models.test.ts
Normal file
70
extensions/openrouter/models.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
66
extensions/openrouter/models.ts
Normal file
66
extensions/openrouter/models.ts
Normal 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";
|
||||
}
|
||||
355
extensions/openrouter/music-generation-provider.test.ts
Normal file
355
extensions/openrouter/music-generation-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
338
extensions/openrouter/music-generation-provider.ts
Normal file
338
extensions/openrouter/music-generation-provider.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
315
extensions/openrouter/oauth.test.ts
Normal file
315
extensions/openrouter/oauth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
429
extensions/openrouter/oauth.ts
Normal file
429
extensions/openrouter/oauth.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
28
extensions/openrouter/onboard.test.ts
Normal file
28
extensions/openrouter/onboard.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
33
extensions/openrouter/onboard.ts
Normal file
33
extensions/openrouter/onboard.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
99
extensions/openrouter/openclaw.plugin.json
Normal file
99
extensions/openrouter/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
218
extensions/openrouter/openrouter.live.test.ts
Normal file
218
extensions/openrouter/openrouter.live.test.ts
Normal 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);
|
||||
});
|
||||
15
extensions/openrouter/package.json
Normal file
15
extensions/openrouter/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
89
extensions/openrouter/provider-catalog.ts
Normal file
89
extensions/openrouter/provider-catalog.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
46
extensions/openrouter/provider-contract-api.ts
Normal file
46
extensions/openrouter/provider-contract-api.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
6
extensions/openrouter/provider-policy-api.ts
Normal file
6
extensions/openrouter/provider-policy-api.ts
Normal 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);
|
||||
}
|
||||
88
extensions/openrouter/provider-routing.ts
Normal file
88
extensions/openrouter/provider-routing.ts
Normal 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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
4
extensions/openrouter/provider-runtime.contract.test.ts
Normal file
4
extensions/openrouter/provider-runtime.contract.test.ts
Normal 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"));
|
||||
219
extensions/openrouter/speech-provider.test.ts
Normal file
219
extensions/openrouter/speech-provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
extensions/openrouter/speech-provider.ts
Normal file
47
extensions/openrouter/speech-provider.ts
Normal 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" }],
|
||||
});
|
||||
}
|
||||
484
extensions/openrouter/stream.ts
Normal file
484
extensions/openrouter/stream.ts
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
5
extensions/openrouter/test-api.ts
Normal file
5
extensions/openrouter/test-api.ts
Normal 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";
|
||||
35
extensions/openrouter/thinking-policy.ts
Normal file
35
extensions/openrouter/thinking-policy.ts
Normal 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;
|
||||
}
|
||||
16
extensions/openrouter/tsconfig.json
Normal file
16
extensions/openrouter/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
841
extensions/openrouter/video-generation-provider.test.ts
Normal file
841
extensions/openrouter/video-generation-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
590
extensions/openrouter/video-generation-provider.ts
Normal file
590
extensions/openrouter/video-generation-provider.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
49
extensions/openrouter/video-http.ts
Normal file
49
extensions/openrouter/video-http.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
300
extensions/openrouter/video-model-catalog.ts
Normal file
300
extensions/openrouter/video-model-catalog.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user