Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Generate images via OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream, and Replicate APIs with batch support.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/providers/openai.ts
1import path from "node:path";2import { readFile } from "node:fs/promises";3import type { CliArgs, OpenAIImageApiDialect } from "../types";45export function getDefaultModel(): string {6return process.env.OPENAI_IMAGE_MODEL || "gpt-image-2";7}89type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };1011export function parseAspectRatio(ar: string): { width: number; height: number } | null {12const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);13if (!match) return null;14const w = parseFloat(match[1]!);15const h = parseFloat(match[2]!);16if (w <= 0 || h <= 0) return null;17return { width: w, height: h };18}1920type SizeMapping = {21square: string;22landscape: string;23portrait: string;24};2526type OpenAIGenerationsBody = Record<string, unknown>;2728function isGptImageModel(model: string): boolean {29return model.includes("gpt-image");30}3132function isGptImage2Model(model: string): boolean {33return model.includes("gpt-image-2");34}3536function roundToMultiple(value: number, multiple: number): number {37return Math.max(multiple, Math.round(value / multiple) * multiple);38}3940function buildGptImage2SizeFromAspectRatio(41ar: string | null,42quality: CliArgs["quality"],43): string {44const parsed = ar ? parseAspectRatio(ar) : null;45const ratio = parsed ? parsed.width / parsed.height : 1;4647if (!parsed || Math.abs(ratio - 1) < 0.1) {48const edge = quality === "2k" ? 2048 : 1024;49return `${edge}x${edge}`;50}5152const targetLongEdge = quality === "2k" ? 2048 : 1024;53let width: number;54let height: number;5556if (ratio > 1) {57width = targetLongEdge;58height = roundToMultiple(width / ratio, 16);59} else {60height = targetLongEdge;61width = roundToMultiple(height * ratio, 16);62}6364while (width * height < 655_360) {65if (ratio > 1) {66width += 16;67height = roundToMultiple(width / ratio, 16);68} else {69height += 16;70width = roundToMultiple(height * ratio, 16);71}72}7374return `${width}x${height}`;75}7677export function getOpenAISize(78model: string,79ar: string | null,80quality: CliArgs["quality"]81): string {82const isDalle3 = model.includes("dall-e-3");83const isDalle2 = model.includes("dall-e-2");8485if (isDalle2) {86return "1024x1024";87}8889if (isGptImage2Model(model)) {90return buildGptImage2SizeFromAspectRatio(ar, quality);91}9293const sizes: SizeMapping = isDalle394? {95square: "1024x1024",96landscape: "1792x1024",97portrait: "1024x1792",98}99: {100square: "1024x1024",101landscape: "1536x1024",102portrait: "1024x1536",103};104105if (!ar) return sizes.square;106107const parsed = parseAspectRatio(ar);108if (!parsed) return sizes.square;109110const ratio = parsed.width / parsed.height;111112if (Math.abs(ratio - 1) < 0.1) return sizes.square;113if (ratio > 1.5) return sizes.landscape;114if (ratio < 0.67) return sizes.portrait;115return sizes.square;116}117118function parsePixelSize(value: string): { width: number; height: number } | null {119const match = value.match(/^(\d+)\s*[xX]\s*(\d+)$/);120if (!match) return null;121122const width = parseInt(match[1]!, 10);123const height = parseInt(match[2]!, 10);124if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {125return null;126}127128return { width, height };129}130131function gcd(a: number, b: number): number {132let x = Math.abs(a);133let y = Math.abs(b);134while (y !== 0) {135const next = x % y;136x = y;137y = next;138}139return x || 1;140}141142export function getOpenAIImageApiDialect(args: Pick<CliArgs, "imageApiDialect">): OpenAIImageApiDialect {143return args.imageApiDialect ?? "openai-native";144}145146export function inferAspectRatioFromSize(size: string | null): string | null {147if (!size) return null;148const parsed = parsePixelSize(size);149if (!parsed) return null;150151const divisor = gcd(parsed.width, parsed.height);152return `${parsed.width / divisor}:${parsed.height / divisor}`;153}154155export function inferResolutionFromSize(size: string | null): "1K" | "2K" | "4K" | null {156if (!size) return null;157const parsed = parsePixelSize(size);158if (!parsed) return null;159160const longestEdge = Math.max(parsed.width, parsed.height);161if (longestEdge <= 1024) return "1K";162if (longestEdge <= 2048) return "2K";163return "4K";164}165166export function getOpenAIAspectRatio(args: Pick<CliArgs, "aspectRatio" | "size">): string {167return args.aspectRatio ?? inferAspectRatioFromSize(args.size) ?? "1:1";168}169170export function getOpenAIResolution(171args: Pick<CliArgs, "imageSize" | "size" | "quality">172): "1K" | "2K" | "4K" {173if (args.imageSize === "1K" || args.imageSize === "2K" || args.imageSize === "4K") {174return args.imageSize;175}176177const inferred = inferResolutionFromSize(args.size);178if (inferred) return inferred;179180return args.quality === "normal" ? "1K" : "2K";181}182183function getOpenAIQuality(model: string, quality: CliArgs["quality"]): "standard" | "hd" | "medium" | "high" | null {184if (model.includes("dall-e-3")) {185return quality === "2k" ? "hd" : "standard";186}187188if (isGptImageModel(model)) {189return quality === "2k" ? "high" : "medium";190}191192return null;193}194195export function getOrientationFromAspectRatio(ar: string): "landscape" | "portrait" | null {196const parsed = parseAspectRatio(ar);197if (!parsed) return null;198199const ratio = parsed.width / parsed.height;200if (Math.abs(ratio - 1) < 0.1) return null;201return ratio > 1 ? "landscape" : "portrait";202}203204export function buildOpenAIGenerationsBody(205prompt: string,206model: string,207args: Pick<CliArgs, "aspectRatio" | "size" | "quality" | "imageSize" | "imageApiDialect">208): OpenAIGenerationsBody {209if (getOpenAIImageApiDialect(args) === "ratio-metadata") {210const aspectRatio = getOpenAIAspectRatio(args);211const metadata: Record<string, string> = {212resolution: getOpenAIResolution(args),213};214const orientation = getOrientationFromAspectRatio(aspectRatio);215if (orientation) metadata.orientation = orientation;216217return {218model,219prompt,220size: aspectRatio,221metadata,222};223}224225const body: OpenAIGenerationsBody = {226model,227prompt,228size: args.size || getOpenAISize(model, args.aspectRatio, args.quality),229};230231const quality = getOpenAIQuality(model, args.quality);232if (quality) {233body.quality = quality;234}235236return body;237}238239export function validateArgs(model: string, args: CliArgs): void {240if (!isGptImage2Model(model)) return;241242if (args.aspectRatio && !args.size) {243const parsed = parseAspectRatio(args.aspectRatio);244if (!parsed) {245throw new Error(`Invalid gpt-image-2 aspect ratio: ${args.aspectRatio}`);246}247const ratio = parsed.width / parsed.height;248if (Math.max(ratio, 1 / ratio) > 3) {249throw new Error("gpt-image-2 aspect ratio must not exceed 3:1.");250}251}252253if (!args.size) return;254255const parsedSize = parsePixelSize(args.size);256if (!parsedSize) {257throw new Error(`Invalid gpt-image-2 --size: ${args.size}. Expected <width>x<height>.`);258}259260const { width, height } = parsedSize;261const totalPixels = width * height;262const ratio = Math.max(width, height) / Math.min(width, height);263264if (Math.max(width, height) > 3840) {265throw new Error("gpt-image-2 --size maximum edge length must be 3840px or less.");266}267if (width % 16 !== 0 || height % 16 !== 0) {268throw new Error("gpt-image-2 --size width and height must both be multiples of 16px.");269}270if (ratio > 3) {271throw new Error("gpt-image-2 --size long edge to short edge ratio must not exceed 3:1.");272}273if (totalPixels < 655_360 || totalPixels > 8_294_400) {274throw new Error("gpt-image-2 --size total pixels must be between 655,360 and 8,294,400.");275}276}277278export async function generateImage(279prompt: string,280model: string,281args: CliArgs282): Promise<Uint8Array> {283const baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";284const apiKey = process.env.OPENAI_API_KEY;285286if (!apiKey) {287throw new Error(288"OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script."289);290}291292if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {293return generateWithChatCompletions(baseURL, apiKey, prompt, model);294}295296const imageApiDialect = getOpenAIImageApiDialect(args);297298if (args.referenceImages.length > 0) {299if (imageApiDialect !== "openai-native") {300throw new Error(301"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."302);303}304if (model.includes("dall-e-2") || model.includes("dall-e-3")) {305throw new Error(306"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-2 (or another gpt-image model)."307);308}309const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);310return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);311}312313return generateWithOpenAIGenerations(314baseURL,315apiKey,316buildOpenAIGenerationsBody(prompt, model, args)317);318}319320async function generateWithChatCompletions(321baseURL: string,322apiKey: string,323prompt: string,324model: string325): Promise<Uint8Array> {326const res = await fetch(`${baseURL}/chat/completions`, {327method: "POST",328headers: {329"Content-Type": "application/json",330Authorization: `Bearer ${apiKey}`,331},332body: JSON.stringify({333model,334messages: [{ role: "user", content: prompt }],335}),336});337338if (!res.ok) {339const err = await res.text();340throw new Error(`OpenAI API error: ${err}`);341}342343const result = (await res.json()) as { choices: Array<{ message: { content: string } }> };344const content = result.choices[0]?.message?.content ?? "";345346const match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/);347if (match) {348return Uint8Array.from(Buffer.from(match[1]!, "base64"));349}350351throw new Error("No image found in chat completions response");352}353354async function generateWithOpenAIGenerations(355baseURL: string,356apiKey: string,357body: OpenAIGenerationsBody358): Promise<Uint8Array> {359const res = await fetch(`${baseURL}/images/generations`, {360method: "POST",361headers: {362"Content-Type": "application/json",363Authorization: `Bearer ${apiKey}`,364},365body: JSON.stringify(body),366});367368if (!res.ok) {369const err = await res.text();370throw new Error(`OpenAI API error: ${err}`);371}372373const result = (await res.json()) as OpenAIImageResponse;374return extractImageFromResponse(result);375}376377async function generateWithOpenAIEdits(378baseURL: string,379apiKey: string,380prompt: string,381model: string,382size: string,383referenceImages: string[],384quality: CliArgs["quality"]385): Promise<Uint8Array> {386const form = new FormData();387form.append("model", model);388form.append("prompt", prompt);389form.append("size", size);390391const openAIQuality = getOpenAIQuality(model, quality);392if (openAIQuality && openAIQuality !== "standard" && openAIQuality !== "hd") {393form.append("quality", openAIQuality);394}395396for (const refPath of referenceImages) {397const bytes = await readFile(refPath);398const filename = path.basename(refPath);399const mimeType = getMimeType(filename);400const blob = new Blob([bytes], { type: mimeType });401form.append("image[]", blob, filename);402}403404const res = await fetch(`${baseURL}/images/edits`, {405method: "POST",406headers: {407Authorization: `Bearer ${apiKey}`,408},409body: form,410});411412if (!res.ok) {413const err = await res.text();414throw new Error(`OpenAI edits API error: ${err}`);415}416417const result = (await res.json()) as OpenAIImageResponse;418return extractImageFromResponse(result);419}420421export function getMimeType(filename: string): string {422const ext = path.extname(filename).toLowerCase();423if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";424if (ext === ".webp") return "image/webp";425if (ext === ".gif") return "image/gif";426return "image/png";427}428429export async function extractImageFromResponse(result: OpenAIImageResponse): Promise<Uint8Array> {430const img = result.data[0];431432if (img?.b64_json) {433return Uint8Array.from(Buffer.from(img.b64_json, "base64"));434}435436if (img?.url) {437const imgRes = await fetch(img.url);438if (!imgRes.ok) throw new Error("Failed to download image");439const buf = await imgRes.arrayBuffer();440return new Uint8Array(buf);441}442443throw new Error("No image in response");444}445