fix(openrouter): use dedicated /api/v1/images endpoint for image generation

This commit is contained in:
Robinnnnn
2026-07-06 21:50:20 -07:00
committed by Shakker
parent ceb469abb3
commit 02af2fa053
7 changed files with 317 additions and 502 deletions
+8 -5
View File
@@ -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
@@ -11,105 +11,77 @@ const { postJsonRequestMock, resolveApiKeyForProviderMock, resolveProviderHttpRe
installProviderHttpMockCleanup();
function requireOpenRouterPostBody(): {
messages?: Array<{ content?: unknown }>;
function requirePostJsonRequest(): {
url: string;
timeoutMs?: number;
body: Record<string, unknown>;
headers: Headers;
allowPrivateNetwork?: boolean;
dispatcherPolicy?: 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;
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<string, unknown>;
return request as {
url: string;
timeoutMs?: number;
body: Record<string, unknown>;
headers: Headers;
allowPrivateNetwork?: boolean;
dispatcherPolicy?: 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");
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<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", () => {
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,
});
});
});
@@ -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<string, unknown> {
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<string, string> {
if (!isGeminiImageModel(model)) {
return {};
function buildRequestBody(params: {
req: ImageGenerationRequest;
inputImages: ImageGenerationSourceImage[];
model: string;
count: number;
}): Record<string, unknown> {
const body: Record<string, unknown> = {
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<string, string> = {};
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",
},
});
}
+19
View File
@@ -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(
+3 -1
View File
@@ -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,
@@ -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: [] }),
@@ -65,6 +65,7 @@ export type OpenAiCompatibleImageProviderOptions = {
providerConfig?: ModelProviderConfig;
}) => boolean | undefined;
useConfiguredRequest?: boolean;
defaultHeaders?: Record<string, string>;
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
* `<baseUrl>/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");