Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gemini-custom-fetch.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/adapters/gemini-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 9 additions & 3 deletions packages/ai-gemini/src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions packages/ai-gemini/src/client/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
62 changes: 62 additions & 0 deletions packages/ai-gemini/tests/custom-fetch-wire.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>(() =>
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()
})
})
22 changes: 22 additions & 0 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -4441,6 +4462,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiForeignChunkEventsRoute: ApiForeignChunkEventsRoute,
ApiForeignClientToolRoute: ApiForeignClientToolRoute,
ApiForeignInterruptRoute: ApiForeignInterruptRoute,
ApiGeminiCustomFetchWireRoute: ApiGeminiCustomFetchWireRoute,
ApiGeminiImageGaModelsRoute: ApiGeminiImageGaModelsRoute,
ApiGeminiNativeImageWireRoute: ApiGeminiNativeImageWireRoute,
ApiGeminiServerToolsWireRoute: ApiGeminiServerToolsWireRoute,
Expand Down
105 changes: 105 additions & 0 deletions testing/e2e/src/routes/api.gemini-custom-fetch-wire.ts
Original file line number Diff line number Diff line change
@@ -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<GeminiTextConfig['httpOptions']> & {
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<string> = []
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),
})
}
},
},
},
})
33 changes: 33 additions & 0 deletions testing/e2e/tests/gemini-custom-fetch-wire.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>
} = 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'),
])
})
})