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",