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/openrouter.ts
1import path from "node:path";2import { readFile } from "node:fs/promises";3import type { CliArgs } from "../types";45const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";6const COMMON_ASPECT_RATIOS = [7"1:1",8"2:3",9"3:2",10"3:4",11"4:3",12"4:5",13"5:4",14"9:16",15"16:9",16"21:9",17];18const GEMINI_EXTENDED_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"];1920type OpenRouterImageEntry = {21image_url?: string | { url?: string | null } | null;22imageUrl?: string | { url?: string | null } | null;23};2425type OpenRouterMessagePart = {26type?: string;27text?: string;28image_url?: string | { url?: string | null } | null;29imageUrl?: string | { url?: string | null } | null;30};3132type OpenRouterResponse = {33choices?: Array<{34finish_reason?: string | null;35native_finish_reason?: string | null;36message?: {37images?: OpenRouterImageEntry[];38content?: string | OpenRouterMessagePart[] | null;39};40}>;41};4243export function getDefaultModel(): string {44return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;45}4647function normalizeModelId(model: string): string {48return model.trim().toLowerCase().split(":")[0]!;49}5051function isTextAndImageModel(model: string): boolean {52const normalized = normalizeModelId(model);53if (normalized === "openrouter/auto") {54return true;55}5657if (normalized.startsWith("google/gemini-") && normalized.includes("image")) {58return true;59}6061if (normalized.startsWith("openai/gpt-") && normalized.includes("image")) {62return true;63}6465return false;66}6768function getSupportedAspectRatios(model: string): Set<string> {69const normalized = normalizeModelId(model);70if (normalized !== "google/gemini-3.1-flash-image-preview") {71return new Set(COMMON_ASPECT_RATIOS);72}7374return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]);75}7677function getApiKey(): string | null {78return process.env.OPENROUTER_API_KEY || null;79}8081function getBaseUrl(): string {82const base = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";83return base.replace(/\/+$/g, "");84}8586function getHeaders(apiKey: string): Record<string, string> {87const headers: Record<string, string> = {88"Content-Type": "application/json",89Authorization: `Bearer ${apiKey}`,90};9192const referer = process.env.OPENROUTER_HTTP_REFERER?.trim();93if (referer) {94headers["HTTP-Referer"] = referer;95}9697const title = process.env.OPENROUTER_TITLE?.trim();98if (title) {99headers["X-OpenRouter-Title"] = title;100headers["X-Title"] = title;101}102103return headers;104}105106function parsePixelSize(value: string): { width: number; height: number } | null {107const match = value.match(/^(\d+)\s*[xX]\s*(\d+)$/);108if (!match) return null;109110const width = parseInt(match[1]!, 10);111const height = parseInt(match[2]!, 10);112113if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {114return null;115}116117return { width, height };118}119120function gcd(a: number, b: number): number {121let x = Math.abs(a);122let y = Math.abs(b);123while (y !== 0) {124const next = x % y;125x = y;126y = next;127}128return x || 1;129}130131function inferAspectRatio(size: string | null): string | null {132if (!size) return null;133const parsed = parsePixelSize(size);134if (!parsed) return null;135136const divisor = gcd(parsed.width, parsed.height);137return `${parsed.width / divisor}:${parsed.height / divisor}`;138}139140function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null {141if (!size) return null;142const parsed = parsePixelSize(size);143if (!parsed) return null;144145const longestEdge = Math.max(parsed.width, parsed.height);146if (longestEdge <= 1024) return "1K";147if (longestEdge <= 2048) return "2K";148return "4K";149}150151export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" | null {152if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";153154const inferredFromSize = inferImageSize(args.size);155if (inferredFromSize) return inferredFromSize;156157if (args.quality === "normal") return "1K";158if (args.quality === "2k") return "2K";159return null;160}161162export function getAspectRatio(model: string, args: CliArgs): string | null {163if (args.aspectRatio) return args.aspectRatio;164165const inferred = inferAspectRatio(args.size);166if (!inferred || !getSupportedAspectRatios(model).has(inferred)) {167return null;168}169170return inferred;171}172173function getModalities(model: string): string[] {174return isTextAndImageModel(model) ? ["image", "text"] : ["image"];175}176177export function validateArgs(model: string, args: CliArgs): void {178const requestedAspectRatio = args.aspectRatio || inferAspectRatio(args.size);179if (!requestedAspectRatio) {180return;181}182183const supported = getSupportedAspectRatios(model);184if (supported.has(requestedAspectRatio)) {185return;186}187188const requestedValue = args.aspectRatio189? `aspect ratio ${requestedAspectRatio}`190: `size ${args.size} (aspect ratio ${requestedAspectRatio})`;191192throw new Error(193`OpenRouter model ${model} does not support ${requestedValue}. Supported values: ${Array.from(supported).join(", ")}`194);195}196197function getMimeType(filename: string): string {198const ext = path.extname(filename).toLowerCase();199if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";200if (ext === ".webp") return "image/webp";201if (ext === ".gif") return "image/gif";202return "image/png";203}204205async function readImageAsDataUrl(filePath: string): Promise<string> {206const bytes = await readFile(filePath);207return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;208}209210export function buildContent(211prompt: string,212referenceImages: string[],213): string | Array<Record<string, unknown>> {214if (referenceImages.length === 0) {215return prompt;216}217218const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];219220for (const imageUrl of referenceImages) {221content.push({222type: "image_url",223image_url: { url: imageUrl },224});225}226227return content;228}229230function extractImageUrl(entry: OpenRouterImageEntry | OpenRouterMessagePart): string | null {231const value = entry.image_url ?? entry.imageUrl;232if (!value) return null;233if (typeof value === "string") return value;234return value.url ?? null;235}236237function decodeDataUrl(value: string): Uint8Array | null {238const match = value.match(/^data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)$/);239if (!match) return null;240return Uint8Array.from(Buffer.from(match[1]!, "base64"));241}242243async function downloadImage(value: string): Promise<Uint8Array> {244const inline = decodeDataUrl(value);245if (inline) return inline;246247if (value.startsWith("http://") || value.startsWith("https://")) {248const response = await fetch(value);249if (!response.ok) {250throw new Error(`Failed to download OpenRouter image: ${response.status}`);251}252const buffer = await response.arrayBuffer();253return new Uint8Array(buffer);254}255256return Uint8Array.from(Buffer.from(value, "base64"));257}258259export async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {260const choice = result.choices?.[0];261const message = choice?.message;262263for (const image of message?.images ?? []) {264const imageUrl = extractImageUrl(image);265if (imageUrl) return downloadImage(imageUrl);266}267268if (Array.isArray(message?.content)) {269for (const item of message.content) {270const imageUrl = extractImageUrl(item);271if (imageUrl) return downloadImage(imageUrl);272273if (item.type === "text" && item.text) {274const inline = decodeDataUrl(item.text);275if (inline) return inline;276}277}278} else if (typeof message?.content === "string") {279const inline = decodeDataUrl(message.content);280if (inline) return inline;281}282283const finishReason =284choice?.native_finish_reason || choice?.finish_reason || "unknown";285throw new Error(286`No image in OpenRouter response (finish_reason=${finishReason})`,287);288}289290export function buildRequestBody(291prompt: string,292model: string,293args: CliArgs,294referenceImages: string[],295): Record<string, unknown> {296validateArgs(model, args);297298const imageConfig: Record<string, string> = {};299300const imageSize = getImageSize(args);301if (imageSize) {302imageConfig.image_size = imageSize;303}304305const aspectRatio = getAspectRatio(model, args);306if (aspectRatio) {307imageConfig.aspect_ratio = aspectRatio;308}309310const body: Record<string, unknown> = {311messages: [312{313role: "user",314content: buildContent(prompt, referenceImages),315},316],317modalities: getModalities(model),318stream: false,319};320321if (Object.keys(imageConfig).length > 0) {322body.image_config = imageConfig;323body.provider = {324require_parameters: true,325};326}327328return body;329}330331export async function generateImage(332prompt: string,333model: string,334args: CliArgs335): Promise<Uint8Array> {336const apiKey = getApiKey();337if (!apiKey) {338throw new Error("OPENROUTER_API_KEY is required. Get one at https://openrouter.ai/settings/keys");339}340341const referenceImages: string[] = [];342for (const refPath of args.referenceImages) {343referenceImages.push(await readImageAsDataUrl(refPath));344}345346const body = {347model,348...buildRequestBody(prompt, model, args, referenceImages),349};350351console.log(352`Generating image with OpenRouter (${model})...`,353(body.image_config as Record<string, string>),354);355356const response = await fetch(`${getBaseUrl()}/chat/completions`, {357method: "POST",358headers: getHeaders(apiKey),359body: JSON.stringify(body),360});361362if (!response.ok) {363const errorText = await response.text();364throw new Error(`OpenRouter API error (${response.status}): ${errorText}`);365}366367const result = (await response.json()) as OpenRouterResponse;368return extractImageFromResponse(result);369}370