From d10f1de96676065a181e8a35d3fd26192daa5b56 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Mon, 26 Oct 2026 22:44:21 -0400 Subject: [PATCH] fix(ai-gemini): allow native image generation with store: false Gemini leaves the interaction id out of an unstored interaction, and the adapter threw 'image interaction returned no id.' on every such call even though the image was generated. The id is only needed to chain an edit through previous_interaction_id, which an unstored interaction cannot do, so use a local id when store is false. A stored interaction without an id still throws. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/gemini-image-store-false.md | 5 +++ docs/media/image-generation.md | 2 +- packages/ai-gemini/src/adapters/image.ts | 20 +++++++-- .../ai-gemini/tests/image-adapter.test.ts | 41 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 .changeset/gemini-image-store-false.md diff --git a/.changeset/gemini-image-store-false.md b/.changeset/gemini-image-store-false.md new file mode 100644 index 0000000000..53c11080da --- /dev/null +++ b/.changeset/gemini-image-store-false.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-gemini': patch +--- + +Native Gemini image generation no longer throws `image interaction returned no id.` when `modelOptions.store` is `false`. Gemini leaves the interaction id out of an unstored interaction, so the result gets a local id, which cannot be passed as `previous_interaction_id`. A stored interaction without an id still throws. diff --git a/docs/media/image-generation.md b/docs/media/image-generation.md index dbbe3c4c78..2b04c4e0c0 100644 --- a/docs/media/image-generation.md +++ b/docs/media/image-generation.md @@ -227,7 +227,7 @@ const edited = await generateImage({ Rules for the edit: -- Leave `store` unset. The API default is `true`. If you set `store` to `false`, a later call cannot use that id. +- Leave `store` unset. The API default is `true`. If you set `store` to `false`, Gemini does not keep the interaction, so `id` is a local id and a later call cannot use it as `previous_interaction_id`. - A free tier keeps a stored interaction for 1 day. A paid tier keeps it for 55 days. - If the edit needs a new reference image, add that image part on this call. Do not send the image from `first` again. diff --git a/packages/ai-gemini/src/adapters/image.ts b/packages/ai-gemini/src/adapters/image.ts index d79174fe18..2aea84a24b 100644 --- a/packages/ai-gemini/src/adapters/image.ts +++ b/packages/ai-gemini/src/adapters/image.ts @@ -4,7 +4,11 @@ import { resolveMediaPrompt, } from '@tanstack/ai' import { BaseImageAdapter } from '@tanstack/ai/adapters' -import { createGeminiClient, getGeminiApiKeyFromEnv } from '../utils' +import { + createGeminiClient, + generateId, + getGeminiApiKeyFromEnv, +} from '../utils' import { flattenModalityTokenCounts, hasModalityTokens } from '../usage' import { parseNativeImageSize, @@ -196,7 +200,11 @@ export class GeminiImageAdapter< }) : await this.client.interactions.create(request) - return this.transformInteractionResponse(model, interaction) + return this.transformInteractionResponse( + model, + interaction, + modelOptions?.store !== false, + ) } /** @@ -261,13 +269,17 @@ export class GeminiImageAdapter< private transformInteractionResponse( model: string, interaction: Interactions.Interaction, + stored: boolean, ): ImageGenerationResult { if (interaction.status !== 'completed') { throw new Error( `Gemini ${model} image interaction ended with status "${interaction.status}".`, ) } - if (!interaction.id) { + // The interaction id only matters for chaining an edit through + // `previous_interaction_id`, and an unstored interaction cannot be chained. + // Gemini leaves the id out when `store` is false, so use a local one. + if (!interaction.id && stored) { throw new Error(`Gemini ${model} image interaction returned no id.`) } @@ -302,7 +314,7 @@ export class GeminiImageAdapter< const usage = interactionImageUsage(interaction.usage) return { - id: interaction.id, + id: interaction.id ?? generateId(this.name), model, images, ...(usage ? { usage } : {}), diff --git a/packages/ai-gemini/tests/image-adapter.test.ts b/packages/ai-gemini/tests/image-adapter.test.ts index 34f2e73298..3546f9da14 100644 --- a/packages/ai-gemini/tests/image-adapter.test.ts +++ b/packages/ai-gemini/tests/image-adapter.test.ts @@ -627,6 +627,47 @@ describe('Gemini Image Adapter', () => { expect(result.id).toBe('int_test') }) + it('returns the images with a local id when store is false and Gemini returns no interaction id', async () => { + const mockCreate = vi + .fn() + .mockResolvedValueOnce(interactionWithImages('out', { id: undefined })) + const adapter = createGeminiImage( + 'gemini-3.1-flash-image', + 'test-api-key', + ) + stubCreate(adapter, mockCreate) + + const result = await generateImage({ + threadId: 'thread-1', + adapter, + prompt: 'A red apple', + modelOptions: { store: false }, + }) + + expect(result.images).toHaveLength(1) + expect(result.id).toEqual(expect.any(String)) + expect(result.id).not.toBe('') + }) + + it('still throws when a stored interaction comes back without an id', async () => { + const mockCreate = vi + .fn() + .mockResolvedValueOnce(interactionWithImages('out', { id: undefined })) + const adapter = createGeminiImage( + 'gemini-3.1-flash-image', + 'test-api-key', + ) + stubCreate(adapter, mockCreate) + + await expect( + generateImage({ + threadId: 'thread-1', + adapter, + prompt: 'A red apple', + }), + ).rejects.toThrow(/returned no id/) + }) + it('passes abortSignal only when one is set', async () => { const { adapter, mockCreate } = mockedNativeAdapter() const controller = new AbortController()