From 225bf9f716e664fda03e9ffa1b22dfdebbca2bcc Mon Sep 17 00:00:00 2001 From: Andrej Kurocenko Date: Mon, 9 Nov 2026 11:35:50 +0100 Subject: [PATCH 1/3] fix(ai-gemini): send requests through httpOptions.fetch again Since 1.1.1 the API-key client calls the global fetch. apiCall in src/client/api.ts never read httpOptions.fetch, and createInteractions did not pass it to createHttpClient. @google/genai 2.23.0 and later read this option, so 1.1.0 and earlier sent requests through it. Call the client's httpOptions.fetch in apiCall, and pass it to createHttpClient. --- .changeset/gemini-custom-fetch.md | 5 ++ packages/ai-gemini/src/client/api.ts | 12 +++- packages/ai-gemini/src/client/interactions.ts | 6 +- .../ai-gemini/tests/custom-fetch-wire.test.ts | 62 +++++++++++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 .changeset/gemini-custom-fetch.md create mode 100644 packages/ai-gemini/tests/custom-fetch-wire.test.ts diff --git a/.changeset/gemini-custom-fetch.md b/.changeset/gemini-custom-fetch.md new file mode 100644 index 0000000000..de9052308d --- /dev/null +++ b/.changeset/gemini-custom-fetch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-gemini': patch +--- + +Send Gemini API requests through `httpOptions.fetch` again when the client has an `apiKey`. Since 1.1.1 the adapters called the global `fetch` and ignored a custom one, also for image generation through the Interactions API. The `httpOptions.fetch` type is in `@google/genai` 2.23.0 and later. diff --git a/packages/ai-gemini/src/client/api.ts b/packages/ai-gemini/src/client/api.ts index d6dff43240..f2168183a9 100644 --- a/packages/ai-gemini/src/client/api.ts +++ b/packages/ai-gemini/src/client/api.ts @@ -71,6 +71,9 @@ export function usesVertexAI(options: GoogleGenAIOptions) { return flag?.toLowerCase() === 'true' } +/** `HttpOptions` plus `fetch`, which the SDK 2.10.0 types do not have. */ +export type ClientHttpOptions = HttpOptions & { fetch?: typeof fetch } + // Port of `ApiClient.patchHttpOptions`: an object value is merged one level // deep, any other defined value replaces the old one. function patchHttpOptions(base: HttpOptions, patch: HttpOptions) { @@ -172,7 +175,7 @@ export function createApiClient(options: GoogleGenAIOptions) { const { apiKey } = options const baseUrl = options.httpOptions?.baseUrl ?? readEnv('GOOGLE_GEMINI_BASE_URL') - const clientHttpOptions = patchHttpOptions( + const clientHttpOptions: ClientHttpOptions = patchHttpOptions( { apiVersion: options.apiVersion ?? 'v1beta', baseUrl: DEFAULT_BASE_URL, @@ -185,12 +188,15 @@ export function createApiClient(options: GoogleGenAIOptions) { // `retryOptions`, with the p-retry backoff (1 s, 2 s, 4 s, ...). const apiCall = async (url: string, init: RequestInit) => { const { retryOptions } = clientHttpOptions - if (!retryOptions) return fetch(url, init) + // ponytail: the client fetch only. Add a per-call fetch when a caller + // needs one. + const fetchFn = clientHttpOptions.fetch ?? fetch + if (!retryOptions) return fetchFn(url, init) const retries = (retryOptions.attempts ?? 5) - 1 for (let attempt = 0; ; attempt++) { let error: unknown try { - const response = await fetch(url, init) + const response = await fetchFn(url, init) if (response.ok) return response if (!RETRY_STATUS_CODES.includes(response.status)) { throw new Error( diff --git a/packages/ai-gemini/src/client/interactions.ts b/packages/ai-gemini/src/client/interactions.ts index 0b98f7bf99..b3e9f8d41a 100644 --- a/packages/ai-gemini/src/client/interactions.ts +++ b/packages/ai-gemini/src/client/interactions.ts @@ -2,7 +2,8 @@ // the Gemini Developer API with an API key. Same URL, headers, body, retries, // response shape (`steps`, `output_text`, ...), and error fields. import { createHttpClient, parseSSE } from '@tanstack/ai-utils/http' -import type { HttpOptions, Interactions } from '@google/genai' +import type { Interactions } from '@google/genai' +import type { ClientHttpOptions } from './api' type Interaction = Interactions.Interaction type InteractionSSEEvent = Interactions.InteractionSSEEvent @@ -192,11 +193,12 @@ async function* readEvents(response: Response, signal?: AbortSignal | null) { */ export function createInteractions(client: { apiKey: string - httpOptions: HttpOptions + httpOptions: ClientHttpOptions }) { const { apiKey, httpOptions } = client const http = createHttpClient({ baseURL: (httpOptions.baseUrl ?? '').replace(/\/+$/, ''), + fetch: httpOptions.fetch, // The SDK sends its own default headers here, not `httpOptions.headers`. headers: { 'Content-Type': 'application/json', diff --git a/packages/ai-gemini/tests/custom-fetch-wire.test.ts b/packages/ai-gemini/tests/custom-fetch-wire.test.ts new file mode 100644 index 0000000000..a1bb278c83 --- /dev/null +++ b/packages/ai-gemini/tests/custom-fetch-wire.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createGoogleGenAI } from '../src/client' +import type { HttpOptions } from '@google/genai' + +// @google/genai 2.23.0 and later have `httpOptions.fetch`. The repo installs +// 2.10.0, whose `HttpOptions` type does not have it yet. +type HttpOptionsWithFetch = HttpOptions & { fetch: typeof fetch } + +/** + * A client whose `httpOptions.fetch` answers `body`. Global `fetch` answers a + * 400, which no client retries. + */ +function clientWithFetch(body: unknown) { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + Response.json( + { error: { message: 'global fetch was called' } }, + { status: 400 }, + ), + ), + ), + ) + const customFetch = vi.fn(() => + Promise.resolve(Response.json(body)), + ) + const httpOptions: HttpOptionsWithFetch = { fetch: customFetch } + const client = createGoogleGenAI({ apiKey: 'test-key', httpOptions }) + return { client, customFetch } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('httpOptions.fetch', () => { + it('sends models.generateContent through it', async () => { + const { client, customFetch } = clientWithFetch({ candidates: [] }) + + await client.models.generateContent({ + model: 'gemini-2.5-flash', + contents: 'Hi', + }) + // Per-call httpOptions are merged over the client options. + await client.models.generateContent({ + model: 'gemini-2.5-flash', + contents: 'Hi', + config: { httpOptions: { headers: { 'x-call': '1' } } }, + }) + + expect(customFetch).toHaveBeenCalledTimes(2) + }) + + it('sends interactions.create through it', async () => { + const { client, customFetch } = clientWithFetch({ id: 'int_1', steps: [] }) + + await client.interactions.create({ model: 'gemini-2.5-flash', input: 'Hi' }) + + expect(customFetch).toHaveBeenCalledOnce() + }) +}) From 43d74415f2496802e093509f00ca1238af660dd6 Mon Sep 17 00:00:00 2001 From: Andrej Kurocenko Date: Mon, 9 Nov 2026 10:56:42 +0100 Subject: [PATCH 2/3] test(e2e): send Gemini chat and image requests through httpOptions.fetch The route runs the real Gemini chat and image adapters with a fake fetch in httpOptions.fetch and returns the URLs that it got. The base URL does not resolve, so a request sent with the global fetch fails. --- testing/e2e/src/routeTree.gen.ts | 22 ++++ .../routes/api.gemini-custom-fetch-wire.ts | 105 ++++++++++++++++++ .../tests/gemini-custom-fetch-wire.spec.ts | 33 ++++++ 3 files changed, 160 insertions(+) create mode 100644 testing/e2e/src/routes/api.gemini-custom-fetch-wire.ts create mode 100644 testing/e2e/tests/gemini-custom-fetch-wire.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 31c7217dd5..313f457cdf 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -105,6 +105,7 @@ import { Route as ApiFileSourceWireRouteImport } from './routes/api.file-source- import { Route as ApiForeignChunkEventsRouteImport } from './routes/api.foreign-chunk-events' import { Route as ApiForeignClientToolRouteImport } from './routes/api.foreign-client-tool' import { Route as ApiForeignInterruptRouteImport } from './routes/api.foreign-interrupt' +import { Route as ApiGeminiCustomFetchWireRouteImport } from './routes/api.gemini-custom-fetch-wire' import { Route as ApiGeminiImageGaModelsRouteImport } from './routes/api.gemini-image-ga-models' import { Route as ApiGeminiNativeImageWireRouteImport } from './routes/api.gemini-native-image-wire' import { Route as ApiGeminiServerToolsWireRouteImport } from './routes/api.gemini-server-tools-wire' @@ -723,6 +724,12 @@ const ApiForeignInterruptRoute = ApiForeignInterruptRouteImport.update({ path: '/api/foreign-interrupt', getParentRoute: () => rootRouteImport, } as any) +const ApiGeminiCustomFetchWireRoute = + ApiGeminiCustomFetchWireRouteImport.update({ + id: '/api/gemini-custom-fetch-wire', + path: '/api/gemini-custom-fetch-wire', + getParentRoute: () => rootRouteImport, + } as any) const ApiGeminiImageGaModelsRoute = ApiGeminiImageGaModelsRouteImport.update({ id: '/api/gemini-image-ga-models', path: '/api/gemini-image-ga-models', @@ -1419,6 +1426,7 @@ export interface FileRoutesByFullPath { '/api/foreign-chunk-events': typeof ApiForeignChunkEventsRoute '/api/foreign-client-tool': typeof ApiForeignClientToolRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/gemini-custom-fetch-wire': typeof ApiGeminiCustomFetchWireRoute '/api/gemini-image-ga-models': typeof ApiGeminiImageGaModelsRoute '/api/gemini-native-image-wire': typeof ApiGeminiNativeImageWireRoute '/api/gemini-server-tools-wire': typeof ApiGeminiServerToolsWireRoute @@ -1631,6 +1639,7 @@ export interface FileRoutesByTo { '/api/foreign-chunk-events': typeof ApiForeignChunkEventsRoute '/api/foreign-client-tool': typeof ApiForeignClientToolRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/gemini-custom-fetch-wire': typeof ApiGeminiCustomFetchWireRoute '/api/gemini-image-ga-models': typeof ApiGeminiImageGaModelsRoute '/api/gemini-native-image-wire': typeof ApiGeminiNativeImageWireRoute '/api/gemini-server-tools-wire': typeof ApiGeminiServerToolsWireRoute @@ -1844,6 +1853,7 @@ export interface FileRoutesById { '/api/foreign-chunk-events': typeof ApiForeignChunkEventsRoute '/api/foreign-client-tool': typeof ApiForeignClientToolRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/gemini-custom-fetch-wire': typeof ApiGeminiCustomFetchWireRoute '/api/gemini-image-ga-models': typeof ApiGeminiImageGaModelsRoute '/api/gemini-native-image-wire': typeof ApiGeminiNativeImageWireRoute '/api/gemini-server-tools-wire': typeof ApiGeminiServerToolsWireRoute @@ -2058,6 +2068,7 @@ export interface FileRouteTypes { | '/api/foreign-chunk-events' | '/api/foreign-client-tool' | '/api/foreign-interrupt' + | '/api/gemini-custom-fetch-wire' | '/api/gemini-image-ga-models' | '/api/gemini-native-image-wire' | '/api/gemini-server-tools-wire' @@ -2270,6 +2281,7 @@ export interface FileRouteTypes { | '/api/foreign-chunk-events' | '/api/foreign-client-tool' | '/api/foreign-interrupt' + | '/api/gemini-custom-fetch-wire' | '/api/gemini-image-ga-models' | '/api/gemini-native-image-wire' | '/api/gemini-server-tools-wire' @@ -2482,6 +2494,7 @@ export interface FileRouteTypes { | '/api/foreign-chunk-events' | '/api/foreign-client-tool' | '/api/foreign-interrupt' + | '/api/gemini-custom-fetch-wire' | '/api/gemini-image-ga-models' | '/api/gemini-native-image-wire' | '/api/gemini-server-tools-wire' @@ -2695,6 +2708,7 @@ export interface RootRouteChildren { ApiForeignChunkEventsRoute: typeof ApiForeignChunkEventsRoute ApiForeignClientToolRoute: typeof ApiForeignClientToolRoute ApiForeignInterruptRoute: typeof ApiForeignInterruptRoute + ApiGeminiCustomFetchWireRoute: typeof ApiGeminiCustomFetchWireRoute ApiGeminiImageGaModelsRoute: typeof ApiGeminiImageGaModelsRoute ApiGeminiNativeImageWireRoute: typeof ApiGeminiNativeImageWireRoute ApiGeminiServerToolsWireRoute: typeof ApiGeminiServerToolsWireRoute @@ -3481,6 +3495,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiForeignInterruptRouteImport parentRoute: typeof rootRouteImport } + '/api/gemini-custom-fetch-wire': { + id: '/api/gemini-custom-fetch-wire' + path: '/api/gemini-custom-fetch-wire' + fullPath: '/api/gemini-custom-fetch-wire' + preLoaderRoute: typeof ApiGeminiCustomFetchWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/gemini-image-ga-models': { id: '/api/gemini-image-ga-models' path: '/api/gemini-image-ga-models' @@ -4441,6 +4462,7 @@ const rootRouteChildren: RootRouteChildren = { ApiForeignChunkEventsRoute: ApiForeignChunkEventsRoute, ApiForeignClientToolRoute: ApiForeignClientToolRoute, ApiForeignInterruptRoute: ApiForeignInterruptRoute, + ApiGeminiCustomFetchWireRoute: ApiGeminiCustomFetchWireRoute, ApiGeminiImageGaModelsRoute: ApiGeminiImageGaModelsRoute, ApiGeminiNativeImageWireRoute: ApiGeminiNativeImageWireRoute, ApiGeminiServerToolsWireRoute: ApiGeminiServerToolsWireRoute, diff --git a/testing/e2e/src/routes/api.gemini-custom-fetch-wire.ts b/testing/e2e/src/routes/api.gemini-custom-fetch-wire.ts new file mode 100644 index 0000000000..8d62249ae3 --- /dev/null +++ b/testing/e2e/src/routes/api.gemini-custom-fetch-wire.ts @@ -0,0 +1,105 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, generateImage } from '@tanstack/ai' +import { createGeminiChat, createGeminiImage } from '@tanstack/ai-gemini' +import type { GeminiTextConfig } from '@tanstack/ai-gemini' + +// @google/genai 2.23.0 and later have `httpOptions.fetch`. The repo installs +// 2.10.0, whose `HttpOptions` type does not have it yet. +type HttpOptionsWithFetch = NonNullable & { + fetch: typeof fetch +} + +const TINY_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + +/** A fake `fetch` that records each URL and answers like the Gemini API. */ +function fakeGeminiFetch() { + const urls: Array = [] + const fetch = (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input) + urls.push(url) + if (url.includes(':streamGenerateContent')) { + const chunk = { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'Hi from fetch.' }] }, + finishReason: 'STOP', + }, + ], + } + return Promise.resolve( + new Response(`data: ${JSON.stringify(chunk)}\n\n`, { + headers: { 'Content-Type': 'text/event-stream' }, + }), + ) + } + const image = { + type: 'image', + mime_type: 'image/png', + data: TINY_PNG_BASE64, + } + return Promise.resolve( + Response.json({ + id: 'int_custom_fetch', + object: 'interaction', + status: 'completed', + steps: [{ type: 'model_output', content: [image] }], + }), + ) + } + return { urls, fetch } +} + +/** + * The real Gemini chat and image adapters with `httpOptions.fetch` set to a + * fake `fetch`. Returns the URLs that fetch got and the results. A request + * that goes to the global `fetch` instead fails, because `.invalid` never + * resolves. + */ +export const Route = createFileRoute('/api/gemini-custom-fetch-wire')({ + server: { + handlers: { + POST: async () => { + try { + const wire = fakeGeminiFetch() + const httpOptions: HttpOptionsWithFetch = { + baseUrl: 'https://gemini.invalid', + fetch: wire.fetch, + } + + let text = '' + for await (const chunk of chat({ + threadId: 'gemini-custom-fetch-wire', + adapter: createGeminiChat('gemini-2.5-flash', 'e2e-key', { + httpOptions, + }), + messages: [{ role: 'user', content: 'Hi' }], + })) { + if (chunk.type === 'TEXT_MESSAGE_CONTENT') text += chunk.delta + } + + const image = await generateImage({ + threadId: 'gemini-custom-fetch-wire', + adapter: createGeminiImage('gemini-3.1-flash-image', 'e2e-key', { + httpOptions, + }), + prompt: 'a guitar', + stream: false, + }) + + return Response.json({ + ok: true, + text, + images: image.images.length, + urls: wire.urls, + }) + } catch (error) { + return Response.json({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } + }, + }, + }, +}) diff --git a/testing/e2e/tests/gemini-custom-fetch-wire.spec.ts b/testing/e2e/tests/gemini-custom-fetch-wire.spec.ts new file mode 100644 index 0000000000..ce2f7750b9 --- /dev/null +++ b/testing/e2e/tests/gemini-custom-fetch-wire.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from './fixtures' + +/** + * `/api/gemini-custom-fetch-wire` runs the real Gemini chat and image + * adapters with `httpOptions.fetch` set to a fake `fetch`, and returns the + * URLs that fetch got. The base URL is `https://gemini.invalid`, so a request + * sent with the global `fetch` instead fails and never reaches the fake. + */ +test.describe('gemini -- httpOptions.fetch', () => { + test('chat and image requests go through the custom fetch', async ({ + request, + }) => { + const response = await request.post('/api/gemini-custom-fetch-wire') + expect(response.ok()).toBe(true) + const result: { + ok: boolean + error?: string + text?: string + images?: number + urls?: Array + } = await response.json() + if (!result.ok) throw new Error(`Route failed: ${result.error}`) + + expect(result.text).toBe('Hi from fetch.') + expect(result.images).toBe(1) + expect(result.urls).toEqual([ + expect.stringContaining( + '/v1beta/models/gemini-2.5-flash:streamGenerateContent', + ), + expect.stringContaining('/v1beta/interactions'), + ]) + }) +}) From e599ce55eb570150a6c59b5dff557377a0416271 Mon Sep 17 00:00:00 2001 From: Andrej Kurocenko Date: Mon, 9 Nov 2026 10:56:43 +0100 Subject: [PATCH 3/3] docs(ai-gemini): point to httpOptions.fetch for a custom fetch The page said that the SDK does not take a custom fetch. @google/genai 2.23.0 and later take httpOptions.fetch. --- docs/adapters/gemini-advanced.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adapters/gemini-advanced.md b/docs/adapters/gemini-advanced.md index 8da1f8bd00..efdebfbca9 100644 --- a/docs/adapters/gemini-advanced.md +++ b/docs/adapters/gemini-advanced.md @@ -41,7 +41,7 @@ const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY! ``` - `baseURL` sets `httpOptions.baseUrl`, and `defaultHeaders` sets `httpOptions.headers`. If you set both forms, `baseURL` and `defaultHeaders` win. -- This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `@google/genai` SDK does not take a custom fetch. +- This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). To send the requests through your own `fetch`, set `httpOptions.fetch`. This option needs `@google/genai` 2.23.0 or later. ## Video understanding options