diff --git a/docs/sdk.md b/docs/sdk.md index 18ab6043..6d85aebc 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -740,7 +740,7 @@ createVideoAd(); | model type | models | |---|---| -| `imageModel` | `mai-image-2-5`, `mai-image-2-5/edit`, `seedream-5-pro`, `seedream-5-pro/edit`, `nano-banana-pro`, `nano-banana-pro/edit`, `nano-banana-2`, `nano-banana-2/edit`, `flux-pro`, `flux-dev`, `flux-schnell`, `seedream-v4.5/edit`, `qwen-image-2`, `qwen-image-2-pro`, `grok-imagine-image`, `phota`, `phota/edit`, `seedvr`, `recraft-clarity`, `topaz` | +| `imageModel` | `ideogram-v4-5`, `ideogram-v4-5/edit`, `gpt-image-2-5`, `gpt-image-2-5/edit`, `mai-image-2-5`, `mai-image-2-5/edit`, `seedream-5-pro`, `seedream-5-pro/edit`, `nano-banana-pro`, `nano-banana-pro/edit`, `nano-banana-2`, `nano-banana-2/edit`, `flux-pro`, `flux-dev`, `flux-schnell`, `seedream-v4.5/edit`, `qwen-image-2`, `qwen-image-2-pro`, `grok-imagine-image`, `phota`, `phota/edit`, `seedvr`, `recraft-clarity`, `topaz` | | `videoModel` | `kling-v3`, `kling-v3-standard`, `kling-v2.6`, `kling-v2.5` | | `lipsyncModel` | `sync-v2-pro` | diff --git a/src/ai-sdk/providers/fal.test.ts b/src/ai-sdk/providers/fal.test.ts index 0f2046aa..7c1d4d25 100644 --- a/src/ai-sdk/providers/fal.test.ts +++ b/src/ai-sdk/providers/fal.test.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, rmSync } from "node:fs"; -import { computeFileHashes, computePendingKey } from "./fal"; +import { calculateIdeogramV45Cost } from "../../definitions/models/ideogram-v4-5"; +import { + applyImageFileInputs, + computeFileHashes, + computePendingKey, + resolveImageEndpoint, + supportsImageAcceleration, +} from "./fal"; const TEST_PENDING_DIR = ".cache/fal-pending-test"; @@ -212,3 +219,81 @@ describe("fal queue recovery", () => { }); }); }); + +describe("ideogram v4.5 wiring", () => { + test("aliases and canonical id resolve to fal endpoints", () => { + expect(resolveImageEndpoint("ideogram-v4-5")).toBe("ideogram/v4.5"); + expect(resolveImageEndpoint("ideogram_v4_5")).toBe("ideogram/v4.5"); + expect(resolveImageEndpoint("ideogram-v4-5/edit")).toBe( + "ideogram/v4.5/edit", + ); + expect(resolveImageEndpoint("ideogram/v4.5")).toBe("ideogram/v4.5"); + }); + + test("files auto-route the t2i id to /edit", () => { + expect(resolveImageEndpoint("ideogram-v4-5", true)).toBe( + "ideogram/v4.5/edit", + ); + expect(resolveImageEndpoint("ideogram_v4_5", true)).toBe( + "ideogram/v4.5/edit", + ); + expect(resolveImageEndpoint("ideogram/v4.5", true)).toBe( + "ideogram/v4.5/edit", + ); + expect(resolveImageEndpoint("raw:ideogram/v4.5", true)).toBe( + "ideogram/v4.5", + ); + }); + + test("no acceleration field (strict fal schema)", () => { + expect(supportsImageAcceleration("ideogram-v4-5")).toBe(false); + expect(supportsImageAcceleration("ideogram/v4.5/edit")).toBe(false); + expect(supportsImageAcceleration("flux-schnell")).toBe(true); + }); + + test("files → image_url + reference_image_urls", () => { + const one: Record = {}; + applyImageFileInputs("ideogram-v4-5/edit", one, ["a"]); + expect(one).toEqual({ image_url: "a" }); + + const three: Record = {}; + applyImageFileInputs("ideogram-v4-5", three, ["a", "b", "c"]); + expect(three).toEqual({ image_url: "a", reference_image_urls: ["b", "c"] }); + }); + + test("more than 4 references throws before submitting", () => { + expect(() => + applyImageFileInputs("ideogram-v4-5/edit", {}, [ + "a", + "b", + "c", + "d", + "e", + "f", + ]), + ).toThrow(/up to 4 references/); + }); + + test("other models keep their layout", () => { + const multi: Record = {}; + applyImageFileInputs("nano-banana-pro/edit", multi, ["a", "b"]); + expect(multi).toEqual({ image_urls: ["a", "b"] }); + + const single: Record = {}; + applyImageFileInputs("reve/edit", single, ["a", "b"]); + expect(single).toEqual({ image_url: "a" }); + }); + + test("pricing by quality × num_images", () => { + expect(calculateIdeogramV45Cost({})).toBeCloseTo(0.06); + expect( + calculateIdeogramV45Cost({ providerOptions: { quality: "very_low" } }), + ).toBeCloseTo(0.008); + expect( + calculateIdeogramV45Cost({ + numImages: 8, + providerOptions: { quality: "high" }, + }), + ).toBeCloseTo(1.76); + }); +}); diff --git a/src/ai-sdk/providers/fal.ts b/src/ai-sdk/providers/fal.ts index 3fc62c80..ceb7b656 100644 --- a/src/ai-sdk/providers/fal.ts +++ b/src/ai-sdk/providers/fal.ts @@ -271,6 +271,11 @@ export const IMAGE_MODELS: Record = { "gpt-image-2-5/edit": "openai/gpt-image-2.5/flare/edit", "gpt-image-2-5-sunburst": "openai/gpt-image-2.5/sunburst/text-to-image", "gpt-image-2-5-sunburst/edit": "openai/gpt-image-2.5/sunburst/edit", + // ── Ideogram 4.5 (2026-09) ────────────────────────────────────────────── + // Typography-first t2i + edit. "ideogram-v4-5" auto-routes to /edit when + // files are passed (see resolveImageEndpoint). + "ideogram-v4-5": "ideogram/v4.5", + "ideogram-v4-5/edit": "ideogram/v4.5/edit", // ── Image upscale models ──────────────────────────────────────────────── seedvr: "fal-ai/seedvr/upscale/image", "recraft-clarity": "fal-ai/recraft-clarity-upscale", @@ -280,6 +285,19 @@ export const IMAGE_MODELS: Record = { "aura-sr": "fal-ai/aura-sr", }; +// Ideogram 4.5 — fal schemas are additionalProperties:false, so unknown fields +// (e.g. `acceleration`, `aspect_ratio`, `image_urls`) are rejected with a 422. +// Edit takes ONE source `image_url` plus up to 4 `reference_image_urls`. +// Raw upstream ids are listed too: normalizeModelId leaves ids with "/" as-is. +const IDEOGRAM_IMAGE_MODELS_LIST = [ + "ideogram-v4-5", + "ideogram-v4-5/edit", + "ideogram/v4.5", + "ideogram/v4.5/edit", +]; +const IDEOGRAM_IMAGE_MODELS = new Set(IDEOGRAM_IMAGE_MODELS_LIST); +const IDEOGRAM_MAX_REFERENCES = 4; + // Models that use image_size instead of aspect_ratio const IMAGE_SIZE_MODELS = new Set([ "flux-schnell", @@ -297,6 +315,7 @@ const IMAGE_SIZE_MODELS = new Set([ "gpt-image-2-5/edit", "gpt-image-2-5-sunburst", "gpt-image-2-5-sunburst/edit", + ...IDEOGRAM_IMAGE_MODELS_LIST, ]); // Qwen Angles model - image-to-image with camera angle adjustment @@ -315,6 +334,69 @@ const SINGULAR_IMAGE_URL_MODELS = new Set([ "aura-sr", ]); +/** + * Put resolved file URLs on the fal input using the field layout the model + * expects. Mutates `input`. `id` is the normalized model id. + * - singular models (reve/edit, upscalers): image_url = urls[0] + * - Ideogram 4.5: image_url = urls[0], reference_image_urls = urls[1..4] + * - everything else: image_urls = urls + */ +export function applyImageFileInputs( + id: string, + input: Record, + urls: string[], +): void { + if (urls.length === 0) return; + if (IDEOGRAM_IMAGE_MODELS.has(id)) { + const refs = urls.slice(1); + if (refs.length > IDEOGRAM_MAX_REFERENCES) { + throw new Error( + `ideogram-v4-5 accepts 1 source image + up to ${IDEOGRAM_MAX_REFERENCES} references (${urls.length} files given)`, + ); + } + input.image_url = urls[0]; + if (refs.length > 0) input.reference_image_urls = refs; + return; + } + if (SINGULAR_IMAGE_URL_MODELS.has(id)) { + input.image_url = urls[0]; + return; + } + input.image_urls = urls; +} + +/** Whether the model accepts fal's `acceleration` field. */ +export function supportsImageAcceleration(id: string): boolean { + return !IDEOGRAM_IMAGE_MODELS.has(id); +} + +/** + * Resolve an image model id (SDK alias, prod canonical, raw fal id, or + * "raw:"-prefixed) to the fal endpoint, routing dual-endpoint families to + * their edit endpoint when files are present. + */ +export function resolveImageEndpoint( + modelId: string, + hasFiles?: boolean, +): string { + const id = normalizeModelId(modelId); + if (id.startsWith("raw:")) { + return id.slice(4); + } + + // Nano Banana 2: route to /edit when images are provided, base endpoint for t2i + if (id === "nano-banana-2" && hasFiles) { + return "fal-ai/nano-banana-2/edit"; + } + + // Ideogram 4.5: t2i takes no images at all — files always mean /edit + if ((id === "ideogram-v4-5" || id === "ideogram/v4.5") && hasFiles) { + return "ideogram/v4.5/edit"; + } + + return IMAGE_MODELS[id] ?? id; +} + // Map aspect ratio to image_size for Qwen Angles (base dimension 1024) const ASPECT_RATIO_TO_QWEN_SIZE: Record< string, @@ -1044,8 +1126,11 @@ class FalImageModel implements ImageModelV3 { } } else { input.prompt = prompt; - // Use high acceleration for faster queue processing on supported models (flux-schnell) - input.acceleration = "high"; + // Use high acceleration for faster queue processing on supported models + // (flux-schnell). Strict-schema models (Ideogram) reject the field. + if (supportsImageAcceleration(id)) { + input.acceleration = "high"; + } } const usesImageSize = IMAGE_SIZE_MODELS.has(id); @@ -1105,12 +1190,8 @@ class FalImageModel implements ImageModelV3 { if (hasFiles && files) { const fileHashes = await computeFileHashes(files); const imageUrls = await pMap(files, fileToUrl, { concurrency: 2 }); - // Reve uses singular image_url instead of image_urls array - if (SINGULAR_IMAGE_URL_MODELS.has(id)) { - input.image_url = imageUrls[0]; - } else { - input.image_urls = imageUrls; - } + // image_urls / image_url / image_url + reference_image_urls per model + applyImageFileInputs(id, input, imageUrls); // Compute stable key after files are resolved const finalEndpointForKey = this.resolveEndpoint(hasFiles); stableKey = JSON.stringify({ @@ -1182,17 +1263,7 @@ class FalImageModel implements ImageModelV3 { } private resolveEndpoint(hasFiles?: boolean): string { - const id = normalizeModelId(this.modelId); - if (id.startsWith("raw:")) { - return id.slice(4); - } - - // Nano Banana 2: route to /edit when images are provided, base endpoint for t2i - if (id === "nano-banana-2" && hasFiles) { - return "fal-ai/nano-banana-2/edit"; - } - - return IMAGE_MODELS[id] ?? id; + return resolveImageEndpoint(this.modelId, hasFiles); } } diff --git a/src/definitions/actions/image.ts b/src/definitions/actions/image.ts index c7efa179..65ef5ecc 100644 --- a/src/definitions/actions/image.ts +++ b/src/definitions/actions/image.ts @@ -26,7 +26,7 @@ const imageInputSchema = z.object({ .string() .optional() .describe( - "Provider-specific model name. For fal: gpt-image-2-5, gpt-image-2-5/edit, gpt-image-2-5-sunburst, gpt-image-2-5-sunburst/edit, mai-image-2-5, mai-image-2-5/edit, seedream-5-pro, seedream-5-pro/edit, nano-banana-pro, nano-banana-pro/edit, flux-pro, flux-dev, flux-schnell, seedream-v4.5/edit, qwen-image-2, qwen-image-2-pro, grok-imagine-image, phota, phota/edit, seedvr, recraft-clarity, topaz. For openai (native, via varg API): gpt-image-2.5-flare, gpt-image-2.5-sunburst, gpt-image-2, gpt-image-1.5, gpt-image-1, chatgpt-image-latest. For magnific: mystic (default), flux-2-pro, flux-2-turbo, flux-2-klein, flux-pro-v1.1, flux-dev, hyperflux, seedream-4, seedream-v4.5, runway-image.", + "Provider-specific model name. For fal: ideogram-v4-5, ideogram-v4-5/edit, gpt-image-2-5, gpt-image-2-5/edit, gpt-image-2-5-sunburst, gpt-image-2-5-sunburst/edit, mai-image-2-5, mai-image-2-5/edit, seedream-5-pro, seedream-5-pro/edit, nano-banana-pro, nano-banana-pro/edit, flux-pro, flux-dev, flux-schnell, seedream-v4.5/edit, qwen-image-2, qwen-image-2-pro, grok-imagine-image, phota, phota/edit, seedvr, recraft-clarity, topaz. For openai (native, via varg API): gpt-image-2.5-flare, gpt-image-2.5-sunburst, gpt-image-2, gpt-image-1.5, gpt-image-1, chatgpt-image-latest. For magnific: mystic (default), flux-2-pro, flux-2-turbo, flux-2-klein, flux-pro-v1.1, flux-dev, hyperflux, seedream-4, seedream-v4.5, runway-image.", ), }); diff --git a/src/definitions/models/ideogram-v4-5.ts b/src/definitions/models/ideogram-v4-5.ts new file mode 100644 index 00000000..395f7654 --- /dev/null +++ b/src/definitions/models/ideogram-v4-5.ts @@ -0,0 +1,135 @@ +/** + * Ideogram 4.5 + * Posters, logos and designs with accurate typography, plus prompt edits with + * reference images, an optional mask and a high-precision mode. + * fal endpoints: ideogram/v4.5 (text-to-image), ideogram/v4.5/edit. + */ + +import { z } from "zod"; +import { imageSizeSchema } from "../../core/schema/shared"; +import type { ModelDefinition, ZodSchema } from "../../core/schema/types"; + +const explicitSizeSchema = z.object({ + width: z.number().int().positive(), + height: z.number().int().positive(), +}); + +// t2i quality: low / medium / high. Edit adds very_low. +const qualitySchema = z.enum(["very_low", "low", "medium", "high"]); + +const ideogramV45InputSchema = z.object({ + prompt: z + .string() + .min(1) + .max(10000) + .describe("Text to render or the edit to apply"), + image_url: z + .string() + .url() + .optional() + .describe("Source image to edit. Omit for text-to-image."), + reference_image_urls: z + .array(z.string().url()) + .max(4) + .optional() + .describe("Edit only: up to 4 reference images (3 when a mask is used)"), + mask_url: z + .string() + .url() + .optional() + .describe( + "Edit only: mask matching the source size. Black is edited, white is preserved.", + ), + edit_precision: z + .enum(["regular", "high"]) + .default("regular") + .describe( + "Edit only: 'high' uses Precise Edit and restores unchanged pixels. Same price.", + ), + quality: qualitySchema + .default("medium") + .describe( + "Quality tier. very_low is edit-only. Price depends on quality, not size.", + ), + image_size: z + .union([imageSizeSchema, z.literal("auto"), explicitSizeSchema]) + .optional() + .describe( + "Output size. t2i default square_hd; edit default auto (keeps source size). 'auto' is edit-only. Explicit t2i sizes must be one of Ideogram's 36 supported sizes (e.g. 1248x832 for 3:2).", + ), + num_images: z + .number() + .int() + .min(1) + .max(8) + .default(1) + .describe("Number of images to generate (1-8)"), + enable_prompt_expansion: z + .boolean() + .default(true) + .describe("Text-to-image only: let Ideogram expand the prompt"), + seed: z.number().int().optional().describe("Random seed"), +}); + +const ideogramV45OutputSchema = z.object({ + images: z.array( + z.object({ + url: z.string(), + content_type: z.string().nullable().optional(), + file_name: z.string().nullable().optional(), + file_size: z.number().nullable().optional(), + }), + ), + seed: z.number(), +}); + +const schema: ZodSchema< + typeof ideogramV45InputSchema, + typeof ideogramV45OutputSchema +> = { + input: ideogramV45InputSchema, + output: ideogramV45OutputSchema, +}; + +// fal list (2026-09), per image. Size, edit_precision, references and mask do +// not change the price (verified via fal's x-fal-billable-units header). +const QUALITY_RATE_USD: Record = { + very_low: 0.008, + low: 0.03, + medium: 0.06, + high: 0.22, +}; + +export function calculateIdeogramV45Cost(params: { + numImages?: number; + providerOptions?: Record; +}): number { + const numImages = params.numImages ?? 1; + const quality = (params.providerOptions?.quality as string) ?? "medium"; + const rate = QUALITY_RATE_USD[quality] ?? QUALITY_RATE_USD.medium ?? 0.06; + return rate * numImages; +} + +export const definition: ModelDefinition = { + type: "model", + name: "ideogram-v4-5", + description: + "Ideogram 4.5 — posters, logos and designs with accurate text rendering. Text-to-image (low/medium/high quality) and editing with up to 4 reference images, an optional mask and a high-precision mode that keeps unchanged pixels. Price depends on quality only.", + providers: ["fal"], + defaultProvider: "fal", + providerModels: { + fal: "ideogram/v4.5", + }, + schema, + pricing: { + fal: { + description: + "Per image by quality: very_low $0.008 (edit only), low $0.03, medium $0.06 (default), high $0.22. Size, edit precision, references and mask don't change the price. num_images 1-8 → max $1.76.", + calculate: calculateIdeogramV45Cost, + minUsd: 0.008, // 1 edit at very_low + maxUsd: 1.76, // 8 images at high + }, + }, +}; + +export default definition; diff --git a/src/definitions/models/index.ts b/src/definitions/models/index.ts index cdffd3ee..c1d298e3 100644 --- a/src/definitions/models/index.ts +++ b/src/definitions/models/index.ts @@ -12,6 +12,7 @@ export { definition as elevenlabsTts } from "./elevenlabs"; export { definition as flux } from "./flux"; export { definition as gptImage25 } from "./gpt-image-2-5"; export { definition as heygenAvatar } from "./heygen"; +export { definition as ideogramV45 } from "./ideogram-v4-5"; export { definition as kling, kling4kDefinition as kling4k, @@ -68,6 +69,7 @@ import { definition as elevenlabsDefinition } from "./elevenlabs"; import { definition as fluxDefinition } from "./flux"; import { definition as gptImage25Definition } from "./gpt-image-2-5"; import { definition as heygenAvatarDefinition } from "./heygen"; +import { definition as ideogramV45Definition } from "./ideogram-v4-5"; import { kling4kDefinition, kling4kRefDefinition, @@ -116,6 +118,7 @@ export const allModels = [ klingV3MotionStdDefinition, fluxDefinition, gptImage25Definition, + ideogramV45Definition, nanoBananaProDefinition, nanoBanana2Definition, qwenImage2Definition, diff --git a/src/react/examples/async/native-audio-scene1.tsx b/src/react/examples/async/native-audio-scene1.tsx index 71f6153f..67b6abb2 100644 --- a/src/react/examples/async/native-audio-scene1.tsx +++ b/src/react/examples/async/native-audio-scene1.tsx @@ -25,7 +25,6 @@ // transcript are lazy graph nodes, cached by computeCacheKey like any op. // ═══════════════════════════════════════════════════════════════════════════ -import { createVarg } from "@vargai/gateway"; import { Captions, Clip, @@ -35,6 +34,7 @@ import { Video, type VideoElement, } from "vargai/react"; +import { createVarg } from "../../../ai-sdk/index"; const varg = createVarg({ apiKey: process.env.VARG_API_KEY! });