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()