From 8106603e97bd0d17e0b2984a9d99c16f08f8f09a Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 29 May 2026 17:07:51 +1000 Subject: [PATCH] fix(gemini): surface token usage from image generateContent path (#330) The Gemini image adapter hardcoded `usage: undefined`, so the `image:usage` devtools event never fired for Gemini image generation even though the `generateContent` response carries `usageMetadata`. Parse `usageMetadata` (promptTokenCount / candidatesTokenCount / totalTokenCount) into `ImageGenerationResult.usage` on the native path, matching the convention used by the Gemini text adapter. The Imagen (`generateImages`) path is left as-is because that SDK response type does not expose `usageMetadata`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/gemini-image-usage.md | 8 ++++ packages/ai-gemini/src/adapters/image.ts | 10 ++++ .../ai-gemini/tests/image-adapter.test.ts | 46 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 .changeset/gemini-image-usage.md diff --git a/.changeset/gemini-image-usage.md b/.changeset/gemini-image-usage.md new file mode 100644 index 000000000..42f9e2cf4 --- /dev/null +++ b/.changeset/gemini-image-usage.md @@ -0,0 +1,8 @@ +--- +'@tanstack/ai-gemini': patch +--- + +Surface token usage from the Gemini image adapter's `generateContent` path +(e.g. Nano Banana) by parsing `usageMetadata` from the response instead of +omitting `usage`. The Imagen (`generateImages`) path is unchanged — that SDK +response type does not expose `usageMetadata`. Fixes #330. diff --git a/packages/ai-gemini/src/adapters/image.ts b/packages/ai-gemini/src/adapters/image.ts index 612385e16..6b0af1f7f 100644 --- a/packages/ai-gemini/src/adapters/image.ts +++ b/packages/ai-gemini/src/adapters/image.ts @@ -214,6 +214,16 @@ export class GeminiImageAdapter< id: generateId(this.name), model, images, + // Surface token usage when the model reports it (e.g. Nano Banana via + // generateContent). Conditionally spread to satisfy + // exactOptionalPropertyTypes — only include usage when present. See #330. + ...(response.usageMetadata && { + usage: { + inputTokens: response.usageMetadata.promptTokenCount ?? 0, + outputTokens: response.usageMetadata.candidatesTokenCount ?? 0, + totalTokens: response.usageMetadata.totalTokenCount ?? 0, + }, + }), } } diff --git a/packages/ai-gemini/tests/image-adapter.test.ts b/packages/ai-gemini/tests/image-adapter.test.ts index f87cb98f2..979975152 100644 --- a/packages/ai-gemini/tests/image-adapter.test.ts +++ b/packages/ai-gemini/tests/image-adapter.test.ts @@ -302,6 +302,52 @@ describe('Gemini Image Adapter', () => { expect(result.images[0]!.b64Json).toBe('gemini-base64-image') }) + it('surfaces token usage from usageMetadata (#330)', async () => { + const mockResponse = { + candidates: [ + { + content: { + parts: [ + { + inlineData: { mimeType: 'image/png', data: 'img' }, + }, + ], + }, + }, + ], + usageMetadata: { + promptTokenCount: 12, + candidatesTokenCount: 34, + totalTokenCount: 46, + }, + } + + const adapter = createGeminiImage( + 'gemini-3.1-flash-image-preview', + 'test-api-key', + ) + ;( + adapter as unknown as { + client: { models: { generateContent: unknown } } + } + ).client = { + models: { + generateContent: vi.fn().mockResolvedValueOnce(mockResponse), + }, + } + + const result = await generateImage({ + adapter, + prompt: 'A futuristic city', + }) + + expect(result.usage).toEqual({ + inputTokens: 12, + outputTokens: 34, + totalTokens: 46, + }) + }) + it('calls generateContent without imageConfig when no size provided', async () => { const mockResponse = { candidates: [