diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index 1299fa0b2112..3adb786d2942 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -115,11 +115,14 @@ under `agents.defaults.mediaModels.image`: } ``` -OpenClaw sends image requests to OpenRouter's chat-completions image API with -`modalities: ["image", "text"]`. Gemini image models additionally receive -`aspectRatio` and `resolution` hints through OpenRouter's `image_config`; other -image models do not. Use `agents.defaults.mediaModels.image.timeoutMs` for -slower models; the `image_generate` tool's per-call `timeoutMs` still wins. +OpenClaw sends image requests to OpenRouter's dedicated image API +(`POST /api/v1/images`) with `model`, `prompt`, and `n`. Gemini image models +additionally receive `aspect_ratio` and `resolution` hints; other image models +do not. Image edits pass source images as `input_references`. Generated images +come back as base64 (`b64_json`) with an optional `media_type`; when +`media_type` is absent, OpenClaw sniffs the image format from the bytes. Use +`agents.defaults.mediaModels.image.timeoutMs` for slower models; the +`image_generate` tool's per-call `timeoutMs` still wins. ## Video generation diff --git a/extensions/openrouter/image-generation-provider.test.ts b/extensions/openrouter/image-generation-provider.test.ts index 25b1a1aebabf..4165ae1cd746 100644 --- a/extensions/openrouter/image-generation-provider.test.ts +++ b/extensions/openrouter/image-generation-provider.test.ts @@ -11,105 +11,77 @@ const { postJsonRequestMock, resolveApiKeyForProviderMock, resolveProviderHttpRe installProviderHttpMockCleanup(); -function requireOpenRouterPostBody(): { - messages?: Array<{ content?: unknown }>; +function requirePostJsonRequest(): { + url: string; + timeoutMs?: number; + body: Record; + headers: Headers; + allowPrivateNetwork?: boolean; + dispatcherPolicy?: unknown; } { - const request = requireOpenRouterPostRequest(); - return request.body as { messages?: Array<{ content?: unknown }> }; -} - -function requireOpenRouterPostRequest(): Record { - const [call] = postJsonRequestMock.mock.calls; - if (!call) { - throw new Error("expected OpenRouter image generation request"); - } - const [request] = call; + const request = postJsonRequestMock.mock.calls[0]?.[0]; if (!request || typeof request !== "object" || Array.isArray(request)) { throw new Error("expected OpenRouter image generation request"); } - return request as Record; + return request as { + url: string; + timeoutMs?: number; + body: Record; + headers: Headers; + allowPrivateNetwork?: boolean; + dispatcherPolicy?: unknown; + }; } -function requireOpenRouterConfigRequest(): Record { - 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"); +function requireConfigRequest() { + const request = resolveProviderHttpRequestConfigMock.mock.calls[0]?.[0]; + if (!request) { + throw new Error("expected OpenRouter image request config"); } 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["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", () => { beforeEach(() => { resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "openrouter-key" }); }); - it("builds provider metadata and capabilities", () => { + it("declares dedicated image generation and edit 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.models).toEqual([ + "google/gemini-3.1-flash-image-preview", + "google/gemini-3-pro-image-preview", + "openai/gpt-5.4-image-2", + ]); + expect(provider.defaultTimeoutMs).toBe(180_000); 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); + expect(provider.capabilities.edit).toMatchObject({ + enabled: true, + maxCount: 4, + maxInputImages: 5, + }); }); - it("sends chat completion image requests with Gemini image config and count", async () => { + it("sends Gemini generation requests to the dedicated endpoint", 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")}`, - }, - }, - ], - }, - }, - ], - }), - }, + response: Response.json({ + data: [{ b64_json: Buffer.from("png-one").toString("base64") }], + }), release, }); - const provider = buildOpenRouterImageGenerationProvider(); - const result = await provider.generateImage({ + const result = await buildOpenRouterImageGenerationProvider().generateImage({ provider: "openrouter", model: "google/gemini-3.1-flash-image-preview", prompt: "draw a sticker", aspectRatio: "16:9", resolution: "2K", - count: 2, + count: 1, timeoutMs: 12_345, ssrfPolicy: { allowRfc2544BenchmarkRange: true }, cfg: { @@ -124,76 +96,47 @@ describe("openrouter image generation provider", () => { }, }); - 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({ + expect(requireConfigRequest()).toEqual({ baseUrl: "https://custom.openrouter.test/api/v1", defaultBaseUrl: "https://openrouter.ai/api/v1", allowPrivateNetwork: false, + request: undefined, defaultHeaders: { Authorization: "Bearer openrouter-key", "HTTP-Referer": "https://openclaw.ai", "X-OpenRouter-Title": "OpenClaw", }, - request: undefined, provider: "openrouter", capability: "image", transport: "http", }); - expect(postJsonRequestMock).toHaveBeenCalledOnce(); - const request = requireOpenRouterPostRequest(); - const headers = requireHeaders(request.headers); - expect(Object.fromEntries(headers.entries())).toEqual({ + const request = requirePostJsonRequest(); + expect(request.url).toBe("https://custom.openrouter.test/api/v1/images"); + expect(Object.fromEntries(request.headers.entries())).toEqual({ authorization: "Bearer openrouter-key", + "content-type": "application/json", "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, + expect(request.body).toEqual({ + model: "google/gemini-3.1-flash-image-preview", + prompt: "draw a sticker", + n: 1, + aspect_ratio: "16:9", + resolution: "2K", + }); + expect(request).toMatchObject({ + timeoutMs: 180_000, 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(result.images[0]?.buffer.toString()).toBe("png-one"); + expect(result.model).toBe("google/gemini-3.1-flash-image-preview"); expect(release).toHaveBeenCalledOnce(); }); - it("applies configured image request transport without weakening private-network policy", async () => { + it("preserves configured request transport without weakening private-network policy", async () => { const requestPolicy = { allowPrivateNetwork: true, headers: { "X-OpenRouter-Trace": "image-trace" }, @@ -215,30 +158,22 @@ describe("openrouter image generation provider", () => { } return { baseUrl: params.baseUrl ?? params.defaultBaseUrl, - allowPrivateNetwork: - (params.allowPrivateNetwork ?? params.request?.allowPrivateNetwork) === true, + allowPrivateNetwork: params.allowPrivateNetwork === true, headers, dispatcherPolicy, }; }); - const release = vi.fn(async () => {}); postJsonRequestMock.mockResolvedValue({ response: Response.json({ - choices: [ - { - message: { - images: [{ image_url: { url: "data:image/png;base64,cG5n" } }], - }, - }, - ], + data: [{ b64_json: Buffer.from("png").toString("base64") }], }), - release, + release: vi.fn(async () => {}), }); const result = await buildOpenRouterImageGenerationProvider().generateImage({ provider: "openrouter", model: "google/gemini-3.1-flash-image-preview", - prompt: "draw through the configured transport", + prompt: "draw through configured transport", cfg: { models: { providers: { @@ -252,88 +187,59 @@ describe("openrouter image generation provider", () => { }, }); - expect(requireOpenRouterConfigRequest()).toMatchObject({ + expect(requireConfigRequest()).toMatchObject({ baseUrl: "https://custom.openrouter.test/api/v1", - provider: "openrouter", - capability: "image", allowPrivateNetwork: false, request: requestPolicy, }); - const request = requireOpenRouterPostRequest(); - const headers = requireHeaders(request.headers); - expect(headers.get("authorization")).toBe("Bearer override-image-token"); - expect(headers.get("x-openrouter-trace")).toBe("image-trace"); + const request = requirePostJsonRequest(); + expect(request.headers.get("authorization")).toBe("Bearer override-image-token"); + expect(request.headers.get("http-referer")).toBe("https://openclaw.ai"); + expect(request.headers.get("x-openrouter-title")).toBe("OpenClaw"); + expect(request.headers.get("x-openrouter-trace")).toBe("image-trace"); expect(request).toMatchObject({ + url: "https://custom.openrouter.test/api/v1/images", allowPrivateNetwork: false, dispatcherPolicy, }); - expect(requireGeneratedImage(result, 0).buffer.toString()).toBe("png"); - expect(release).toHaveBeenCalledOnce(); + expect(result.images[0]?.buffer.toString()).toBe("png"); }); - it("uses a 180s default timeout when no request timeout is provided", async () => { - const release = vi.fn(async () => {}); + it("uses the default endpoint and operation timeout", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - choices: [ - { - message: { - images: [ - { - imageUrl: { - url: `data:image/png;base64,${Buffer.from("png-one").toString("base64")}`, - }, - }, - ], - }, - }, - ], - }), - }, - release, + response: Response.json({ + data: [{ b64_json: Buffer.from("png-one").toString("base64") }], + }), + release: vi.fn(async () => {}), }); - const provider = buildOpenRouterImageGenerationProvider(); - await provider.generateImage({ + await buildOpenRouterImageGenerationProvider().generateImage({ provider: "openrouter", model: "google/gemini-3.1-flash-image-preview", prompt: "draw a sticker", cfg: {}, }); - expect(postJsonRequestMock).toHaveBeenCalledWith( - expect.objectContaining({ - timeoutMs: 180_000, - }), - ); + expect(requirePostJsonRequest()).toMatchObject({ + url: "https://openrouter.ai/api/v1/images", + timeoutMs: 180_000, + }); }); - it("sends reference images as data URLs for edit-style requests", async () => { + it("passes reference images as input_references and reads media_type", 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")}`, - }, - }, - ], - }, - }, - ], - }), - }, + response: Response.json({ + data: [ + { + b64_json: Buffer.from("webp-one").toString("base64"), + media_type: "image/webp", + }, + ], + }), release: vi.fn(async () => {}), }); - const provider = buildOpenRouterImageGenerationProvider(); - const result = await provider.generateImage({ + const result = await buildOpenRouterImageGenerationProvider().generateImage({ provider: "openrouter", model: "google/gemini-3.1-flash-image-preview", prompt: "turn this into watercolor", @@ -341,9 +247,7 @@ describe("openrouter image generation provider", () => { cfg: {}, }); - const body = requireOpenRouterPostBody(); - expect(body.messages?.[0]?.content).toEqual([ - { type: "text", text: "turn this into watercolor" }, + expect(requirePostJsonRequest().body.input_references).toEqual([ { type: "image_url", image_url: { @@ -351,89 +255,50 @@ describe("openrouter image generation provider", () => { }, }, ]); - const image = requireGeneratedImage(result, 0); - expect(image.buffer.toString()).toBe("webp-one"); - expect(image.mimeType).toBe("image/webp"); + expect(result.images[0]?.buffer.toString()).toBe("webp-one"); + expect(result.images[0]?.mimeType).toBe("image/webp"); }); - it("wraps wrong-shape successful OpenRouter image responses", async () => { + it("sniffs the MIME type when media_type is absent", async () => { + const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]); 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: {}, + response: Response.json({ + data: [{ b64_json: jpegBytes.toString("base64") }], }), - ).rejects.toThrow("OpenRouter image generation response malformed"); - }); - - it("extracts image fallbacks from string content and raw b64 parts", async () => { - const png = Buffer.from("png-inline").toString("base64"); - const raw = Buffer.from("raw-inline").toString("base64"); - postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - choices: [ - { - message: { - content: `done data:image/png;base64,${png}`, - }, - }, - { - message: { - content: [{ b64_json: raw }], - }, - }, - ], - }), - }, release: vi.fn(async () => {}), }); const result = await buildOpenRouterImageGenerationProvider().generateImage({ provider: "openrouter", model: "google/gemini-3.1-flash-image-preview", - prompt: "draw image fallbacks", + prompt: "photo", cfg: {}, }); - expect(result.images.map((image) => image.buffer.toString())).toEqual([ - "png-inline", - "raw-inline", - ]); + expect(result.images[0]?.mimeType).toBe("image/jpeg"); }); - it("rejects invalid raw image parts in strict extraction mode", async () => { + it("omits Gemini-only geometry fields for other models", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - choices: [ - { - message: { - content: [{ b64_json: "not-base64!" }], - }, - }, - ], - }), - }, + response: Response.json({ + data: [{ b64_json: Buffer.from("img").toString("base64") }], + }), release: vi.fn(async () => {}), }); - await expect( - buildOpenRouterImageGenerationProvider().generateImage({ - provider: "openrouter", - model: "google/gemini-3.1-flash-image-preview", - prompt: "draw invalid fallback", - cfg: {}, - }), - ).rejects.toThrow("OpenRouter image generation response malformed"); + await buildOpenRouterImageGenerationProvider().generateImage({ + provider: "openrouter", + model: "openai/gpt-5.4-image-2", + prompt: "draw something", + aspectRatio: "16:9", + resolution: "2K", + cfg: {}, + }); + + expect(requirePostJsonRequest().body).toEqual({ + model: "openai/gpt-5.4-image-2", + prompt: "draw something", + n: 1, + }); }); }); diff --git a/extensions/openrouter/image-generation-provider.ts b/extensions/openrouter/image-generation-provider.ts index f0290dcdca9f..d7c4aec901c3 100644 --- a/extensions/openrouter/image-generation-provider.ts +++ b/extensions/openrouter/image-generation-provider.ts @@ -1,32 +1,20 @@ // Openrouter provider module implements model/runtime integration. import type { - GeneratedImageAsset, ImageGenerationProvider, ImageGenerationRequest, + ImageGenerationSourceImage, } from "openclaw/plugin-sdk/image-generation"; import { - generatedImageAssetFromBase64, - generatedImageAssetFromDataUrl, - resolveInlineImageJsonResponseMaxBytes, + createOpenAiCompatibleImageGenerationProvider, toImageDataUrl, } from "openclaw/plugin-sdk/image-generation"; -import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generation-runtime"; -import { resolveIntegerOption } 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, - postJsonRequest, - readProviderJsonResponse, - resolveProviderHttpRequestConfig, - sanitizeConfiguredModelProviderRequest, -} from "openclaw/plugin-sdk/provider-http"; -import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { 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 MAX_INPUT_IMAGES = 5; const SUPPORTED_MODELS = [ DEFAULT_MODEL, "google/gemini-3-pro-image-preview", @@ -44,182 +32,56 @@ const SUPPORTED_ASPECT_RATIOS = [ "16:9", "21:9", ] as const; -const OPENROUTER_IMAGE_MALFORMED_RESPONSE = "OpenRouter image generation response malformed"; - -function throwMalformedOpenRouterImageResponse(): never { - throw new Error(OPENROUTER_IMAGE_MALFORMED_RESPONSE); -} - -function requireOpenRouterImageRecord(value: unknown): Record { - if (!isRecord(value)) { - throwMalformedOpenRouterImageResponse(); - } - return value; -} - -function requireOpenRouterImageUrl(value: unknown): string { - const url = normalizeOptionalString(requireOpenRouterImageRecord(value).url); - if (!url) { - throwMalformedOpenRouterImageResponse(); - } - return url; -} - -function pushDataUrlImage(images: GeneratedImageAsset[], dataUrl: string, strict = true): void { - const image = generatedImageAssetFromDataUrl({ dataUrl, index: images.length }); - if (!image) { - if (strict) { - throwMalformedOpenRouterImageResponse(); - } - return; - } - images.push(image); -} - -function extractImagesFromPart(images: GeneratedImageAsset[], value: unknown): void { - const part = requireOpenRouterImageRecord(value); - if (part.type === "text") { - return; - } - if (part.type === "image_url") { - pushDataUrlImage(images, requireOpenRouterImageUrl(part.image_url ?? part.imageUrl)); - return; - } - - const rawBase64 = normalizeOptionalString(part.b64_json); - if (rawBase64) { - const image = generatedImageAssetFromBase64({ base64: rawBase64, index: images.length }); - if (image) { - images.push(image); - return; - } - throwMalformedOpenRouterImageResponse(); - } - if ("b64_json" in part) { - throwMalformedOpenRouterImageResponse(); - } - - const inlineData = part.inlineData ?? part.inline_data; - if (inlineData === undefined || inlineData === null) { - return; - } - const inline = requireOpenRouterImageRecord(inlineData); - const data = normalizeOptionalString(inline.data); - if (!data) { - throwMalformedOpenRouterImageResponse(); - } - const mimeType = - normalizeOptionalString(inline.mimeType) ?? - normalizeOptionalString(inline.mime_type) ?? - "image/png"; - const image = generatedImageAssetFromBase64({ - base64: data, - index: images.length, - mimeType, - }); - if (image) { - images.push(image); - return; - } - throwMalformedOpenRouterImageResponse(); -} - -function extractOpenRouterImagesFromResponse(body: unknown): GeneratedImageAsset[] { - const payload = requireOpenRouterImageRecord(body); - const choices = payload.choices; - if (choices === undefined || choices === null) { - return []; - } - if (!Array.isArray(choices)) { - throwMalformedOpenRouterImageResponse(); - } - - const images: GeneratedImageAsset[] = []; - for (const choiceValue of choices) { - const choice = requireOpenRouterImageRecord(choiceValue); - const messageValue = choice.message; - if (messageValue === undefined || messageValue === null) { - continue; - } - const message = requireOpenRouterImageRecord(messageValue); - - const messageImages = message.images; - if (messageImages !== undefined && messageImages !== null) { - if (!Array.isArray(messageImages)) { - throwMalformedOpenRouterImageResponse(); - } - for (const entryValue of messageImages) { - const entry = requireOpenRouterImageRecord(entryValue); - pushDataUrlImage(images, requireOpenRouterImageUrl(entry.image_url ?? entry.imageUrl)); - } - } - - 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], false); - } - } else if (Array.isArray(content)) { - for (const part of content) { - extractImagesFromPart(images, part); - } - } else if (content !== undefined && content !== null) { - throwMalformedOpenRouterImageResponse(); - } - } - return images; -} - -function resolveImageCount(count: number | undefined): number { - return resolveIntegerOption(count, 1, { min: 1, max: MAX_IMAGE_RESULTS }); -} 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 buildInputReferences( + inputImages: ImageGenerationSourceImage[], +): Array<{ type: "image_url"; image_url: { url: string } }> { + return inputImages.map((image) => ({ + type: "image_url" as const, + image_url: { url: toImageDataUrl(image) }, + })); } -function buildImageConfig(req: ImageGenerationRequest, model: string): Record { - if (!isGeminiImageModel(model)) { - return {}; +function buildRequestBody(params: { + req: ImageGenerationRequest; + inputImages: ImageGenerationSourceImage[]; + model: string; + count: number; +}): Record { + const body: Record = { + model: params.model, + prompt: params.req.prompt, + n: Math.min(params.count, MAX_IMAGE_RESULTS), + }; + + if (isGeminiImageModel(params.model)) { + const aspectRatio = normalizeOptionalString(params.req.aspectRatio); + if (aspectRatio) { + body.aspect_ratio = aspectRatio; + } + const resolution = normalizeOptionalString(params.req.resolution); + if (resolution) { + body.resolution = resolution; + } } - const imageConfig: Record = {}; - const aspectRatio = normalizeOptionalString(req.aspectRatio); - if (aspectRatio) { - imageConfig.aspect_ratio = aspectRatio; + + if (params.inputImages.length > 0) { + body.input_references = buildInputReferences(params.inputImages); } - const resolution = normalizeOptionalString(req.resolution); - if (resolution) { - imageConfig.image_size = resolution; - } - return imageConfig; + + return body; } export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvider { - return { + return createOpenAiCompatibleImageGenerationProvider({ id: "openrouter", label: "OpenRouter", defaultModel: DEFAULT_MODEL, models: [...SUPPORTED_MODELS], - isConfigured: (ctx) => isProviderApiKeyConfigured({ provider: "openrouter", ...ctx }), capabilities: { generate: { maxCount: MAX_IMAGE_RESULTS, @@ -230,7 +92,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide edit: { enabled: true, maxCount: MAX_IMAGE_RESULTS, - maxInputImages: 5, + maxInputImages: MAX_INPUT_IMAGES, supportsSize: false, supportsAspectRatio: true, supportsResolution: true, @@ -240,71 +102,34 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide 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", - }, - request: sanitizeConfiguredModelProviderRequest( - req.cfg?.models?.providers?.openrouter?.request, - ), - 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, - resolveGeneratedMediaMaxBytes(req.cfg, "image"), - ), - }); - const images = extractOpenRouterImagesFromResponse(payload); - if (images.length === 0) { - throw new Error("OpenRouter image generation response missing image data"); - } - return { images, model }; - } finally { - await release(); - } + defaultBaseUrl: OPENROUTER_BASE_URL, + resolveBaseUrl: ({ req }) => + normalizeOptionalString(req.cfg?.models?.providers?.openrouter?.baseUrl) ?? + OPENROUTER_BASE_URL, + resolveAllowPrivateNetwork: () => false, + useConfiguredRequest: true, + defaultHeaders: { + "HTTP-Referer": "https://openclaw.ai", + "X-OpenRouter-Title": "OpenClaw", }, - }; + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + endpointPath: "images", + buildGenerateRequest: ({ req, inputImages, model, count }) => ({ + kind: "json", + body: buildRequestBody({ req, inputImages, model, count }), + }), + buildEditRequest: ({ req, inputImages, model, count }) => ({ + kind: "json", + body: buildRequestBody({ req, inputImages, model, count }), + }), + // OpenRouter reports media_type only for non-PNG images, but live testing + // showed some upstream models return non-PNG bytes without it; fall back + // to magic-byte sniffing (explicit media_type still wins when present). + response: { sniffMimeType: true }, + missingApiKeyError: "OpenRouter API key missing", + failureLabels: { + generate: "OpenRouter image generation failed", + edit: "OpenRouter image edit failed", + }, + }); } diff --git a/src/image-generation/image-assets.test.ts b/src/image-generation/image-assets.test.ts index d165a432d1c9..0c81bada7b14 100644 --- a/src/image-generation/image-assets.test.ts +++ b/src/image-generation/image-assets.test.ts @@ -95,6 +95,25 @@ describe("image asset helpers", () => { ]); }); + it("reads media_type as a MIME type fallback for OpenRouter-style responses", () => { + const images = parseOpenAiCompatibleImageResponse({ + data: [ + { + b64_json: Buffer.from("webp-bytes").toString("base64"), + media_type: "image/webp", + }, + ], + }); + + expect(images).toEqual([ + { + buffer: Buffer.from("webp-bytes"), + mimeType: "image/webp", + fileName: "image-1.webp", + }, + ]); + }); + it("skips malformed OpenAI-compatible base64 image responses", () => { expect( parseOpenAiCompatibleImageResponse( diff --git a/src/image-generation/image-assets.ts b/src/image-generation/image-assets.ts index bf86a86225bb..545ea88a9f02 100644 --- a/src/image-generation/image-assets.ts +++ b/src/image-generation/image-assets.ts @@ -23,6 +23,8 @@ export type ImageMimeTypeDetection = { export type OpenAiCompatibleImageResponseEntry = { b64_json?: unknown; mime_type?: unknown; + // OpenRouter's /api/v1/images responses report the MIME type as media_type. + media_type?: unknown; revised_prompt?: unknown; }; @@ -198,7 +200,7 @@ export function generatedImageAssetFromOpenAiCompatibleEntry( return generatedImageAssetFromBase64({ base64: normalizeOptionalString(entry.b64_json), index, - mimeType: normalizeOptionalString(entry.mime_type), + mimeType: normalizeOptionalString(entry.mime_type) ?? normalizeOptionalString(entry.media_type), revisedPrompt: normalizeOptionalString(entry.revised_prompt), defaultMimeType: options.defaultMimeType, fileNamePrefix: options.fileNamePrefix, diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index 3bcdfe79fd21..106b08d3d2d7 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -356,6 +356,81 @@ describe("OpenAI-compatible image provider helper", () => { expect(headers.has("Content-Type")).toBe(false); }); + it("overrides the endpoint with a flat endpointPath string", async () => { + mockGeneratedResponse(); + const provider = createProvider({ endpointPath: "images" }); + + await provider.generateImage({ + provider: "sample", + model: "sample-image", + prompt: "flat endpoint", + cfg: {} as never, + }); + + const jsonRequest = requireFirstCallArg(postJsonRequestMock) as { url?: string }; + expect(jsonRequest.url).toBe("https://sample.example/v1/images"); + }); + + it("normalizes leading slashes in endpointPath", async () => { + mockGeneratedResponse(); + const provider = createProvider({ endpointPath: "/images" }); + + await provider.generateImage({ + provider: "sample", + model: "sample-image", + prompt: "leading slash", + cfg: {} as never, + }); + + const jsonRequest = requireFirstCallArg(postJsonRequestMock) as { url?: string }; + expect(jsonRequest.url).toBe("https://sample.example/v1/images"); + }); + + it("merges provider default headers with factory authentication", async () => { + mockGeneratedResponse(); + const provider = createProvider({ + defaultHeaders: { + "HTTP-Referer": "https://example.test", + "X-Provider-Title": "Sample", + }, + }); + + await provider.generateImage({ + provider: "sample", + model: "sample-image", + prompt: "header contract", + cfg: {} as never, + }); + + expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + defaultHeaders: { + Authorization: "Bearer provider-key", + "HTTP-Referer": "https://example.test", + "X-Provider-Title": "Sample", + }, + }), + ); + }); + + it("resolves per-mode endpointPath functions for edit requests", async () => { + mockGeneratedResponse(); + const provider = createProvider({ + endpointPath: (mode) => (mode === "edit" ? "images/custom-edits" : "images"), + }); + + await provider.generateImage({ + provider: "sample", + model: "sample-image", + prompt: "edit it", + inputImages: [{ buffer: Buffer.from("source"), mimeType: "image/png" }], + cfg: {} as never, + }); + + const multipartRequest = requireFirstCallArg(postMultipartRequestMock) as { url?: string }; + expect(multipartRequest.url).toBe("https://sample.example/v1/images/custom-edits"); + }); + it("honors default operation timeouts and empty-response errors", async () => { postJsonRequestMock.mockResolvedValue({ response: jsonResponse({ data: [] }), diff --git a/src/image-generation/openai-compatible-image-provider.ts b/src/image-generation/openai-compatible-image-provider.ts index 6a398afa826c..19ae3ea2153e 100644 --- a/src/image-generation/openai-compatible-image-provider.ts +++ b/src/image-generation/openai-compatible-image-provider.ts @@ -65,6 +65,7 @@ export type OpenAiCompatibleImageProviderOptions = { providerConfig?: ModelProviderConfig; }) => boolean | undefined; useConfiguredRequest?: boolean; + defaultHeaders?: Record; defaultTimeoutMs?: number; resolveCount?: (params: { req: ImageGenerationRequest; @@ -89,6 +90,13 @@ export type OpenAiCompatibleImageProviderOptions = { generate?: string; edit?: string; }; + /** + * Overrides the default OpenAI-style `/images/generations` and `/images/edits` + * paths. The path is appended to the resolved base URL; leading and duplicate + * slashes are normalized (e.g. both `"images"` and `"/images"` resolve to + * `/images`). + */ + endpointPath?: string | ((mode: OpenAiCompatibleImageRequestMode) => string); }; function readProviderConfig( @@ -106,10 +114,26 @@ function trimTrailingSlash(value: string): string { return value.replace(/\/+$/u, ""); } +function trimLeadingSlash(value: string): string { + return value.replace(/^\/+/u, ""); +} + function appendImagesPath(baseUrl: string, mode: OpenAiCompatibleImageRequestMode): string { return `${trimTrailingSlash(baseUrl)}/images/${mode === "edit" ? "edits" : "generations"}`; } +function resolveEndpointUrl( + baseUrl: string, + mode: OpenAiCompatibleImageRequestMode, + endpointPath: string | ((mode: OpenAiCompatibleImageRequestMode) => string) | undefined, +): string { + if (endpointPath === undefined) { + return appendImagesPath(baseUrl, mode); + } + const path = typeof endpointPath === "function" ? endpointPath(mode) : endpointPath; + return `${trimTrailingSlash(baseUrl)}/${trimLeadingSlash(path)}`; +} + function resolveRequestTimeoutMs(params: { options: OpenAiCompatibleImageProviderOptions; req: ImageGenerationRequest; @@ -225,6 +249,7 @@ export function createOpenAiCompatibleImageGenerationProvider( : undefined, defaultHeaders: { Authorization: `Bearer ${auth.apiKey}`, + ...options.defaultHeaders, }, provider: options.id, capability: "image", @@ -241,10 +266,11 @@ export function createOpenAiCompatibleImageGenerationProvider( const timeoutMs = resolveRequestTimeoutMs({ options, req, mode }); // Multipart requests must let FormData set its own boundary header, while // JSON requests need an explicit content type after configured headers. + const endpointUrl = resolveEndpointUrl(baseUrl, mode, options.endpointPath); const request = requestBody.kind === "multipart" ? postMultipartRequest({ - url: appendImagesPath(baseUrl, mode), + url: endpointUrl, headers: (() => { const multipartHeaders = new Headers(headers); multipartHeaders.delete("Content-Type"); @@ -258,7 +284,7 @@ export function createOpenAiCompatibleImageGenerationProvider( dispatcherPolicy, }) : postJsonRequest({ - url: appendImagesPath(baseUrl, mode), + url: endpointUrl, headers: (() => { const jsonHeaders = new Headers(headers); jsonHeaders.set("Content-Type", "application/json");