From 1af984a64f83c0fdc47efd1d735eef6fa48a460b Mon Sep 17 00:00:00 2001 From: cwandev <18888351756@163.com> Date: Thu, 19 Mar 2026 17:36:03 +0800 Subject: [PATCH 1/4] fix(baoyu-image-gen): align OpenRouter image generation with current API --- .../scripts/providers/openrouter.test.ts | 93 +++++++++++++++++++ .../scripts/providers/openrouter.ts | 82 ++++++++++------ 2 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 skills/baoyu-image-gen/scripts/providers/openrouter.test.ts diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts new file mode 100644 index 0000000..254059f --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + buildContent, + buildRequestBody, + extractImageFromResponse, + getAspectRatio, + getImageSize, +} from "./openrouter.ts"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +test("OpenRouter request body uses image_config and string content for text-only prompts", () => { + const args = makeArgs({ aspectRatio: "16:9", quality: "2k" }); + const body = buildRequestBody("hello", args, []); + + assert.deepEqual(body.image_config, { + image_size: "2K", + aspect_ratio: "16:9", + }); + assert.equal(body.stream, false); + assert.equal(body.messages[0].content, "hello"); +}); + +test("OpenRouter request body keeps multimodal array content when references are provided", () => { + const content = buildContent("hello", ["data:image/png;base64,abc"]); + assert.ok(Array.isArray(content)); + assert.deepEqual(content[0], { type: "text", text: "hello" }); + assert.deepEqual(content[1], { + type: "image_url", + image_url: { url: "data:image/png;base64,abc" }, + }); +}); + +test("OpenRouter size and aspect helpers infer expected defaults", () => { + assert.equal(getImageSize(makeArgs({ quality: "normal" })), "1K"); + assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K"); + assert.equal(getAspectRatio(makeArgs({ size: "2048x1024" })), "2:1"); +}); + +test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => { + const bytes = await extractImageFromResponse({ + choices: [ + { + message: { + images: [ + { + image_url: { + url: `data:image/png;base64,${Buffer.from("hello").toString("base64")}`, + }, + }, + ], + }, + }, + ], + }); + assert.equal(Buffer.from(bytes).toString("utf8"), "hello"); + + await assert.rejects( + () => + extractImageFromResponse({ + choices: [ + { + finish_reason: "error", + native_finish_reason: "MALFORMED_FUNCTION_CALL", + message: { content: null }, + }, + ], + }), + /finish_reason=MALFORMED_FUNCTION_CALL/, + ); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.ts index 4e55e06..fca7602 100644 --- a/skills/baoyu-image-gen/scripts/providers/openrouter.ts +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.ts @@ -18,9 +18,11 @@ type OpenRouterMessagePart = { type OpenRouterResponse = { choices?: Array<{ + finish_reason?: string | null; + native_finish_reason?: string | null; message?: { images?: OpenRouterImageEntry[]; - content?: string | OpenRouterMessagePart[]; + content?: string | OpenRouterMessagePart[] | null; }; }>; }; @@ -103,7 +105,7 @@ function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null { return "4K"; } -function getImageSize(args: CliArgs): "1K" | "2K" | "4K" { +export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" { if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K"; const inferredFromSize = inferImageSize(args.size); @@ -112,7 +114,7 @@ function getImageSize(args: CliArgs): "1K" | "2K" | "4K" { return args.quality === "normal" ? "1K" : "2K"; } -function getAspectRatio(args: CliArgs): string | null { +export function getAspectRatio(args: CliArgs): string | null { return args.aspectRatio || inferAspectRatio(args.size); } @@ -129,7 +131,14 @@ async function readImageAsDataUrl(filePath: string): Promise { return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`; } -function buildContent(prompt: string, referenceImages: string[]): Array> { +export function buildContent( + prompt: string, + referenceImages: string[], +): string | Array> { + if (referenceImages.length === 0) { + return prompt; + } + const content: Array> = [{ type: "text", text: prompt }]; for (const imageUrl of referenceImages) { @@ -171,8 +180,9 @@ async function downloadImage(value: string): Promise { return Uint8Array.from(Buffer.from(value, "base64")); } -async function extractImageFromResponse(result: OpenRouterResponse): Promise { - const message = result.choices?.[0]?.message; +export async function extractImageFromResponse(result: OpenRouterResponse): Promise { + const choice = result.choices?.[0]; + const message = choice?.message; for (const image of message?.images ?? []) { const imageUrl = extractImageUrl(image); @@ -194,7 +204,38 @@ async function extractImageFromResponse(result: OpenRouterResponse): Promise { + const imageConfig: Record = { + image_size: getImageSize(args), + }; + + const aspectRatio = getAspectRatio(args); + if (aspectRatio) { + imageConfig.aspect_ratio = aspectRatio; + } + + return { + messages: [ + { + role: "user", + content: buildContent(prompt, referenceImages), + }, + ], + modalities: ["image", "text"], + image_config: imageConfig, + stream: false, + }; } export async function generateImage( @@ -212,32 +253,15 @@ export async function generateImage( referenceImages.push(await readImageAsDataUrl(refPath)); } - const imageGenerationOptions: Record = { - size: getImageSize(args), - }; - - const aspectRatio = getAspectRatio(args); - if (aspectRatio) { - imageGenerationOptions.aspect_ratio = aspectRatio; - } - const body = { model, - messages: [ - { - role: "user", - content: buildContent(prompt, referenceImages), - }, - ], - modalities: ["image", "text"], - max_tokens: 256, - imageGenerationOptions, - providerPreferences: { - require_parameters: true, - }, + ...buildRequestBody(prompt, args, referenceImages), }; - console.log(`Generating image with OpenRouter (${model})...`, imageGenerationOptions); + console.log( + `Generating image with OpenRouter (${model})...`, + (body.image_config as Record), + ); const response = await fetch(`${getBaseUrl()}/chat/completions`, { method: "POST", From efb7a1917a97f0f787ff406d336e739d5af97e50 Mon Sep 17 00:00:00 2001 From: cwandev <18888351756@163.com> Date: Thu, 19 Mar 2026 21:26:19 +0800 Subject: [PATCH 2/4] fix(baoyu-image-gen): require OpenRouter image parameters --- skills/baoyu-image-gen/scripts/providers/openrouter.test.ts | 3 +++ skills/baoyu-image-gen/scripts/providers/openrouter.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts index 254059f..f303657 100644 --- a/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts @@ -39,6 +39,9 @@ test("OpenRouter request body uses image_config and string content for text-only image_size: "2K", aspect_ratio: "16:9", }); + assert.deepEqual(body.provider, { + require_parameters: true, + }); assert.equal(body.stream, false); assert.equal(body.messages[0].content, "hello"); }); diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.ts index fca7602..c8c3806 100644 --- a/skills/baoyu-image-gen/scripts/providers/openrouter.ts +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.ts @@ -234,6 +234,9 @@ export function buildRequestBody( ], modalities: ["image", "text"], image_config: imageConfig, + provider: { + require_parameters: true, + }, stream: false, }; } From 96ef6e225104aba0596878606a0e290ddf985589 Mon Sep 17 00:00:00 2001 From: cwandev <18888351756@163.com> Date: Thu, 19 Mar 2026 21:59:41 +0800 Subject: [PATCH 3/4] fix(baoyu-image-gen): harden OpenRouter image support --- .../scripts/providers/openrouter.test.ts | 33 ++++++- .../scripts/providers/openrouter.ts | 97 ++++++++++++++++--- 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts index f303657..f0e2dfc 100644 --- a/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts @@ -8,8 +8,12 @@ import { extractImageFromResponse, getAspectRatio, getImageSize, + validateArgs, } from "./openrouter.ts"; +const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview"; +const FLUX_MODEL = "black-forest-labs/flux.2-pro"; + function makeArgs(overrides: Partial = {}): CliArgs { return { prompt: null, @@ -33,7 +37,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { test("OpenRouter request body uses image_config and string content for text-only prompts", () => { const args = makeArgs({ aspectRatio: "16:9", quality: "2k" }); - const body = buildRequestBody("hello", args, []); + const body = buildRequestBody("hello", GEMINI_MODEL, args, []); assert.deepEqual(body.image_config, { image_size: "2K", @@ -42,6 +46,17 @@ test("OpenRouter request body uses image_config and string content for text-only assert.deepEqual(body.provider, { require_parameters: true, }); + assert.deepEqual(body.modalities, ["image", "text"]); + assert.equal(body.stream, false); + assert.equal(body.messages[0].content, "hello"); +}); + +test("OpenRouter request body omits image_config and provider guard when no image options are requested", () => { + const body = buildRequestBody("hello", FLUX_MODEL, makeArgs(), []); + + assert.equal(body.image_config, undefined); + assert.equal(body.provider, undefined); + assert.deepEqual(body.modalities, ["image"]); assert.equal(body.stream, false); assert.equal(body.messages[0].content, "hello"); }); @@ -57,9 +72,23 @@ test("OpenRouter request body keeps multimodal array content when references are }); test("OpenRouter size and aspect helpers infer expected defaults", () => { + assert.equal(getImageSize(makeArgs()), null); assert.equal(getImageSize(makeArgs({ quality: "normal" })), "1K"); assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K"); - assert.equal(getAspectRatio(makeArgs({ size: "2048x1024" })), "2:1"); + assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "2048x1024" })), null); + assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1600x900" })), "16:9"); + assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), "1:4"); + assert.equal(getAspectRatio(FLUX_MODEL, makeArgs({ size: "1024x4096" })), null); +}); + +test("OpenRouter validates explicit aspect ratios against model support", () => { + assert.doesNotThrow(() => + validateArgs(GEMINI_MODEL, makeArgs({ aspectRatio: "1:4" })), + ); + assert.throws( + () => validateArgs(FLUX_MODEL, makeArgs({ aspectRatio: "1:4" })), + /does not support aspect ratio 1:4/, + ); }); test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => { diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.ts index c8c3806..93db5f4 100644 --- a/skills/baoyu-image-gen/scripts/providers/openrouter.ts +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.ts @@ -3,6 +3,19 @@ import { readFile } from "node:fs/promises"; import type { CliArgs } from "../types"; const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview"; +const COMMON_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]; +const GEMINI_EXTENDED_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"]; type OpenRouterImageEntry = { image_url?: string | { url?: string | null } | null; @@ -31,6 +44,23 @@ export function getDefaultModel(): string { return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL; } +function normalizeModelId(model: string): string { + return model.trim().toLowerCase(); +} + +export function isGeminiImageModel(model: string): boolean { + const normalized = normalizeModelId(model); + return normalized.startsWith("google/gemini-") && normalized.includes("image-preview"); +} + +function getSupportedAspectRatios(model: string): Set { + if (!isGeminiImageModel(model)) { + return new Set(COMMON_ASPECT_RATIOS); + } + + return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]); +} + function getApiKey(): string | null { return process.env.OPENROUTER_API_KEY || null; } @@ -105,17 +135,45 @@ function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null { return "4K"; } -export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" { +export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" | null { if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K"; const inferredFromSize = inferImageSize(args.size); if (inferredFromSize) return inferredFromSize; - return args.quality === "normal" ? "1K" : "2K"; + if (args.quality === "normal") return "1K"; + if (args.quality === "2k") return "2K"; + return null; } -export function getAspectRatio(args: CliArgs): string | null { - return args.aspectRatio || inferAspectRatio(args.size); +export function getAspectRatio(model: string, args: CliArgs): string | null { + if (args.aspectRatio) return args.aspectRatio; + + const inferred = inferAspectRatio(args.size); + if (!inferred || !getSupportedAspectRatios(model).has(inferred)) { + return null; + } + + return inferred; +} + +function getModalities(model: string): string[] { + return isGeminiImageModel(model) ? ["image", "text"] : ["image"]; +} + +export function validateArgs(model: string, args: CliArgs): void { + if (!args.aspectRatio) { + return; + } + + const supported = getSupportedAspectRatios(model); + if (supported.has(args.aspectRatio)) { + return; + } + + throw new Error( + `OpenRouter model ${model} does not support aspect ratio ${args.aspectRatio}. Supported values: ${Array.from(supported).join(", ")}` + ); } function getMimeType(filename: string): string { @@ -213,32 +271,41 @@ export async function extractImageFromResponse(result: OpenRouterResponse): Prom export function buildRequestBody( prompt: string, + model: string, args: CliArgs, referenceImages: string[], ): Record { - const imageConfig: Record = { - image_size: getImageSize(args), - }; + const imageConfig: Record = {}; - const aspectRatio = getAspectRatio(args); + const imageSize = getImageSize(args); + if (imageSize) { + imageConfig.image_size = imageSize; + } + + const aspectRatio = getAspectRatio(model, args); if (aspectRatio) { imageConfig.aspect_ratio = aspectRatio; } - return { + const body: Record = { messages: [ { role: "user", content: buildContent(prompt, referenceImages), }, ], - modalities: ["image", "text"], - image_config: imageConfig, - provider: { - require_parameters: true, - }, + modalities: getModalities(model), stream: false, }; + + if (Object.keys(imageConfig).length > 0) { + body.image_config = imageConfig; + body.provider = { + require_parameters: true, + }; + } + + return body; } export async function generateImage( @@ -258,7 +325,7 @@ export async function generateImage( const body = { model, - ...buildRequestBody(prompt, args, referenceImages), + ...buildRequestBody(prompt, model, args, referenceImages), }; console.log( From e43eec260a0aec5853f31c341e69a15c186a5ac0 Mon Sep 17 00:00:00 2001 From: cwandev <18888351756@163.com> Date: Thu, 19 Mar 2026 22:09:23 +0800 Subject: [PATCH 4/4] fix(baoyu-image-gen): narrow OpenRouter Gemini ratios --- skills/baoyu-image-gen/scripts/providers/openrouter.test.ts | 6 ++++++ skills/baoyu-image-gen/scripts/providers/openrouter.ts | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts index f0e2dfc..eda5437 100644 --- a/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.test.ts @@ -12,6 +12,7 @@ import { } from "./openrouter.ts"; const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview"; +const GEMINI_25_MODEL = "google/gemini-2.5-flash-image-preview"; const FLUX_MODEL = "black-forest-labs/flux.2-pro"; function makeArgs(overrides: Partial = {}): CliArgs { @@ -78,6 +79,7 @@ test("OpenRouter size and aspect helpers infer expected defaults", () => { assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "2048x1024" })), null); assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1600x900" })), "16:9"); assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), "1:4"); + assert.equal(getAspectRatio(GEMINI_25_MODEL, makeArgs({ size: "1024x4096" })), null); assert.equal(getAspectRatio(FLUX_MODEL, makeArgs({ size: "1024x4096" })), null); }); @@ -85,6 +87,10 @@ test("OpenRouter validates explicit aspect ratios against model support", () => assert.doesNotThrow(() => validateArgs(GEMINI_MODEL, makeArgs({ aspectRatio: "1:4" })), ); + assert.throws( + () => validateArgs(GEMINI_25_MODEL, makeArgs({ aspectRatio: "1:4" })), + /does not support aspect ratio 1:4/, + ); assert.throws( () => validateArgs(FLUX_MODEL, makeArgs({ aspectRatio: "1:4" })), /does not support aspect ratio 1:4/, diff --git a/skills/baoyu-image-gen/scripts/providers/openrouter.ts b/skills/baoyu-image-gen/scripts/providers/openrouter.ts index 93db5f4..86e0db7 100644 --- a/skills/baoyu-image-gen/scripts/providers/openrouter.ts +++ b/skills/baoyu-image-gen/scripts/providers/openrouter.ts @@ -45,7 +45,7 @@ export function getDefaultModel(): string { } function normalizeModelId(model: string): string { - return model.trim().toLowerCase(); + return model.trim().toLowerCase().split(":")[0]!; } export function isGeminiImageModel(model: string): boolean { @@ -54,7 +54,8 @@ export function isGeminiImageModel(model: string): boolean { } function getSupportedAspectRatios(model: string): Set { - if (!isGeminiImageModel(model)) { + const normalized = normalizeModelId(model); + if (normalized !== "google/gemini-3.1-flash-image-preview") { return new Set(COMMON_ASPECT_RATIOS); }