mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-08-05 08:29:47 +08:00
fix(baoyu-image-gen): harden OpenRouter image support
This commit is contained in:
@@ -8,8 +8,12 @@ import {
|
|||||||
extractImageFromResponse,
|
extractImageFromResponse,
|
||||||
getAspectRatio,
|
getAspectRatio,
|
||||||
getImageSize,
|
getImageSize,
|
||||||
|
validateArgs,
|
||||||
} from "./openrouter.ts";
|
} 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> = {}): CliArgs {
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
return {
|
return {
|
||||||
prompt: null,
|
prompt: null,
|
||||||
@@ -33,7 +37,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
|
|
||||||
test("OpenRouter request body uses image_config and string content for text-only prompts", () => {
|
test("OpenRouter request body uses image_config and string content for text-only prompts", () => {
|
||||||
const args = makeArgs({ aspectRatio: "16:9", quality: "2k" });
|
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, {
|
assert.deepEqual(body.image_config, {
|
||||||
image_size: "2K",
|
image_size: "2K",
|
||||||
@@ -42,6 +46,17 @@ test("OpenRouter request body uses image_config and string content for text-only
|
|||||||
assert.deepEqual(body.provider, {
|
assert.deepEqual(body.provider, {
|
||||||
require_parameters: true,
|
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.stream, false);
|
||||||
assert.equal(body.messages[0].content, "hello");
|
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", () => {
|
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({ quality: "normal" })), "1K");
|
||||||
assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K");
|
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 () => {
|
test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ import { readFile } from "node:fs/promises";
|
|||||||
import type { CliArgs } from "../types";
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
|
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 = {
|
type OpenRouterImageEntry = {
|
||||||
image_url?: string | { url?: string | null } | null;
|
image_url?: string | { url?: string | null } | null;
|
||||||
@@ -31,6 +44,23 @@ export function getDefaultModel(): string {
|
|||||||
return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;
|
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<string> {
|
||||||
|
if (!isGeminiImageModel(model)) {
|
||||||
|
return new Set(COMMON_ASPECT_RATIOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]);
|
||||||
|
}
|
||||||
|
|
||||||
function getApiKey(): string | null {
|
function getApiKey(): string | null {
|
||||||
return process.env.OPENROUTER_API_KEY || null;
|
return process.env.OPENROUTER_API_KEY || null;
|
||||||
}
|
}
|
||||||
@@ -105,17 +135,45 @@ function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null {
|
|||||||
return "4K";
|
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";
|
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
||||||
|
|
||||||
const inferredFromSize = inferImageSize(args.size);
|
const inferredFromSize = inferImageSize(args.size);
|
||||||
if (inferredFromSize) return inferredFromSize;
|
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 {
|
export function getAspectRatio(model: string, args: CliArgs): string | null {
|
||||||
return args.aspectRatio || inferAspectRatio(args.size);
|
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 {
|
function getMimeType(filename: string): string {
|
||||||
@@ -213,32 +271,41 @@ export async function extractImageFromResponse(result: OpenRouterResponse): Prom
|
|||||||
|
|
||||||
export function buildRequestBody(
|
export function buildRequestBody(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
model: string,
|
||||||
args: CliArgs,
|
args: CliArgs,
|
||||||
referenceImages: string[],
|
referenceImages: string[],
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const imageConfig: Record<string, string> = {
|
const imageConfig: Record<string, string> = {};
|
||||||
image_size: getImageSize(args),
|
|
||||||
};
|
|
||||||
|
|
||||||
const aspectRatio = getAspectRatio(args);
|
const imageSize = getImageSize(args);
|
||||||
|
if (imageSize) {
|
||||||
|
imageConfig.image_size = imageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatio = getAspectRatio(model, args);
|
||||||
if (aspectRatio) {
|
if (aspectRatio) {
|
||||||
imageConfig.aspect_ratio = aspectRatio;
|
imageConfig.aspect_ratio = aspectRatio;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const body: Record<string, unknown> = {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: buildContent(prompt, referenceImages),
|
content: buildContent(prompt, referenceImages),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
modalities: ["image", "text"],
|
modalities: getModalities(model),
|
||||||
image_config: imageConfig,
|
|
||||||
provider: {
|
|
||||||
require_parameters: true,
|
|
||||||
},
|
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (Object.keys(imageConfig).length > 0) {
|
||||||
|
body.image_config = imageConfig;
|
||||||
|
body.provider = {
|
||||||
|
require_parameters: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateImage(
|
export async function generateImage(
|
||||||
@@ -258,7 +325,7 @@ export async function generateImage(
|
|||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
model,
|
model,
|
||||||
...buildRequestBody(prompt, args, referenceImages),
|
...buildRequestBody(prompt, model, args, referenceImages),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
Reference in New Issue
Block a user