mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 14:21:37 +00:00
* fix(image-gen): bound image generation provider JSON response reads Route success JSON reads through readProviderJsonResponse (16 MiB cap) in openrouter, google, fal, minimax, openai, and vydra image generation providers to prevent OOM from oversized or hostile endpoint responses. Mirrors the response-limit campaign already applied to other provider paths. AI-assisted. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(image-gen): size bounded JSON caps for inline image payloads Signed-off-by: sallyom <somalley@redhat.com> --------- Signed-off-by: sallyom <somalley@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: sallyom <somalley@redhat.com>
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
// 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");
|
|
});
|
|
});
|