From 614d2928aba9edd8500507c84f780db18f00bbc9 Mon Sep 17 00:00:00 2001 From: FunJim Date: Wed, 6 May 2026 23:40:59 +0800 Subject: [PATCH] fix(openrouter-images): handle object-shape image payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate.ts and edit.ts assumed each entry in message.images is a base64 string and called .startsWith() on it directly. The OpenRouter chat-completions response actually returns an object for several image-generation models (currently observed for openai/gpt-5.x-image* and google/gemini-3.1-flash-image-preview): { type: "image_url", image_url: { url: "data:image/png;base64,..." } } That makes images[i].startsWith() throw a TypeError and the script crashes before saving anything — for both 'generate' and 'edit', and for the documented default model (google/gemini-3.1-flash-image-preview). This change adds a toDataUrl() helper in lib.ts that normalizes: - strings (existing behavior — base64 or data URL) - { image_url: { url } } (OpenAI-style) - { url } / { b64_json } / { data } (other variants seen in the wild) into a data-URL string suitable for saveImage(). On an unrecognized shape it prints a one-line error with a 200-char payload preview and exits 1, matching the surrounding error-handling style. Both generate.ts and edit.ts now route through this helper; their images-array type is widened from string[] to unknown[] so the helper owns the runtime check. Verified end-to-end against: - google/gemini-3.1-flash-image-preview (default) - openai/gpt-5.4-image-2 Typecheck (tsc --strict --target ES2022 --module NodeNext) passes clean (with @types/node). --- skills/openrouter-images/scripts/edit.ts | 5 ++-- skills/openrouter-images/scripts/generate.ts | 5 ++-- skills/openrouter-images/scripts/lib.ts | 30 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/skills/openrouter-images/scripts/edit.ts b/skills/openrouter-images/scripts/edit.ts index 37199b1..2d5d96d 100644 --- a/skills/openrouter-images/scripts/edit.ts +++ b/skills/openrouter-images/scripts/edit.ts @@ -6,6 +6,7 @@ import { readImageAsDataUrl, saveImage, defaultOutputPath, + toDataUrl, } from "./lib.js"; const apiKey = requireApiKey(); @@ -57,7 +58,7 @@ if (message.content) { console.error(`Model: ${message.content}`); } -const images: string[] = message.images ?? []; +const images: unknown[] = message.images ?? []; if (images.length === 0) { console.error("Error: No images returned by model."); process.exit(1); @@ -65,7 +66,7 @@ if (images.length === 0) { const saved: string[] = []; for (let i = 0; i < images.length; i++) { - const img = images[i].startsWith("data:") ? images[i] : `data:image/png;base64,${images[i]}`; + const img = toDataUrl(images[i]); let outPath: string; if (images.length === 1) { outPath = outputBase; diff --git a/skills/openrouter-images/scripts/generate.ts b/skills/openrouter-images/scripts/generate.ts index 50e7f72..9eb488a 100644 --- a/skills/openrouter-images/scripts/generate.ts +++ b/skills/openrouter-images/scripts/generate.ts @@ -5,6 +5,7 @@ import { postChatCompletion, saveImage, defaultOutputPath, + toDataUrl, } from "./lib.js"; const apiKey = requireApiKey(); @@ -44,7 +45,7 @@ if (message.content) { console.error(`Model: ${message.content}`); } -const images: string[] = message.images ?? []; +const images: unknown[] = message.images ?? []; if (images.length === 0) { console.error("Error: No images returned by model."); process.exit(1); @@ -52,7 +53,7 @@ if (images.length === 0) { const saved: string[] = []; for (let i = 0; i < images.length; i++) { - const dataUrl = images[i].startsWith("data:") ? images[i] : `data:image/png;base64,${images[i]}`; + const dataUrl = toDataUrl(images[i]); let outPath: string; if (images.length === 1) { outPath = outputBase; diff --git a/skills/openrouter-images/scripts/lib.ts b/skills/openrouter-images/scripts/lib.ts index 143aea4..c22daff 100644 --- a/skills/openrouter-images/scripts/lib.ts +++ b/skills/openrouter-images/scripts/lib.ts @@ -103,3 +103,33 @@ export function defaultOutputPath(): string { `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; return `image-${stamp}.png`; } + +/** + * Normalize an entry from `message.images` into a data-URL string. + * + * The chat-completions response shape for `images` varies by model: + * - some models return a raw base64 string, + * - some return a data URL string, + * - others (e.g. openai/gpt-5.4-image-2, recent google/gemini-*-image + * variants) return an object: `{ type: "image_url", image_url: { url } }` + * or `{ b64_json: "..." }`. + * + * This helper accepts any of those and returns a `data:...;base64,...` URL + * suitable for `saveImage()`. It exits with a clean error on unrecognized + * shapes rather than throwing a `TypeError`. + */ +export function toDataUrl(entry: unknown): string { + let str: string | undefined; + if (typeof entry === "string") { + str = entry; + } else if (entry && typeof entry === "object") { + const e = entry as { image_url?: { url?: string }; url?: string; b64_json?: string; data?: string }; + str = e.image_url?.url ?? e.url ?? e.b64_json ?? e.data; + } + if (typeof str !== "string" || str.length === 0) { + const preview = JSON.stringify(entry).slice(0, 200); + console.error(`Error: Unrecognized image payload shape in response: ${preview}`); + process.exit(1); + } + return str.startsWith("data:") ? str : `data:image/png;base64,${str}`; +}