fix(baoyu-image-gen): align OpenRouter image generation with current API
This commit is contained in:
parent
60363fc2df
commit
1af984a64f
|
|
@ -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> = {}): 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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -18,9 +18,11 @@ type OpenRouterMessagePart = {
|
||||||
|
|
||||||
type OpenRouterResponse = {
|
type OpenRouterResponse = {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
|
finish_reason?: string | null;
|
||||||
|
native_finish_reason?: string | null;
|
||||||
message?: {
|
message?: {
|
||||||
images?: OpenRouterImageEntry[];
|
images?: OpenRouterImageEntry[];
|
||||||
content?: string | OpenRouterMessagePart[];
|
content?: string | OpenRouterMessagePart[] | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
@ -103,7 +105,7 @@ function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null {
|
||||||
return "4K";
|
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";
|
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
||||||
|
|
||||||
const inferredFromSize = inferImageSize(args.size);
|
const inferredFromSize = inferImageSize(args.size);
|
||||||
|
|
@ -112,7 +114,7 @@ function getImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
||||||
return args.quality === "normal" ? "1K" : "2K";
|
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);
|
return args.aspectRatio || inferAspectRatio(args.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,7 +131,14 @@ async function readImageAsDataUrl(filePath: string): Promise<string> {
|
||||||
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContent(prompt: string, referenceImages: string[]): Array<Record<string, unknown>> {
|
export function buildContent(
|
||||||
|
prompt: string,
|
||||||
|
referenceImages: string[],
|
||||||
|
): string | Array<Record<string, unknown>> {
|
||||||
|
if (referenceImages.length === 0) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
|
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
|
||||||
|
|
||||||
for (const imageUrl of referenceImages) {
|
for (const imageUrl of referenceImages) {
|
||||||
|
|
@ -171,8 +180,9 @@ async function downloadImage(value: string): Promise<Uint8Array> {
|
||||||
return Uint8Array.from(Buffer.from(value, "base64"));
|
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
export async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
||||||
const message = result.choices?.[0]?.message;
|
const choice = result.choices?.[0];
|
||||||
|
const message = choice?.message;
|
||||||
|
|
||||||
for (const image of message?.images ?? []) {
|
for (const image of message?.images ?? []) {
|
||||||
const imageUrl = extractImageUrl(image);
|
const imageUrl = extractImageUrl(image);
|
||||||
|
|
@ -194,7 +204,38 @@ async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uin
|
||||||
if (inline) return inline;
|
if (inline) return inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("No image in OpenRouter response");
|
const finishReason =
|
||||||
|
choice?.native_finish_reason || choice?.finish_reason || "unknown";
|
||||||
|
throw new Error(
|
||||||
|
`No image in OpenRouter response (finish_reason=${finishReason})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequestBody(
|
||||||
|
prompt: string,
|
||||||
|
args: CliArgs,
|
||||||
|
referenceImages: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const imageConfig: Record<string, string> = {
|
||||||
|
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(
|
export async function generateImage(
|
||||||
|
|
@ -212,32 +253,15 @@ export async function generateImage(
|
||||||
referenceImages.push(await readImageAsDataUrl(refPath));
|
referenceImages.push(await readImageAsDataUrl(refPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageGenerationOptions: Record<string, string> = {
|
|
||||||
size: getImageSize(args),
|
|
||||||
};
|
|
||||||
|
|
||||||
const aspectRatio = getAspectRatio(args);
|
|
||||||
if (aspectRatio) {
|
|
||||||
imageGenerationOptions.aspect_ratio = aspectRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
model,
|
model,
|
||||||
messages: [
|
...buildRequestBody(prompt, args, referenceImages),
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: buildContent(prompt, referenceImages),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
modalities: ["image", "text"],
|
|
||||||
max_tokens: 256,
|
|
||||||
imageGenerationOptions,
|
|
||||||
providerPreferences: {
|
|
||||||
require_parameters: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Generating image with OpenRouter (${model})...`, imageGenerationOptions);
|
console.log(
|
||||||
|
`Generating image with OpenRouter (${model})...`,
|
||||||
|
(body.image_config as Record<string, string>),
|
||||||
|
);
|
||||||
|
|
||||||
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
|
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue