diff --git a/.changeset/gemini-image-ga-model-ids-and-per-model-sizes.md b/.changeset/gemini-image-ga-model-ids-and-per-model-sizes.md new file mode 100644 index 0000000000..13da4ccc4f --- /dev/null +++ b/.changeset/gemini-image-ga-model-ids-and-per-model-sizes.md @@ -0,0 +1,37 @@ +--- +'@tanstack/ai-gemini': minor +--- + +Add the GA Gemini native image model ids and give each native image model its own size type. + +`gemini-3.1-flash-image-preview` and `gemini-3-pro-image-preview` were shut down on 2026-06-25 and now 404. Their GA replacements — `gemini-3.1-flash-image` and `gemini-3-pro-image` — are now the primary ids. The `-preview` ids remain in the model union as aliases so existing code keeps compiling; `gemini-2.5-flash-image` stays fully supported ahead of its 2026-10-02 shutdown. + +Sizes were a single flat union (`{8 ratios}_{1K|2K|4K}`) applied to every native model. Google documents four different sets, so each model now maps to its own: + +| model | aspect ratios | resolutions | +| --------------------------------------- | ------------- | -------------------------------- | +| `gemini-3.1-flash-image` (+ `-preview`) | 14 | `512` `1K` `2K` `4K` | +| `gemini-3.1-flash-lite-image` | 14 | `1K` | +| `gemini-3-pro-image` (+ `-preview`) | 10 | `1K` `2K` `4K` | +| `gemini-2.5-flash-image` | 10 | none — bare ratio, e.g. `'16:9'` | + +`4:5` and `5:4` are now accepted on every native model (Google lists them for all four; the old union omitted them). `9:21` is deliberately still rejected — it exists on Vertex/Cloud only and the Gemini API rejects it. + +**Runtime behaviour changes in two places.** The rest of the change is types-only, but these two are real wire-format deltas: + +- `parseNativeImageSize()` now accepts a bare aspect ratio. Previously `'16:9'` failed to parse, so the adapter omitted `imageConfig` entirely and the model picked its own aspect ratio; it now parses to `{ aspectRatio: '16:9' }` and the adapter sends `imageConfig.aspectRatio = '16:9'`. A JavaScript caller — or a TypeScript caller whose `size` is computed at runtime and widened to `string` — that already passed a bare ratio will get a differently-framed image after upgrading, with no compile or runtime error. +- Migrating a `gemini-2.5-flash-image` call from `'16:9_1K'` to the now-required bare `'16:9'` drops `imageSize` from the `generateContent` request. That is intended: Google publishes no `image_size` value or default for this model, so the adapter no longer guesses a tier the API never documented. + +**BREAKING (types only):** size combinations the selected model never supported no longer compile. No model id was removed. + +- `gemini-3.1-flash-lite-image`: `2K` and `4K` are rejected (the model only emits 1K). Use `'_1K'`. +- `gemini-3-pro-image` / `gemini-3-pro-image-preview`: the extreme banner ratios `1:4` `4:1` `1:8` `8:1` are rejected (Gemini 3.1 Flash Image only), as is the `512` tier. +- `gemini-2.5-flash-image`: any `_1K` / `_2K` / `_4K` suffix is rejected — pass the bare ratio (`'16:9'`, not `'16:9_1K'`) — as are the four extreme banner ratios. +- `GeminiNativeImageSize` is now the union of the per-model types rather than one flat template literal. It was not previously reachable from the package entry point, so this is a new export rather than a changed one. + +**New type exports**, so the per-model narrowing is nameable and not just inferred at the call site: `GeminiImageModelSizeByName`, `GeminiStandardImageAspectRatio`, `GeminiExtendedImageAspectRatio`, `Gemini31FlashImageSize`, `Gemini31FlashLiteImageSize`, `Gemini3ProImageSize`, `Gemini25FlashImageSize`, `GeminiNativeImageSize`. + +**Two caveats worth knowing before you rely on this.** + +- _No in-editor deprecation warning on the dead `-preview` ids._ The `@deprecated` tags live on module-private model-metadata consts, and `GeminiImageModels` is projected out of a const array (`(typeof GEMINI_IMAGE_MODELS)[number]`), which collapses to bare string literals — JSDoc does not survive that projection. So `geminiImage('gemini-3-pro-image-preview')` still compiles cleanly with no strikethrough and no hint, and fails only at request time. Grep your codebase for `-image-preview` rather than expecting the compiler to flag it. +- _`gemini-3.1-flash-lite-image`'s four extreme ratios (`1:4` `4:1` `1:8` `8:1`) are partially inferred._ Unlike the other three native models, Flash Lite has no per-model ratio table on the Gemini API guide. The 14-value set rests on the Cloud model page's explicit enumeration plus `ai.google.dev`'s bare "a discrete set of 14 aspect ratios" assertion; the only Gemini-API enumeration for this model is a 10-item bullet prefixed "New aspect ratios", read here as a what's-new list rather than an exhaustive set. If the API rejects those four in practice, this type over-accepts and should narrow to the 10-ratio set. diff --git a/docs/adapters/gemini.md b/docs/adapters/gemini.md index 31da9cf6e2..54298b152d 100644 --- a/docs/adapters/gemini.md +++ b/docs/adapters/gemini.md @@ -411,7 +411,7 @@ See the [Embeddings guide](../embeddings.md) for the full API. The Gemini adapter supports two types of image generation: -- **Gemini native image models** (NanoBanana) — Use the `generateContent` API with models like `gemini-3.1-flash-image-preview`. These support extended resolution tiers (1K, 2K, 4K) and aspect ratio control. +- **Gemini native image models** (NanoBanana) — Use the `generateContent` API with models like `gemini-3.1-flash-image`. These support aspect ratio control plus resolution tiers (`512`, `1K`, `2K`, `4K`); which ratios and tiers are accepted varies per model and is enforced at compile time. - **Imagen models** — Use the `generateImages` API with models like `imagen-4.0-generate-001`. These are dedicated image generation models with WIDTHxHEIGHT sizing. The adapter automatically routes to the correct API based on the model name — models starting with `gemini-` use `generateContent`, while `imagen-` models use `generateImages`. @@ -425,7 +425,7 @@ import { generateImage } from "@tanstack/ai"; import { geminiImage } from "@tanstack/ai-gemini"; const result = await generateImage({ - adapter: geminiImage("gemini-3.1-flash-image-preview"), + adapter: geminiImage("gemini-3.1-flash-image"), prompt: "A futuristic cityscape at sunset", numberOfImages: 1, size: "16:9_4K", @@ -462,10 +462,18 @@ size: "1:1_2K" size: "9:16_1K" ``` -| Component | Values | -|-----------|--------| -| Aspect Ratio | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `9:16`, `16:9`, `21:9` | -| Resolution | `1K`, `2K`, `4K` | +The accepted set differs per model, and each model's `size` is narrowed to its own set at compile time: + +| Model | Aspect ratios | Resolutions | +|-------|---------------|-------------| +| `gemini-3.1-flash-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`, `1:4`, `4:1`, `1:8`, `8:1` | `512`, `1K`, `2K`, `4K` | +| `gemini-3.1-flash-lite-image` | same 14 as above (see note) | `1K` only | +| `gemini-3-pro-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` | `1K`, `2K`, `4K` | +| `gemini-2.5-flash-image` | same 10 as above | none — pass the bare ratio, e.g. `size: "16:9"` | + +The `K` is case-sensitive (`1k` is rejected by the API), the smallest tier's token is `512` (not `512px` or `0.5K`), and `9:21` is Vertex/Cloud-only so it is not accepted here. Google documents no `image_size` for `gemini-2.5-flash-image`, so that model takes a bare aspect ratio and the adapter sends no `imageSize`. + +> **Note on `gemini-3.1-flash-lite-image`.** The four extreme banner ratios (`1:4`, `4:1`, `1:8`, `8:1`) are partially inferred for this model. Unlike the other three, Flash Lite has no per-model ratio table in Google's Gemini API guide; the 14-value set comes from the Cloud model page's explicit enumeration plus the guide's bare "a discrete set of 14 aspect ratios" assertion. The only Gemini-API enumeration for it is a 10-item bullet headed "New aspect ratios", which we read as a what's-new list rather than an exhaustive one. If the API rejects those four in practice, prefer the 10 standard ratios on this model. #### Imagen Models @@ -547,14 +555,16 @@ GOOGLE_API_KEY=your-api-key-here ### Gemini Native Image Models (NanoBanana) -These models use the `generateContent` API and support resolution tiers (1K, 2K, 4K). +These models use the `generateContent` API and support per-model resolution tiers. | Model | Description | |-------|-------------| -| `gemini-3.1-flash-image-preview` | Latest and fastest Gemini native image generation | -| `gemini-3.1-flash-lite-image` | Nano Banana 2 Lite — ultra-low-latency, low-cost image generation | -| `gemini-3-pro-image-preview` | Higher quality Gemini native image generation | -| `gemini-2.5-flash-image` | Gemini 2.5 Flash with image generation | +| `gemini-3.1-flash-image` | Nano Banana 2 — latest and fastest Gemini native image generation (512/1K/2K/4K) | +| `gemini-3.1-flash-lite-image` | Nano Banana 2 Lite — ultra-low-latency, low-cost image generation (1K only) | +| `gemini-3-pro-image` | Nano Banana Pro — higher quality Gemini native image generation (1K/2K/4K) | +| `gemini-2.5-flash-image` | Nano Banana — legacy; shuts down 2026-10-02. Takes a bare aspect ratio | + +The `gemini-3.1-flash-image-preview` and `gemini-3-pro-image-preview` ids were shut down on 2026-06-25. They remain in the type union as deprecated aliases so existing code compiles, but calls to them fail — use the GA ids above. ### Imagen Models diff --git a/docs/advanced/runtime-adapter-switching.md b/docs/advanced/runtime-adapter-switching.md index e5c6189c98..8ac2cf25c1 100644 --- a/docs/advanced/runtime-adapter-switching.md +++ b/docs/advanced/runtime-adapter-switching.md @@ -116,7 +116,7 @@ export const Route = createFileRoute('/api/chat')({ ## Using with Image Adapters -The same pattern works for image generation: +The same pattern works for image generation. Unlike the text and summarize adapters above, image adapters don't all accept the same shape of `size` — so it travels alongside its adapter in the provider map instead of being passed once for every branch: ```typescript import { generateImage } from '@tanstack/ai' @@ -125,25 +125,28 @@ import { geminiImage } from '@tanstack/ai-gemini' type ImageProvider = 'openai' | 'gemini' -const imageAdapters: Record ReturnType> = { - openai: () => openaiImage('gpt-image-2'), - gemini: () => geminiImage('gemini-3.1-flash-image-preview'), +const imageAdapters = { + openai: () => ({ adapter: openaiImage('gpt-image-2'), size: '1024x1024' as const }), + gemini: () => ({ adapter: geminiImage('gemini-3.1-flash-image'), size: '16:9_4K' as const }), } export async function POST(request: Request) { const body = await request.json() const provider: ImageProvider = body.provider ?? 'openai' + const { adapter, size } = imageAdapters[provider]() const result = await generateImage({ - adapter: imageAdapters[provider](), + adapter, prompt: 'A beautiful sunset over mountains', - size: '1024x1024', + size, }) return Response.json(result) } ``` +`size` is provider-specific, which is why it can't be a single literal shared across branches: Gemini native image models take a `'_'` string (e.g. `'16:9_4K'`), while OpenAI and Imagen models take pixel dimensions (e.g. `'1024x1024'`). + ## Using with Summarize Adapters And for summarization: diff --git a/docs/config.json b/docs/config.json index e5c98bc877..3789654fb3 100644 --- a/docs/config.json +++ b/docs/config.json @@ -437,7 +437,7 @@ "label": "Image Generation", "to": "media/image-generation", "addedAt": "2026-04-15", - "updatedAt": "2026-08-13" + "updatedAt": "2026-08-14" }, { "label": "Video Generation", @@ -671,7 +671,8 @@ { "label": "Runtime Adapter Switching", "to": "advanced/runtime-adapter-switching", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-08-14" }, { "label": "Tree-Shaking", @@ -796,7 +797,7 @@ "label": "Google Gemini", "to": "adapters/gemini", "addedAt": "2026-04-15", - "updatedAt": "2026-07-22" + "updatedAt": "2026-08-14" }, { "label": "Ollama", diff --git a/docs/media/image-generation.md b/docs/media/image-generation.md index c1312e8122..6fed5f85b3 100644 --- a/docs/media/image-generation.md +++ b/docs/media/image-generation.md @@ -54,7 +54,7 @@ import { geminiImage } from "@tanstack/ai-gemini"; // Gemini native model (NanoBanana) — uses generateContent API const result = await generateImage({ - adapter: geminiImage("gemini-3.1-flash-image-preview"), + adapter: geminiImage("gemini-3.1-flash-image"), prompt: "A futuristic cityscape at night", size: "16:9_4K", }); @@ -121,19 +121,27 @@ All image adapters support these common options: #### Gemini Native Models (NanoBanana) -Gemini native image models use a template literal size format: `"aspectRatio_resolution"`. +Gemini native image models use a template literal size format: `"aspectRatio_resolution"`. Each model accepts its own set, narrowed at compile time: -| Aspect Ratios | Resolutions | -| --------------------------------------------------------- | ---------------- | -| `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `9:16`, `16:9`, `21:9` | `1K`, `2K`, `4K` | +| Model | Aspect Ratios | Resolutions | +| ----------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------- | +| `gemini-3.1-flash-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`, `1:4`, `4:1`, `1:8`, `8:1` | `512`, `1K`, `2K`, `4K` | +| `gemini-3.1-flash-lite-image` | same 14 as above (see note) | `1K` | +| `gemini-3-pro-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` | `1K`, `2K`, `4K` | +| `gemini-2.5-flash-image` | same 10 as above | none — bare ratio | ```typescript ignore // Examples size: "16:9_4K"; // Widescreen at 4K resolution size: "1:1_2K"; // Square at 2K resolution -size: "9:16_1K"; // Portrait at 1K resolution +size: "1:8_512"; // Tall banner at the 512 (0.5K) tier — Flash Image only +size: "16:9"; // gemini-2.5-flash-image: bare ratio, no resolution suffix ``` +The `K` is case-sensitive (`1k` is rejected by the API), the smallest tier's token is `512` (not `512px` or `0.5K`), and `9:21` is Vertex/Cloud-only so it is not accepted. Google documents no `image_size` for `gemini-2.5-flash-image`, so that model takes a bare aspect ratio and the adapter sends no `imageSize`. + +> **Note on `gemini-3.1-flash-lite-image`.** The four extreme banner ratios (`1:4`, `4:1`, `1:8`, `8:1`) are partially inferred for this model. Unlike the other three, Flash Lite has no per-model ratio table in Google's Gemini API guide; the 14-value set comes from the Cloud model page's explicit enumeration plus the guide's bare "a discrete set of 14 aspect ratios" assertion. The only Gemini-API enumeration for it is a 10-item bullet headed "New aspect ratios", which we read as a what's-new list rather than an exhaustive one. If the API rejects those four in practice, prefer the 10 standard ratios on this model. + #### Gemini Imagen Models Imagen models accept WIDTHxHEIGHT format, which maps to aspect ratios internally: @@ -191,7 +199,7 @@ import { geminiImage } from "@tanstack/ai-gemini"; import { badExampleUrl, goodExampleUrl } from "./urls"; await generateImage({ - adapter: geminiImage("gemini-3.1-flash-image-preview"), + adapter: geminiImage("gemini-3.1-flash-image"), prompt: [ { type: "text", content: "Not like this" }, { type: "image", source: { type: "url", value: badExampleUrl } }, @@ -334,7 +342,7 @@ import { generateImage } from "@tanstack/ai"; import { geminiImage } from "@tanstack/ai-gemini"; await generateImage({ - adapter: geminiImage("gemini-3.1-flash-image-preview"), + adapter: geminiImage("gemini-3.1-flash-image"), prompt: [ { type: "text", @@ -641,7 +649,7 @@ import { generateImage } from "@tanstack/ai"; import { geminiImage } from "@tanstack/ai-gemini"; const result = await generateImage({ - adapter: geminiImage("gemini-3.1-flash-image-preview"), + adapter: geminiImage("gemini-3.1-flash-image"), prompt: "A beautiful garden", size: "16:9_4K", }); @@ -709,12 +717,14 @@ if (result.usage?.unitsBilled != null) { #### Gemini Native Models (NanoBanana) -| Model | Description | -| -------------------------------- | ----------------------------------------------------------------- | -| `gemini-3.1-flash-image-preview` | Latest and fastest Gemini native image generation | -| `gemini-3.1-flash-lite-image` | Nano Banana 2 Lite — ultra-low-latency, low-cost image generation | -| `gemini-3-pro-image-preview` | Higher quality Gemini native image generation | -| `gemini-2.5-flash-image` | Gemini 2.5 Flash with image generation | +| Model | Description | +| ----------------------------- | -------------------------------------------------------------------------- | +| `gemini-3.1-flash-image` | Nano Banana 2 — latest and fastest Gemini native image generation | +| `gemini-3.1-flash-lite-image` | Nano Banana 2 Lite — ultra-low-latency, low-cost image generation | +| `gemini-3-pro-image` | Nano Banana Pro — higher quality Gemini native image generation | +| `gemini-2.5-flash-image` | Nano Banana — legacy; shuts down 2026-10-02 | + +The `gemini-3.1-flash-image-preview` and `gemini-3-pro-image-preview` ids were shut down on 2026-06-25. They remain in the type union as deprecated aliases so existing code compiles, but calls to them fail — use the GA ids above. #### Gemini Imagen Models diff --git a/examples/ts-react-chat/src/routes/image-gen.tsx b/examples/ts-react-chat/src/routes/image-gen.tsx index c891fd1464..d106f65f49 100644 --- a/examples/ts-react-chat/src/routes/image-gen.tsx +++ b/examples/ts-react-chat/src/routes/image-gen.tsx @@ -27,12 +27,12 @@ const IMAGE_MODELS = [ label: 'Gemini 2.5 Flash Image', }, { - value: 'google/gemini-2.5-flash-image-preview', - label: 'Gemini 2.5 Flash Image Preview', + value: 'google/gemini-3.1-flash-image', + label: 'Gemini 3.1 Flash Image (Nano Banana 2)', }, { - value: 'google/gemini-3-pro-image-preview', - label: 'Gemini 3 Pro Image Preview', + value: 'google/gemini-3-pro-image', + label: 'Gemini 3 Pro Image (Nano Banana Pro)', }, ] as const diff --git a/examples/ts-react-media/src/lib/models.ts b/examples/ts-react-media/src/lib/models.ts index 633f8da8bd..8ab505947c 100644 --- a/examples/ts-react-media/src/lib/models.ts +++ b/examples/ts-react-media/src/lib/models.ts @@ -48,7 +48,7 @@ export const IMAGE_MODELS = [ provider: 'fal' as const, }, { - id: 'gemini-3.1-flash-image-preview', + id: 'gemini-3.1-flash-image', name: 'NanoBanana 2 (Gemini 3.1 Flash)', description: 'Latest and fastest Gemini native image generation', defaultSize: '16:9_4K' as const, @@ -56,7 +56,7 @@ export const IMAGE_MODELS = [ provider: 'gemini' as const, }, { - id: 'gemini-3-pro-image-preview', + id: 'gemini-3-pro-image', name: 'NanoBanana Pro (Gemini 3 Pro)', description: 'Higher quality Gemini native image generation', defaultSize: '16:9_4K' as const, diff --git a/examples/ts-react-media/src/lib/server-functions.ts b/examples/ts-react-media/src/lib/server-functions.ts index aa0f12c07a..717f3e1c47 100644 --- a/examples/ts-react-media/src/lib/server-functions.ts +++ b/examples/ts-react-media/src/lib/server-functions.ts @@ -196,17 +196,17 @@ export const generateImageFn = createServerFn({ method: 'POST' }) }, }) } - case 'gemini-3.1-flash-image-preview': { + case 'gemini-3.1-flash-image': { return generateImage({ - adapter: geminiImage('gemini-3.1-flash-image-preview'), + adapter: geminiImage('gemini-3.1-flash-image'), prompt: asImagePrompt(data.prompt), numberOfImages: 1, size: '16:9_4K', }) } - case 'gemini-3-pro-image-preview': { + case 'gemini-3-pro-image': { return generateImage({ - adapter: geminiImage('gemini-3-pro-image-preview'), + adapter: geminiImage('gemini-3-pro-image'), prompt: asImagePrompt(data.prompt), numberOfImages: 1, size: '16:9_4K', diff --git a/packages/ai-gemini/src/image/image-provider-options.ts b/packages/ai-gemini/src/image/image-provider-options.ts index 84fd66da76..951a60993e 100644 --- a/packages/ai-gemini/src/image/image-provider-options.ts +++ b/packages/ai-gemini/src/image/image-provider-options.ts @@ -145,47 +145,119 @@ export type GeminiImageSize = | '1080x1920' /** - * Aspect ratios supported by Gemini native image models (via generateContent API). - * Matches the SDK's ImageConfig.aspectRatio values. + * The ten aspect ratios every Gemini native image model accepts. + * + * Note `9:21` is deliberately absent: it exists only on Vertex / Cloud and is + * rejected by the Gemini API (`generateContent`), which is the surface this + * adapter targets. + * + * @see https://ai.google.dev/gemini-api/docs/image-generation */ -export type GeminiNativeImageAspectRatio = +export type GeminiStandardImageAspectRatio = | '1:1' | '2:3' | '3:2' | '3:4' | '4:3' + | '4:5' + | '5:4' | '9:16' | '16:9' | '21:9' /** - * Resolution tiers for Gemini native image models. - * Matches the SDK's ImageConfig.imageSize values. + * The ten standard ratios plus the four extreme banner/strip ratios that only + * the Gemini 3.1 Flash Image models accept — 14 values, matching the + * `generateContent` `ImageConfig.aspectRatio` field union. + * + * @see https://ai.google.dev/api/generate-content + */ +export type GeminiExtendedImageAspectRatio = + | GeminiStandardImageAspectRatio + | '1:4' + | '4:1' + | '1:8' + | '8:1' + +/** + * Sizes for `gemini-3.1-flash-image` (and its shut-down `-preview` alias): + * all 14 aspect ratios at 512 / 1K / 2K / 4K. `512` is the wire token for the + * 0.5K tier — not `512px`, and the `K` is case-sensitive (`1k` is rejected). + */ +export type Gemini31FlashImageSize = + `${GeminiExtendedImageAspectRatio}_${'512' | '1K' | '2K' | '4K'}` + +/** + * Sizes for `gemini-3.1-flash-lite-image`: all 14 aspect ratios, 1K only — + * 2K and 4K are explicitly unsupported on this model. + * + * Caveat on the ratio set: the four extreme banner ratios (`1:4`, `4:1`, + * `1:8`, `8:1`) are the one partially-inferred cell in this file. Unlike the + * other three native models, Flash Lite has no per-model ratio table on the + * Gemini API guide. The 14 rest on the Cloud model page's explicit 14-value + * enumeration plus `ai.google.dev`'s bare assertion that the model "supports a + * discrete set of 14 aspect ratios"; the only Gemini-API enumeration for it is + * a 10-item bullet prefixed "New aspect ratios", read here as a what's-new + * list rather than an exhaustive set. If a live probe shows the extremes are + * rejected, narrow this to {@link GeminiStandardImageAspectRatio}. + * + * @see https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/3-1-flash-lite-image + * @see https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite-image + */ +export type Gemini31FlashLiteImageSize = `${GeminiExtendedImageAspectRatio}_1K` + +/** + * Sizes for `gemini-3-pro-image` (and its shut-down `-preview` alias): the ten + * standard aspect ratios at 1K / 2K / 4K. Pro has no 512 tier and none of the + * extreme banner ratios on the Gemini API. + */ +export type Gemini3ProImageSize = + `${GeminiStandardImageAspectRatio}_${'1K' | '2K' | '4K'}` + +/** + * Sizes for `gemini-2.5-flash-image`: a bare aspect ratio with no resolution + * suffix, e.g. `'16:9'`. Google documents no `image_size` value or default for + * this model — it emits a single fixed 1024px-class output — so the adapter + * sends `imageConfig.aspectRatio` and omits `imageSize` entirely rather than + * guessing a tier the API never documented. */ -export type GeminiNativeImageResolution = '1K' | '2K' | '4K' +export type Gemini25FlashImageSize = GeminiStandardImageAspectRatio /** - * Template literal size type for Gemini native image models: "16:9_4K", "1:1_2K", etc. + * Any size accepted by any Gemini native image model. Prefer the per-model + * narrowing in {@link GeminiImageModelSizeByName} — this union is the widest + * possible set and accepts combinations no single model supports. */ export type GeminiNativeImageSize = - `${GeminiNativeImageAspectRatio}_${GeminiNativeImageResolution}` + | Gemini31FlashImageSize + | Gemini31FlashLiteImageSize + | Gemini3ProImageSize + | Gemini25FlashImageSize /** * Gemini native image models that use the generateContent API path. - * These models support template literal sizes (aspectRatio_resolution). + * These models take an aspect-ratio-based size rather than Imagen's + * WIDTHxHEIGHT pixel strings. */ export type GeminiNativeImageModels = + | 'gemini-3.1-flash-image' | 'gemini-3.1-flash-image-preview' | 'gemini-3.1-flash-lite-image' + | 'gemini-3-pro-image' | 'gemini-3-pro-image-preview' | 'gemini-2.5-flash-image' /** - * Model-specific size options mapping. - * Gemini native image models use template literal sizes, Imagen models use pixel sizes. + * Model-specific size options mapping. Each native model gets its own ratio × + * resolution set (they genuinely differ); Imagen models use pixel sizes. */ export type GeminiImageModelSizeByName = { - [K in GeminiNativeImageModels]: GeminiNativeImageSize + 'gemini-3.1-flash-image': Gemini31FlashImageSize + 'gemini-3.1-flash-image-preview': Gemini31FlashImageSize + 'gemini-3.1-flash-lite-image': Gemini31FlashLiteImageSize + 'gemini-3-pro-image': Gemini3ProImageSize + 'gemini-3-pro-image-preview': Gemini3ProImageSize + 'gemini-2.5-flash-image': Gemini25FlashImageSize } & { [K in Exclude]: GeminiImageSize } @@ -309,13 +381,23 @@ export function validatePrompt(options: { /** * Parses a Gemini native image size string into its components. - * Format: "aspectRatio_resolution" e.g. "16:9_4K" → { aspectRatio: "16:9", resolution: "4K" } + * + * Format: `"aspectRatio_resolution"`, e.g. `"16:9_4K"` → + * `{ aspectRatio: "16:9", resolution: "4K" }`. + * + * The resolution suffix is optional: `gemini-2.5-flash-image` takes a bare + * aspect ratio (`"16:9"` → `{ aspectRatio: "16:9" }`) because Google documents + * no `image_size` for it, and the caller must then omit `imageSize` from the + * request rather than substituting a default. */ export function parseNativeImageSize( size: string, -): { aspectRatio: string; resolution: string } | undefined { - const match = size.match(/^(\d+:\d+)_(.+)$/) +): { aspectRatio: string; resolution?: string } | undefined { + const match = size.match(/^(\d+:\d+)(?:_(.+))?$/) const [, aspectRatio, resolution] = match ?? [] - if (aspectRatio === undefined || resolution === undefined) return undefined - return { aspectRatio, resolution } + if (aspectRatio === undefined) return undefined + return { + aspectRatio, + ...(resolution !== undefined && { resolution }), + } } diff --git a/packages/ai-gemini/src/index.ts b/packages/ai-gemini/src/index.ts index e0c58922c0..f7ed26ceab 100644 --- a/packages/ai-gemini/src/index.ts +++ b/packages/ai-gemini/src/index.ts @@ -30,6 +30,18 @@ export type { GeminiImageProviderOptions, GeminiImageModelProviderOptionsByName, GeminiAspectRatio, + // Per-model size narrowing. `GeminiImageModelSizeByName` is the map + // `generateImage()` applies at the call site; the per-model aliases let you + // name a single model's set directly. `GeminiNativeImageSize` is the widest + // union across all native models — prefer the narrower types above it. + GeminiImageModelSizeByName, + GeminiStandardImageAspectRatio, + GeminiExtendedImageAspectRatio, + Gemini31FlashImageSize, + Gemini31FlashLiteImageSize, + Gemini3ProImageSize, + Gemini25FlashImageSize, + GeminiNativeImageSize, // Re-export SDK types for convenience PersonGeneration, SafetyFilterLevel, diff --git a/packages/ai-gemini/src/model-meta.ts b/packages/ai-gemini/src/model-meta.ts index 1ec09c5a86..aae4957c5f 100644 --- a/packages/ai-gemini/src/model-meta.ts +++ b/packages/ai-gemini/src/model-meta.ts @@ -118,7 +118,46 @@ const GEMINI_3_FLASH = { GeminiThinkingOptions > +/** + * Gemini 3 Pro Image ("Nano Banana Pro") — GA. Accepts the ten standard + * aspect ratios at 1K / 2K / 4K. + * @see https://ai.google.dev/gemini-api/docs/models/gemini-3-pro-image + */ const GEMINI_3_PRO_IMAGE = { + name: 'gemini-3-pro-image', + max_input_tokens: 65_536, + max_output_tokens: 32_768, + knowledge_cutoff: '2025-01-01', + supports: { + input: ['text', 'image'], + output: ['text', 'image'], + capabilities: ['batch_api', 'structured_output', 'thinking'], + tools: ['google_search'], + }, + pricing: { + input: { + normal: 2, + }, + output: { + normal: 0.134, + }, + }, +} as const satisfies ModelMeta< + GeminiToolConfigOptions & + GeminiSafetyOptions & + GeminiCommonConfigOptions & + GeminiCachedContentOptions & + GeminiStructuredOutputOptions & + GeminiThinkingOptions +> + +/** + * @deprecated `gemini-3-pro-image-preview` was shut down on 2026-06-25. Use + * the GA id `gemini-3-pro-image` instead — the preview id now 404s. + * Kept in the model union so existing code still compiles. + * @see https://ai.google.dev/gemini-api/docs/deprecations + */ +const GEMINI_3_PRO_IMAGE_PREVIEW = { name: 'gemini-3-pro-image-preview', max_input_tokens: 65_536, max_output_tokens: 32_768, @@ -146,7 +185,47 @@ const GEMINI_3_PRO_IMAGE = { GeminiThinkingOptions > +/** + * Gemini 3.1 Flash Image ("Nano Banana 2") — GA. The only native image model + * that accepts the four extreme banner ratios (1:4, 4:1, 1:8, 8:1) and the + * 512 (0.5K) resolution tier. + * @see https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-image + */ const GEMINI_3_1_FLASH_IMAGE = { + name: 'gemini-3.1-flash-image', + max_input_tokens: 65_536, + max_output_tokens: 65_536, + knowledge_cutoff: '2025-01-01', + supports: { + input: ['text', 'image'], + output: ['text', 'image'], + capabilities: ['batch_api', 'structured_output', 'thinking'], + tools: ['google_search'], + }, + pricing: { + input: { + normal: 0.25, + }, + output: { + normal: 1.5, + }, + }, +} as const satisfies ModelMeta< + GeminiToolConfigOptions & + GeminiSafetyOptions & + GeminiCommonConfigOptions & + GeminiCachedContentOptions & + GeminiStructuredOutputOptions & + GeminiThinkingOptions +> + +/** + * @deprecated `gemini-3.1-flash-image-preview` was shut down on 2026-06-25. + * Use the GA id `gemini-3.1-flash-image` instead — the preview id now 404s. + * Kept in the model union so existing code still compiles. + * @see https://ai.google.dev/gemini-api/docs/deprecations + */ +const GEMINI_3_1_FLASH_IMAGE_PREVIEW = { name: 'gemini-3.1-flash-image-preview', max_input_tokens: 65_536, max_output_tokens: 65_536, @@ -174,6 +253,10 @@ const GEMINI_3_1_FLASH_IMAGE = { GeminiThinkingOptions > +/** + * Gemini 3.1 Flash Lite Image ("Nano Banana 2 Lite") — GA. 1K output only. + * @see https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite-image + */ const GEMINI_3_1_FLASH_LITE_IMAGE = { name: 'gemini-3.1-flash-lite-image', max_input_tokens: 65_536, @@ -375,6 +458,17 @@ const GEMINI_2_5_FLASH = { GeminiThinkingOptions > +/** + * Gemini 2.5 Flash Image ("Nano Banana") — still GA, but documented as the + * legacy member of the family. Google publishes no `image_size` value for it, + * so its size type is a bare aspect ratio and the adapter sends no + * `imageConfig.imageSize`. + * @deprecated `gemini-2.5-flash-image` shuts down on 2026-10-02. Migrate to + * `gemini-3.1-flash-lite-image` (cheapest successor) or + * `gemini-3.1-flash-image`. Google's deprecations table still names the + * already-dead `gemini-3.1-flash-image-preview` as the replacement. + * @see https://ai.google.dev/gemini-api/docs/deprecations + */ const GEMINI_2_5_FLASH_IMAGE = { name: 'gemini-2.5-flash-image', max_input_tokens: 1_048_576, @@ -948,6 +1042,11 @@ export type GeminiModels = (typeof GEMINI_MODELS)[number] export type GeminiImageModels = (typeof GEMINI_IMAGE_MODELS)[number] +/** + * Image generation models. GA ids come first; the trailing `-preview` ids are + * shut-down aliases kept only so existing code keeps compiling — new code + * should use the GA id above its alias. + */ export const GEMINI_IMAGE_MODELS = [ GEMINI_3_1_FLASH_IMAGE.name, GEMINI_3_1_FLASH_LITE_IMAGE.name, @@ -956,6 +1055,9 @@ export const GEMINI_IMAGE_MODELS = [ IMAGEN_4_GENERATE.name, IMAGEN_4_GENERATE_FAST.name, IMAGEN_4_GENERATE_ULTRA.name, + // Deprecated aliases — shut down 2026-06-25. + GEMINI_3_1_FLASH_IMAGE_PREVIEW.name, + GEMINI_3_PRO_IMAGE_PREVIEW.name, ] as const /** diff --git a/packages/ai-gemini/tests/image-adapter.test.ts b/packages/ai-gemini/tests/image-adapter.test.ts index 7f07bb04f0..d503c61d7e 100644 --- a/packages/ai-gemini/tests/image-adapter.test.ts +++ b/packages/ai-gemini/tests/image-adapter.test.ts @@ -152,11 +152,34 @@ describe('Gemini Image Adapter', () => { }) }) + it('parses the per-model sizes, including a bare ratio with no resolution', () => { + // 4:5 / 5:4 are documented for all four native models but were missing + // from the old shared 8-ratio union; "512" is the wire token for the + // 0.5K tier — not "512px", not "0.5K". + expect(parseNativeImageSize('4:5_2K')).toEqual({ + aspectRatio: '4:5', + resolution: '2K', + }) + expect(parseNativeImageSize('5:4_1K')).toEqual({ + aspectRatio: '5:4', + resolution: '1K', + }) + expect(parseNativeImageSize('1:8_512')).toEqual({ + aspectRatio: '1:8', + resolution: '512', + }) + // gemini-2.5-flash-image has no documented image_size, so its sizes are + // bare ratios: the resolution is absent, not defaulted. + expect(parseNativeImageSize('16:9')).toEqual({ aspectRatio: '16:9' }) + expect(parseNativeImageSize('16:9')?.resolution).toBeUndefined() + }) + it('returns undefined for invalid formats', () => { expect(parseNativeImageSize('1024x1024')).toBeUndefined() expect(parseNativeImageSize('invalid')).toBeUndefined() - expect(parseNativeImageSize('16:9')).toBeUndefined() expect(parseNativeImageSize('4K')).toBeUndefined() + // A trailing separator with no resolution is not a bare ratio. + expect(parseNativeImageSize('16:9_')).toBeUndefined() }) }) @@ -340,7 +363,8 @@ describe('Gemini Image Adapter', () => { const result = await generateImage({ adapter, prompt: 'A red circle', - size: '1:1_2K', + // Flash Lite is 1K-only; 2K/4K are rejected at compile time. + size: '1:1_1K', }) expect(mockGenerateContent).toHaveBeenCalledWith({ @@ -350,7 +374,7 @@ describe('Gemini Image Adapter', () => { responseModalities: ['TEXT', 'IMAGE'], imageConfig: { aspectRatio: '1:1', - imageSize: '2K', + imageSize: '1K', }, }, }) @@ -360,6 +384,173 @@ describe('Gemini Image Adapter', () => { expect(result.images[0]!.b64Json).toBe('lite-base64-image') }) + it('routes the GA id gemini-3.1-flash-image through generateContent and sends the 512 tier', async () => { + const mockResponse = { + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: 'image/png', + data: 'ga-flash-image', + }, + }, + ], + }, + }, + ], + } + + const mockGenerateContent = vi.fn().mockResolvedValueOnce(mockResponse) + const mockGenerateImages = vi.fn() + + const adapter = createGeminiImage( + 'gemini-3.1-flash-image', + 'test-api-key', + ) + ;( + adapter as unknown as { + client: { + models: { generateContent: unknown; generateImages: unknown } + } + } + ).client = { + models: { + generateContent: mockGenerateContent, + generateImages: mockGenerateImages, + }, + } + + const result = await generateImage({ + adapter, + prompt: 'A tall banner', + size: '1:8_512', + }) + + expect(mockGenerateContent).toHaveBeenCalledWith({ + model: 'gemini-3.1-flash-image', + contents: 'A tall banner', + config: { + responseModalities: ['TEXT', 'IMAGE'], + imageConfig: { + aspectRatio: '1:8', + imageSize: '512', + }, + }, + }) + // Native models must never take the Imagen generateImages path. + expect(mockGenerateImages).not.toHaveBeenCalled() + expect(result.images[0]!.b64Json).toBe('ga-flash-image') + }) + + it('routes the GA id gemini-3-pro-image through generateContent', async () => { + const mockResponse = { + candidates: [ + { + content: { + parts: [ + { + inlineData: { mimeType: 'image/png', data: 'ga-pro-image' }, + }, + ], + }, + }, + ], + } + + const mockGenerateContent = vi.fn().mockResolvedValueOnce(mockResponse) + const mockGenerateImages = vi.fn() + + const adapter = createGeminiImage('gemini-3-pro-image', 'test-api-key') + ;( + adapter as unknown as { + client: { + models: { generateContent: unknown; generateImages: unknown } + } + } + ).client = { + models: { + generateContent: mockGenerateContent, + generateImages: mockGenerateImages, + }, + } + + const result = await generateImage({ + adapter, + prompt: 'A portrait', + size: '4:5_2K', + }) + + expect(mockGenerateContent).toHaveBeenCalledWith({ + model: 'gemini-3-pro-image', + contents: 'A portrait', + config: { + responseModalities: ['TEXT', 'IMAGE'], + imageConfig: { + aspectRatio: '4:5', + imageSize: '2K', + }, + }, + }) + expect(mockGenerateImages).not.toHaveBeenCalled() + expect(result.images[0]!.b64Json).toBe('ga-pro-image') + }) + + it('sends aspectRatio but no imageSize for gemini-2.5-flash-image', async () => { + // Google documents no image_size for this model, so the adapter must + // send the ratio alone rather than inventing a resolution tier. + const mockResponse = { + candidates: [ + { + content: { + parts: [ + { + inlineData: { mimeType: 'image/png', data: 'legacy-image' }, + }, + ], + }, + }, + ], + } + + const mockGenerateContent = vi.fn().mockResolvedValueOnce(mockResponse) + + const adapter = createGeminiImage( + 'gemini-2.5-flash-image', + 'test-api-key', + ) + ;( + adapter as unknown as { + client: { models: { generateContent: unknown } } + } + ).client = { + models: { + generateContent: mockGenerateContent, + }, + } + + await generateImage({ + adapter, + prompt: 'A wide landscape', + size: '16:9', + }) + + expect(mockGenerateContent).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash-image', + contents: 'A wide landscape', + config: { + responseModalities: ['TEXT', 'IMAGE'], + imageConfig: { + aspectRatio: '16:9', + }, + }, + }) + + const config = mockGenerateContent.mock.calls[0]![0].config + expect('imageSize' in config.imageConfig).toBe(false) + }) + it('surfaces token usage from usageMetadata (#330)', async () => { const mockResponse = { candidates: [ diff --git a/packages/ai-gemini/tests/image-per-model-type-safety.test.ts b/packages/ai-gemini/tests/image-per-model-type-safety.test.ts new file mode 100644 index 0000000000..96d47a9e8e --- /dev/null +++ b/packages/ai-gemini/tests/image-per-model-type-safety.test.ts @@ -0,0 +1,240 @@ +/** + * Per-model type-safety tests for Gemini `generateImage()` sizes. + * + * Positive cases: each documented (model, size) pair compiles cleanly. + * Negative cases: each undocumented pair produces a `@ts-expect-error`. `tsc` + * errors on an *unused* `@ts-expect-error`, so a green typecheck is itself the + * proof that every negative below is genuinely rejected. + * + * Companion to `chat-per-model-type-safety.test.ts` (modelOptions) and + * `tools-per-model-type-safety.test.ts` (tools). Compile-time only — + * `createImageOptions` is the identity helper for `generateImage()` options, + * so nothing here touches the network. + */ +import { describe, expectTypeOf, it } from 'vitest' +import { createImageOptions } from '@tanstack/ai' +import { createGeminiImage } from '../src/adapters/image' +import type { GeminiImageModelSizeByName } from '../src/image/image-provider-options' + +const apiKey = 'test-api-key' + +describe('Gemini per-model image size gating', () => { + describe('gemini-3.1-flash-image — 14 ratios × 512/1K/2K/4K', () => { + it('accepts the extreme banner ratios and the 512 tier', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-image', apiKey), + prompt: 'a wide banner', + size: '1:8_512', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-image', apiKey), + prompt: 'a wide banner', + size: '8:1_4K', + }) + }) + + it('accepts the flexible 4:5 / 5:4 ratios', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-image', apiKey), + prompt: 'a portrait', + size: '4:5_1K', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-image', apiKey), + prompt: 'a landscape', + size: '5:4_2K', + }) + }) + + it('rejects the Cloud-only 9:21 ratio and lowercase resolutions', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-image', apiKey), + prompt: 'a tall strip', + // @ts-expect-error - 9:21 exists on Vertex/Cloud only; the Gemini API rejects it + size: '9:21_1K', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-image', apiKey), + prompt: 'a square', + // @ts-expect-error - the K is case-sensitive; "1k" is rejected by the API + size: '1:1_1k', + }) + }) + + it('applies the same set to the shut-down -preview alias', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-image-preview', apiKey), + prompt: 'a wide banner', + size: '1:8_512', + }) + }) + }) + + describe('gemini-3.1-flash-lite-image — 14 ratios, 1K only', () => { + it('accepts 1K at any of the 14 ratios', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-lite-image', apiKey), + prompt: 'a square', + size: '1:1_1K', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-lite-image', apiKey), + prompt: 'a banner', + size: '4:1_1K', + }) + }) + + it('rejects every resolution above 1K', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-lite-image', apiKey), + prompt: 'a landscape', + // @ts-expect-error - Flash Lite Image only supports 1K; 2K/4K are unsupported + size: '16:9_4K', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-lite-image', apiKey), + prompt: 'a landscape', + // @ts-expect-error - Flash Lite Image only supports 1K + size: '16:9_2K', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-3.1-flash-lite-image', apiKey), + prompt: 'a landscape', + // @ts-expect-error - the 512 tier is Gemini 3.1 Flash Image only + size: '16:9_512', + }) + }) + }) + + describe('gemini-3-pro-image — 10 ratios × 1K/2K/4K', () => { + it('accepts the flexible 4:5 ratio at 2K', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3-pro-image', apiKey), + prompt: 'a portrait', + size: '4:5_2K', + }) + }) + + it('rejects the extreme banner ratios', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3-pro-image', apiKey), + prompt: 'a tall strip', + // @ts-expect-error - 1:8 is Gemini 3.1 Flash Image only; Pro takes the ten standard ratios + size: '1:8_1K', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-3-pro-image', apiKey), + prompt: 'a wide strip', + // @ts-expect-error - 4:1 is Gemini 3.1 Flash Image only + size: '4:1_2K', + }) + }) + + it('rejects the 512 tier', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3-pro-image', apiKey), + prompt: 'a square', + // @ts-expect-error - Pro has no 512 (0.5K) tier + size: '1:1_512', + }) + }) + + it('applies the same set to the shut-down -preview alias', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-3-pro-image-preview', apiKey), + prompt: 'a portrait', + size: '4:5_2K', + }) + }) + }) + + describe('gemini-2.5-flash-image — bare aspect ratio, no resolution', () => { + it('accepts a bare ratio', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-2.5-flash-image', apiKey), + prompt: 'a landscape', + size: '16:9', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-2.5-flash-image', apiKey), + prompt: 'a portrait', + size: '4:5', + }) + }) + + it('rejects any resolution suffix', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-2.5-flash-image', apiKey), + prompt: 'a landscape', + // @ts-expect-error - Google documents no image_size for this model + size: '16:9_2K', + }) + createImageOptions({ + adapter: createGeminiImage('gemini-2.5-flash-image', apiKey), + prompt: 'a landscape', + // @ts-expect-error - Google documents no image_size for this model + size: '16:9_1K', + }) + }) + + it('rejects the extreme banner ratios', () => { + createImageOptions({ + adapter: createGeminiImage('gemini-2.5-flash-image', apiKey), + prompt: 'a wide strip', + // @ts-expect-error - 8:1 is Gemini 3.1 Flash Image only + size: '8:1', + }) + }) + }) + + describe('Imagen models keep pixel sizes', () => { + it('accepts WIDTHxHEIGHT and rejects aspect-ratio sizes', () => { + createImageOptions({ + adapter: createGeminiImage('imagen-4.0-generate-001', apiKey), + prompt: 'a cat', + size: '1024x1024', + }) + createImageOptions({ + adapter: createGeminiImage('imagen-4.0-generate-001', apiKey), + prompt: 'a cat', + // @ts-expect-error - Imagen takes pixel sizes, not aspect-ratio sizes + size: '16:9_2K', + }) + }) + }) + + describe('Model name type safety', () => { + it('rejects unknown image model names at the factory', () => { + // @ts-expect-error - 'gemini-9-pro-image' is not a Gemini image model + createGeminiImage('gemini-9-pro-image', apiKey) + }) + }) +}) + +describe('Gemini image size map shape assertions', () => { + it('maps each GA id to its own size set', () => { + expectTypeOf< + GeminiImageModelSizeByName['gemini-3.1-flash-image'] + >().toEqualTypeOf< + GeminiImageModelSizeByName['gemini-3.1-flash-image-preview'] + >() + expectTypeOf< + GeminiImageModelSizeByName['gemini-3-pro-image'] + >().toEqualTypeOf< + GeminiImageModelSizeByName['gemini-3-pro-image-preview'] + >() + }) + + it('gives the four native models four different size sets', () => { + expectTypeOf< + GeminiImageModelSizeByName['gemini-3.1-flash-image'] + >().not.toEqualTypeOf< + GeminiImageModelSizeByName['gemini-3.1-flash-lite-image'] + >() + expectTypeOf< + GeminiImageModelSizeByName['gemini-3-pro-image'] + >().not.toEqualTypeOf< + GeminiImageModelSizeByName['gemini-2.5-flash-image'] + >() + }) +}) diff --git a/packages/ai/skills/ai-core/adapter-configuration/references/gemini-adapter.md b/packages/ai/skills/ai-core/adapter-configuration/references/gemini-adapter.md index f21c0b1ab5..fb27b468b1 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/references/gemini-adapter.md +++ b/packages/ai/skills/ai-core/adapter-configuration/references/gemini-adapter.md @@ -94,8 +94,12 @@ Note: `GOOGLE_GENAI_API_KEY` does NOT work. ## Gotchas - All Gemini models are multimodal (text, image, audio, video, document input). -- Image generation models (`gemini-3-pro-image-preview`, etc.) have smaller - input limits (65K tokens) compared to text models (1M tokens). +- Image generation models (`gemini-3-pro-image`, `gemini-3.1-flash-image`, etc.) + have smaller input limits (65K tokens) compared to text models (1M tokens). +- Use the GA image ids. `gemini-3-pro-image-preview` and + `gemini-3.1-flash-image-preview` were shut down on 2026-06-25 and now 404; + they remain in the type union only as deprecated aliases, so a call to them + compiles and then fails at runtime. - `thinkingConfig.thinkingLevel` (level-based) and `thinkingConfig.thinkingBudget` (budget-based) serve different models. Check which your model supports. - `cachedContent` must follow the format `cachedContents/{id}`. diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index fdf185a615..c7ca7e055d 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -151,9 +151,16 @@ function ImageGenerator() { ### 1. Image Generation Supported adapters: `openaiImage` (dall-e-2, dall-e-3, gpt-image-1, -gpt-image-1-mini, gpt-image-2), `geminiImage` (gemini-3.1-flash-image-preview, -gemini-3.1-flash-lite-image, imagen-4.0-generate-001, etc.) and `byteplusImage` -(Seedream — `seedream-4-0-250828`, `seedream-4-5-251128`, the 5.0 family). +gpt-image-1-mini, gpt-image-2), `geminiImage` (gemini-3.1-flash-image, +gemini-3.1-flash-lite-image, gemini-3-pro-image, imagen-4.0-generate-001, etc.) +and `byteplusImage` (Seedream — `seedream-4-0-250828`, `seedream-4-5-251128`, +the 5.0 family). + +> **Use the GA Gemini image ids.** `gemini-3.1-flash-image-preview` and +> `gemini-3-pro-image-preview` were shut down on 2026-06-25 and now 404. They +> survive in the type union only as deprecated aliases so existing code keeps +> compiling — a call to them typechecks and then fails at runtime. Use +> `gemini-3.1-flash-image` / `gemini-3-pro-image` instead. > **Seedream quirks:** `watermark` defaults to **`true`** (pass > `modelOptions: { watermark: false }` for a clean image), `size` is a token @@ -182,7 +189,7 @@ const openaiResult = await generateImage({ // Gemini native model with aspect-ratio sizes const geminiResult = await generateImage({ - adapter: geminiImage('gemini-3.1-flash-image-preview'), + adapter: geminiImage('gemini-3.1-flash-image'), prompt: 'A futuristic cityscape at night', size: '16:9_4K', }) @@ -996,7 +1003,7 @@ generateImage({ }) generateImage({ - adapter: geminiImage('gemini-3.1-flash-image-preview'), // native multimodal + adapter: geminiImage('gemini-3.1-flash-image'), // native multimodal prompt: [ { type: 'text', content: 'Edit this' }, { type: 'image', source: { type: 'url', value: url } }, diff --git a/testing/e2e/global-setup.ts b/testing/e2e/global-setup.ts index 0502e4418c..a8958ba022 100644 --- a/testing/e2e/global-setup.ts +++ b/testing/e2e/global-setup.ts @@ -82,6 +82,20 @@ export default async function globalSetup() { mock.mount('/api/embed', ollamaEmbedMount()) mock.mount('/mistral', mistralEmbeddingsMount()) + // Gemini native image generation (#1104: GA model ids + per-model + // aspectRatio/imageSize). Like TTS above, this hits generateContent, but + // aimock's handleGemini has no image-response branch at all (it imports + // isTextResponse/isToolCallResponse/isContentWithToolCallsResponse/ + // isAudioResponse from helpers.js — isImageResponse is wired only into + // images.js's OpenAI /v1/images/* and Gemini's :predict handling) — a + // fixture shaped `{image}`/`{images}` falls through every branch and 500s + // with "Fixture response did not match any known type". Shares the + // '/v1beta/models' prefix with the Veo and batch-embed mounts above; only + // the two model paths the #1104 spec exercises are handled here, so + // everything else still falls through to those mounts / aimock's native + // Gemini handlers. + mock.mount('/v1beta/models', geminiNativeImageMount()) + // Gemini Omni Flash video generation (Interactions API). aimock handles // synchronous text interactions natively, but not background video jobs // (POST /v1beta/interactions with background:true → poll @@ -505,6 +519,153 @@ function geminiBatchEmbedMount(): Mountable { } } +/** + * A tiny (1x1) real PNG, base64-encoded. Only needs to be a valid inline + * image byte string — the #1104 spec asserts on `images.length`, not pixel + * content. Mirrors `FAKE_MP3_BYTES`/`FAKE_PCM_BYTES` above. + */ +const TINY_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' + +/** + * Rejects with a Gemini-shaped error envelope (`{ error: { code, message, + * status } }`) so a validation failure surfaces as the actual missing/extra + * field in the adapter's thrown error message, not a generic 500. + */ +function rejectGeminiImageRequest( + res: http.ServerResponse, + message: string, +): true { + res.statusCode = 400 + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + error: { code: 400, message, status: 'INVALID_ARGUMENT' }, + }), + ) + return true +} + +/** + * Mounts Gemini native image generation (`POST /v1beta/models/{model}:generateContent`) + * for the two models #1104's spec exercises: `gemini-3.1-flash-image` (the + * new GA id) and `gemini-2.5-flash-image` (the legacy model whose sizes are a + * bare aspect ratio with no resolution tier). + * + * aimock's own `handleGemini` has no image-response branch — it imports + * `isTextResponse`/`isToolCallResponse`/`isContentWithToolCallsResponse`/ + * `isAudioResponse` from `helpers.js`, but never `isImageResponse` (that's + * wired only into `images.js`'s OpenAI `/v1/images/*` and Gemini's + * `:predict` handling) — so a `{image}`/`{images}` fixture matched against + * `generateContent` falls through every branch and 500s with "Fixture + * response did not match any known type". This mount hand-crafts the + * `candidates[0].content.parts[0].inlineData` shape directly, mirroring + * `geminiTTSMount` above (image/png instead of PCM audio). + * + * The request is validated against the raw, untranslated body — mounts see + * it before aimock's internal `geminiToCompletionRequest` translation, which + * drops `generationConfig.imageConfig` entirely — so this is what actually + * proves `imageConfig.aspectRatio` / `imageConfig.imageSize` reached the + * wire, per model: + * - `gemini-3.1-flash-image` rejects unless both `aspectRatio` AND + * `imageSize` are present (this model has resolution tiers). + * - `gemini-2.5-flash-image` rejects if `aspectRatio` is missing, OR if + * `imageSize` is present at all — Google documents no `image_size` for + * this model (#1104), so `parseNativeImageSize` must produce a bare ratio + * and the adapter must omit `imageSize` from the request. + */ +function geminiNativeImageMount(): Mountable { + const NATIVE_IMAGE_MODELS = new Set([ + 'gemini-3.1-flash-image', + 'gemini-2.5-flash-image', + ]) + + // Deliberately coupled to the `size` values in + // testing/e2e/src/routes/api.gemini-image-ga-models.ts ('16:9_2K' and + // '16:9') — a future edit to one must update the other. + const EXPECTED_ASPECT_RATIO = '16:9' + const EXPECTED_FLASH_IMAGE_SIZE = '2K' + + return { + async handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + // aimock strips the mount prefix ('/v1beta/models'), so pathname + // looks like '/{model}:generateContent'. + pathname: string, + ): Promise { + const match = pathname.match(/^\/([^/:]+):generateContent$/) + const model = match?.[1] + if (!model || !NATIVE_IMAGE_MODELS.has(model) || req.method !== 'POST') { + return false + } + + const body = await readJsonRequestBody(req) + if (!body) { + return rejectGeminiImageRequest(res, 'Malformed JSON body.') + } + + const generationConfig = asRecord(body.generationConfig) + const imageConfig = asRecord(generationConfig?.imageConfig) + const aspectRatio = imageConfig?.aspectRatio + const imageSize = imageConfig?.imageSize + + if (aspectRatio !== EXPECTED_ASPECT_RATIO) { + return rejectGeminiImageRequest( + res, + `${model}: generationConfig.imageConfig.aspectRatio must be "${EXPECTED_ASPECT_RATIO}", got ${JSON.stringify(aspectRatio)}.`, + ) + } + + if (model === 'gemini-3.1-flash-image') { + if (imageSize !== EXPECTED_FLASH_IMAGE_SIZE) { + return rejectGeminiImageRequest( + res, + `${model}: generationConfig.imageConfig.imageSize must be "${EXPECTED_FLASH_IMAGE_SIZE}", got ${JSON.stringify(imageSize)}.`, + ) + } + } else if (imageSize !== undefined) { + // gemini-2.5-flash-image: Google documents no image_size for this + // model (#1104) — the adapter must send a bare aspect ratio only. + return rejectGeminiImageRequest( + res, + `${model}: generationConfig.imageConfig.imageSize must be absent.`, + ) + } + + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + candidates: [ + { + content: { + role: 'model', + parts: [ + { + inlineData: { + mimeType: 'image/png', + data: TINY_PNG_BASE64, + }, + }, + ], + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 1290, + totalTokenCount: 1298, + }, + }), + ) + return true + }, + } +} + /** * Mounts Ollama's batch embed endpoint (POST /api/embed). aimock's native * handler answers with the legacy /api/embeddings shape (singular diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index c6d9853490..0cadb398d8 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -59,6 +59,7 @@ import { Route as ApiInterruptsTestRouteImport } from './routes/api.interrupts-t import { Route as ApiImageRouteImport } from './routes/api.image' import { Route as ApiGenerationPersistenceServerRouteImport } from './routes/api.generation-persistence-server' import { Route as ApiGenerationPersistenceResumeRouteImport } from './routes/api.generation-persistence-resume' +import { Route as ApiGeminiImageGaModelsRouteImport } from './routes/api.gemini-image-ga-models' import { Route as ApiForeignInterruptRouteImport } from './routes/api.foreign-interrupt' import { Route as ApiEmbeddingRouteImport } from './routes/api.embedding' import { Route as ApiDurableTakeoverRouteImport } from './routes/api.durable-takeover' @@ -336,6 +337,11 @@ const ApiGenerationPersistenceResumeRoute = path: '/api/generation-persistence-resume', getParentRoute: () => rootRouteImport, } as any) +const ApiGeminiImageGaModelsRoute = ApiGeminiImageGaModelsRouteImport.update({ + id: '/api/gemini-image-ga-models', + path: '/api/gemini-image-ga-models', + getParentRoute: () => rootRouteImport, +} as any) const ApiForeignInterruptRoute = ApiForeignInterruptRouteImport.update({ id: '/api/foreign-interrupt', path: '/api/foreign-interrupt', @@ -453,6 +459,7 @@ export interface FileRoutesByFullPath { '/api/durable-takeover': typeof ApiDurableTakeoverRoute '/api/embedding': typeof ApiEmbeddingRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/gemini-image-ga-models': typeof ApiGeminiImageGaModelsRoute '/api/generation-persistence-resume': typeof ApiGenerationPersistenceResumeRoute '/api/generation-persistence-server': typeof ApiGenerationPersistenceServerRoute '/api/image': typeof ApiImageRouteWithChildren @@ -522,6 +529,7 @@ export interface FileRoutesByTo { '/api/durable-takeover': typeof ApiDurableTakeoverRoute '/api/embedding': typeof ApiEmbeddingRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/gemini-image-ga-models': typeof ApiGeminiImageGaModelsRoute '/api/generation-persistence-resume': typeof ApiGenerationPersistenceResumeRoute '/api/generation-persistence-server': typeof ApiGenerationPersistenceServerRoute '/api/image': typeof ApiImageRouteWithChildren @@ -592,6 +600,7 @@ export interface FileRoutesById { '/api/durable-takeover': typeof ApiDurableTakeoverRoute '/api/embedding': typeof ApiEmbeddingRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/gemini-image-ga-models': typeof ApiGeminiImageGaModelsRoute '/api/generation-persistence-resume': typeof ApiGenerationPersistenceResumeRoute '/api/generation-persistence-server': typeof ApiGenerationPersistenceServerRoute '/api/image': typeof ApiImageRouteWithChildren @@ -663,6 +672,7 @@ export interface FileRouteTypes { | '/api/durable-takeover' | '/api/embedding' | '/api/foreign-interrupt' + | '/api/gemini-image-ga-models' | '/api/generation-persistence-resume' | '/api/generation-persistence-server' | '/api/image' @@ -732,6 +742,7 @@ export interface FileRouteTypes { | '/api/durable-takeover' | '/api/embedding' | '/api/foreign-interrupt' + | '/api/gemini-image-ga-models' | '/api/generation-persistence-resume' | '/api/generation-persistence-server' | '/api/image' @@ -801,6 +812,7 @@ export interface FileRouteTypes { | '/api/durable-takeover' | '/api/embedding' | '/api/foreign-interrupt' + | '/api/gemini-image-ga-models' | '/api/generation-persistence-resume' | '/api/generation-persistence-server' | '/api/image' @@ -871,6 +883,7 @@ export interface RootRouteChildren { ApiDurableTakeoverRoute: typeof ApiDurableTakeoverRoute ApiEmbeddingRoute: typeof ApiEmbeddingRoute ApiForeignInterruptRoute: typeof ApiForeignInterruptRoute + ApiGeminiImageGaModelsRoute: typeof ApiGeminiImageGaModelsRoute ApiGenerationPersistenceResumeRoute: typeof ApiGenerationPersistenceResumeRoute ApiGenerationPersistenceServerRoute: typeof ApiGenerationPersistenceServerRoute ApiImageRoute: typeof ApiImageRouteWithChildren @@ -1258,6 +1271,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGenerationPersistenceResumeRouteImport parentRoute: typeof rootRouteImport } + '/api/gemini-image-ga-models': { + id: '/api/gemini-image-ga-models' + path: '/api/gemini-image-ga-models' + fullPath: '/api/gemini-image-ga-models' + preLoaderRoute: typeof ApiGeminiImageGaModelsRouteImport + parentRoute: typeof rootRouteImport + } '/api/foreign-interrupt': { id: '/api/foreign-interrupt' path: '/api/foreign-interrupt' @@ -1468,6 +1488,7 @@ const rootRouteChildren: RootRouteChildren = { ApiDurableTakeoverRoute: ApiDurableTakeoverRoute, ApiEmbeddingRoute: ApiEmbeddingRoute, ApiForeignInterruptRoute: ApiForeignInterruptRoute, + ApiGeminiImageGaModelsRoute: ApiGeminiImageGaModelsRoute, ApiGenerationPersistenceResumeRoute: ApiGenerationPersistenceResumeRoute, ApiGenerationPersistenceServerRoute: ApiGenerationPersistenceServerRoute, ApiImageRoute: ApiImageRouteWithChildren, diff --git a/testing/e2e/src/routes/api.gemini-image-ga-models.ts b/testing/e2e/src/routes/api.gemini-image-ga-models.ts new file mode 100644 index 0000000000..645497eb57 --- /dev/null +++ b/testing/e2e/src/routes/api.gemini-image-ga-models.ts @@ -0,0 +1,98 @@ +import { createFileRoute } from '@tanstack/react-router' +import { generateImage } from '@tanstack/ai' +import { createGeminiImage, GeminiImageModels } from '@tanstack/ai-gemini' +import type { ImageGenerationResult } from '@tanstack/ai' + +const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' +const DUMMY_KEY = 'sk-e2e-test-dummy-key' + +interface StageResult { + imageCount?: number + error?: string +} + +async function runStage( + generate: () => Promise, +): Promise { + try { + const result = await generate() + return { imageCount: result.images.length } + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} + +/** + * Regression coverage for #1104 (GA image model ids + per-model sizes). + * + * Runs the two model calls as independent stages (each with its own + * try/catch) so a regression in one doesn't mask the other — the two specs + * each assert on only their own stage's result. + * + * 1. `gaModel`: `gemini-3.1-flash-image` — the GA id for "Nano Banana 2" — + * must be listed in the package's exported `GeminiImageModels` runtime + * array and must actually route a `generateImage()` call through + * Gemini's `generateContent` API to a returned image. Before the fix, + * only the shut-down `-preview` id was in that list (the GA id 404s + * against the real API otherwise). `GeminiImageModels` is genuine + * runtime data (an exported `as const` array), unlike the per-model + * `size` typing below, so `.includes()` on it is real, revert-detectable + * coverage independent of any TypeScript compile step — nothing in the + * adapter's routing logic gates on this array at runtime, so it's the + * only way to observe the id addition without a type-checker. + * 2. `legacyModel`: `gemini-2.5-flash-image` must send + * `generationConfig.imageConfig` with only `aspectRatio` set — no + * `imageSize` — because Google documents no `image_size` value for this + * model. `parseNativeImageSize`'s regex used to require an + * `_` suffix on every size string, so a bare ratio like + * `"16:9"` failed to match at all pre-fix and no `imageConfig` (not even + * `aspectRatio`) reached the wire. The `geminiNativeImageMount` in + * `global-setup.ts` 400s unless `aspectRatio` is present and `imageSize` + * is absent, so this is genuine runtime proof of the parser change, not + * just a type check. + * + * Both models are proxied through `geminiNativeImageMount` (global-setup.ts) + * — aimock's own Gemini `generateContent` handler has no image-response + * branch (see that mount's doc comment for why). + */ +export const Route = createFileRoute('/api/gemini-image-ga-models')({ + server: { + handlers: { + POST: async () => { + const gaModelListed = GeminiImageModels.includes( + 'gemini-3.1-flash-image', + ) + + const gaModel = await runStage(() => + generateImage({ + adapter: createGeminiImage('gemini-3.1-flash-image', DUMMY_KEY, { + httpOptions: { baseUrl: LLMOCK_DEFAULT_BASE }, + }), + prompt: 'a guitar in a music store', + size: '16:9_2K', + }), + ) + + const legacyModel = await runStage(() => + generateImage({ + adapter: createGeminiImage('gemini-2.5-flash-image', DUMMY_KEY, { + httpOptions: { baseUrl: LLMOCK_DEFAULT_BASE }, + }), + prompt: 'a guitar in a music store', + // Bare aspect ratio, no `_` suffix — the shape + // `gemini-2.5-flash-image` requires post-#1104. + size: '16:9', + }), + ) + + return new Response( + JSON.stringify({ gaModelListed, gaModel, legacyModel }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + }, + }, + }, +}) diff --git a/testing/e2e/tests/gemini-image-ga-models.spec.ts b/testing/e2e/tests/gemini-image-ga-models.spec.ts new file mode 100644 index 0000000000..8219386b43 --- /dev/null +++ b/testing/e2e/tests/gemini-image-ga-models.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from './fixtures' + +interface StageResult { + imageCount?: number + error?: string +} + +interface GeminiImageGaModelsResponse { + gaModelListed: boolean + gaModel: StageResult + legacyModel: StageResult +} + +/** + * Regression coverage for #1104 (Gemini GA image model ids + per-model + * sizes). See `api.gemini-image-ga-models.ts` for the full mechanism and + * `geminiNativeImageMount` in `global-setup.ts` for the mock that makes it + * possible — aimock's native Gemini `generateContent` handler has no + * image-response branch, so this route/mount pair exists specifically to + * cover what aimock alone cannot. The route runs both model calls as + * independent stages, so each test below only depends on its own stage. + */ +test.describe('gemini — GA image model ids and per-model sizes (#1104)', () => { + test('gemini-3.1-flash-image is a listed GA id and generates an image', async ({ + request, + }) => { + const res = await request.post('/api/gemini-image-ga-models') + expect(res.ok()).toBe(true) + + const { gaModelListed, gaModel } = + (await res.json()) as GeminiImageGaModelsResponse + + // Genuine runtime data, not a type check: `GeminiImageModels` is the + // package's exported `as const` array of supported image model ids. + // Nothing at runtime gates request routing on this array (the adapter + // dispatches on the model string's "gemini-" prefix, not membership), so + // checking its contents is the only way to observe the id addition + // without a TypeScript compile step. Before #1104 it only carried the + // shut-down `gemini-3.1-flash-image-preview` alias. + expect(gaModelListed).toBe(true) + expect(gaModel.error ?? null).toBeNull() + expect(gaModel.imageCount).toBe(1) + }) + + test('gemini-2.5-flash-image sends imageConfig.aspectRatio with no imageSize', async ({ + request, + }) => { + const res = await request.post('/api/gemini-image-ga-models') + expect(res.ok()).toBe(true) + + const { legacyModel } = (await res.json()) as GeminiImageGaModelsResponse + + expect(legacyModel.error ?? null).toBeNull() + expect(legacyModel.imageCount).toBe(1) + }) +}) diff --git a/testing/panel/src/routes/api.image.ts b/testing/panel/src/routes/api.image.ts index e9c4aeda48..1a71332e3f 100644 --- a/testing/panel/src/routes/api.image.ts +++ b/testing/panel/src/routes/api.image.ts @@ -19,7 +19,7 @@ export const Route = createFileRoute('/api/image')({ const defaultModels: Record = { openai: 'gpt-image-1', gemini: 'gemini-2.5-flash-image', - openrouter: 'google/gemini-3.1-flash-image-preview', + openrouter: 'google/gemini-3.1-flash-image', } const model: string = data.model || body.model || defaultModels[provider]