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(