mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-08-08 01:43:03 +08:00
feat(baoyu-imagine): add OpenAI-compatible image API dialect support
Add --imageApiDialect flag, OPENAI_IMAGE_API_DIALECT env var, and default_image_api_dialect config for gateways that expect aspect-ratio size plus metadata.resolution instead of pixel size.
This commit is contained in:
@@ -48,6 +48,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -2,9 +2,16 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildOpenAIGenerationsBody,
|
||||
extractImageFromResponse,
|
||||
getOpenAIAspectRatio,
|
||||
getOpenAIImageApiDialect,
|
||||
getOpenAIResolution,
|
||||
getMimeType,
|
||||
getOpenAISize,
|
||||
getOrientationFromAspectRatio,
|
||||
inferAspectRatioFromSize,
|
||||
inferResolutionFromSize,
|
||||
parseAspectRatio,
|
||||
} from "./openai.ts";
|
||||
|
||||
@@ -18,6 +25,69 @@ test("OpenAI aspect-ratio parsing and size selection match model families", () =
|
||||
assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024");
|
||||
assert.equal(inferAspectRatioFromSize("1536x1024"), "3:2");
|
||||
assert.equal(inferResolutionFromSize("1536x1024"), "2K");
|
||||
assert.equal(getOpenAIAspectRatio({ aspectRatio: null, size: "2048x1152" }), "16:9");
|
||||
assert.equal(getOpenAIResolution({ imageSize: null, size: "2048x1152", quality: "normal" }), "2K");
|
||||
assert.equal(getOrientationFromAspectRatio("16:9"), "landscape");
|
||||
assert.equal(getOrientationFromAspectRatio("9:16"), "portrait");
|
||||
assert.equal(getOrientationFromAspectRatio("1:1"), null);
|
||||
assert.equal(getOpenAIImageApiDialect({ imageApiDialect: null }), "openai-native");
|
||||
});
|
||||
|
||||
test("OpenAI generations body switches between native and ratio-metadata dialects", () => {
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a skyline", "gpt-image-1.5", {
|
||||
aspectRatio: "16:9",
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
}),
|
||||
{
|
||||
model: "gpt-image-1.5",
|
||||
prompt: "Draw a skyline",
|
||||
size: "1536x1024",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a skyline", "gemini-3-pro-image-preview", {
|
||||
aspectRatio: "16:9",
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: "ratio-metadata",
|
||||
}),
|
||||
{
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Draw a skyline",
|
||||
size: "16:9",
|
||||
metadata: {
|
||||
resolution: "2K",
|
||||
orientation: "landscape",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a portrait", "gemini-3-pro-image-preview", {
|
||||
aspectRatio: null,
|
||||
size: "1152x2048",
|
||||
quality: "normal",
|
||||
imageSize: null,
|
||||
imageApiDialect: "ratio-metadata",
|
||||
}),
|
||||
{
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Draw a portrait",
|
||||
size: "9:16",
|
||||
metadata: {
|
||||
resolution: "2K",
|
||||
orientation: "portrait",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI mime-type detection covers supported reference image extensions", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
import type { CliArgs, OpenAIImageApiDialect } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
@@ -23,6 +23,8 @@ type SizeMapping = {
|
||||
portrait: string;
|
||||
};
|
||||
|
||||
type OpenAIGenerationsBody = Record<string, unknown>;
|
||||
|
||||
export function getOpenAISize(
|
||||
model: string,
|
||||
ar: string | null,
|
||||
@@ -60,6 +62,114 @@ export function getOpenAISize(
|
||||
return sizes.square;
|
||||
}
|
||||
|
||||
function parsePixelSize(value: string): { width: number; height: number } | null {
|
||||
const match = value.match(/^(\d+)\s*[xX]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const width = parseInt(match[1]!, 10);
|
||||
const height = parseInt(match[2]!, 10);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
let x = Math.abs(a);
|
||||
let y = Math.abs(b);
|
||||
while (y !== 0) {
|
||||
const next = x % y;
|
||||
x = y;
|
||||
y = next;
|
||||
}
|
||||
return x || 1;
|
||||
}
|
||||
|
||||
export function getOpenAIImageApiDialect(args: Pick<CliArgs, "imageApiDialect">): OpenAIImageApiDialect {
|
||||
return args.imageApiDialect ?? "openai-native";
|
||||
}
|
||||
|
||||
export function inferAspectRatioFromSize(size: string | null): string | null {
|
||||
if (!size) return null;
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const divisor = gcd(parsed.width, parsed.height);
|
||||
return `${parsed.width / divisor}:${parsed.height / divisor}`;
|
||||
}
|
||||
|
||||
export function inferResolutionFromSize(size: string | null): "1K" | "2K" | "4K" | null {
|
||||
if (!size) return null;
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const longestEdge = Math.max(parsed.width, parsed.height);
|
||||
if (longestEdge <= 1024) return "1K";
|
||||
if (longestEdge <= 2048) return "2K";
|
||||
return "4K";
|
||||
}
|
||||
|
||||
export function getOpenAIAspectRatio(args: Pick<CliArgs, "aspectRatio" | "size">): string {
|
||||
return args.aspectRatio ?? inferAspectRatioFromSize(args.size) ?? "1:1";
|
||||
}
|
||||
|
||||
export function getOpenAIResolution(
|
||||
args: Pick<CliArgs, "imageSize" | "size" | "quality">
|
||||
): "1K" | "2K" | "4K" {
|
||||
if (args.imageSize === "1K" || args.imageSize === "2K" || args.imageSize === "4K") {
|
||||
return args.imageSize;
|
||||
}
|
||||
|
||||
const inferred = inferResolutionFromSize(args.size);
|
||||
if (inferred) return inferred;
|
||||
|
||||
return args.quality === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
export function getOrientationFromAspectRatio(ar: string): "landscape" | "portrait" | null {
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return null;
|
||||
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (Math.abs(ratio - 1) < 0.1) return null;
|
||||
return ratio > 1 ? "landscape" : "portrait";
|
||||
}
|
||||
|
||||
export function buildOpenAIGenerationsBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: Pick<CliArgs, "aspectRatio" | "size" | "quality" | "imageSize" | "imageApiDialect">
|
||||
): OpenAIGenerationsBody {
|
||||
if (getOpenAIImageApiDialect(args) === "ratio-metadata") {
|
||||
const aspectRatio = getOpenAIAspectRatio(args);
|
||||
const metadata: Record<string, string> = {
|
||||
resolution: getOpenAIResolution(args),
|
||||
};
|
||||
const orientation = getOrientationFromAspectRatio(aspectRatio);
|
||||
if (orientation) metadata.orientation = orientation;
|
||||
|
||||
return {
|
||||
model,
|
||||
prompt,
|
||||
size: aspectRatio,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const body: OpenAIGenerationsBody = {
|
||||
model,
|
||||
prompt,
|
||||
size: args.size || getOpenAISize(model, args.aspectRatio, args.quality),
|
||||
};
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = args.quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
@@ -78,18 +188,28 @@ export async function generateImage(
|
||||
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||
}
|
||||
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
const imageApiDialect = getOpenAIImageApiDialect(args);
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (imageApiDialect !== "openai-native") {
|
||||
throw new Error(
|
||||
"Reference images are not supported with the ratio-metadata OpenAI dialect yet. Use openai-native, Google, Azure, OpenRouter, MiniMax, Seedream, or Replicate for image-edit workflows."
|
||||
);
|
||||
}
|
||||
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||
throw new Error(
|
||||
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)."
|
||||
);
|
||||
}
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
|
||||
}
|
||||
|
||||
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
||||
return generateWithOpenAIGenerations(
|
||||
baseURL,
|
||||
apiKey,
|
||||
buildOpenAIGenerationsBody(prompt, model, args)
|
||||
);
|
||||
}
|
||||
|
||||
async function generateWithChatCompletions(
|
||||
@@ -129,17 +249,8 @@ async function generateWithChatCompletions(
|
||||
async function generateWithOpenAIGenerations(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
quality: CliArgs["quality"]
|
||||
body: OpenAIGenerationsBody
|
||||
): Promise<Uint8Array> {
|
||||
const body: Record<string, any> = { model, prompt, size };
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
Reference in New Issue
Block a user