Skip to content
Merged
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-image-url-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-gemini': patch
---

fix(ai-gemini): stop fetching arbitrary HTTPS image URLs in `createGeminiImage`. URL sources in multimodal image-generation prompts now pass through as `fileData.fileUri` (Gemini fetches them server-side), matching the chat adapter. This avoids fetch + base64 double-buffering that could OOM on memory-constrained runtimes such as Cloudflare Workers.
18 changes: 18 additions & 0 deletions .changeset/gemini-openai-url-input-error-by-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@tanstack/ai-gemini': minor
'@tanstack/ai-openai': minor
---

fix(ai-gemini, ai-openai): don't buffer arbitrary HTTP(S) URL image inputs by default on paths that require uploaded bytes.

Gemini **Veo** (`createGeminiVideo`), OpenAI image **edits** (`createOpenaiImage`), and OpenAI **Sora** `input_reference` (`createOpenaiVideo`) have no URL passthrough β€” the provider only accepts inline bytes (or, for Veo, a `gs://` reference). Previously an HTTP(S) URL image input was silently fetched and buffered in memory, which can OOM memory-constrained runtimes (e.g. Cloudflare Workers).

These paths now **throw** on an HTTP(S) URL image input by default, with an error pointing to the alternatives. `data:` URIs (and `gs://` for Veo) still work without any flag. To opt back into fetching + buffering, set `allowUrlFetch: true` on the adapter config:

```ts
createOpenaiImage('gpt-image-1', apiKey, { allowUrlFetch: true })
createOpenaiVideo('sora-2', apiKey, { allowUrlFetch: true })
createGeminiVideo('veo-3.1-generate-preview', apiKey, { allowUrlFetch: true })
```

Migration: if you passed HTTP(S) URL image inputs to these adapters, either fetch the bytes yourself and pass a `data:` URI, pass a `gs://` reference (Veo), or set `allowUrlFetch: true`.
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@
"label": "Image Generation",
"to": "media/image-generation",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-01"
"updatedAt": "2026-07-07"
},
{
"label": "Video Generation",
Expand Down
26 changes: 24 additions & 2 deletions docs/media/image-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,30 @@ base64 data β€” pass whichever you have:
{ type: 'image', source: { type: 'data', value: base64String, mimeType: 'image/png' } }
```

OpenAI's edit endpoint requires file uploads; the adapter fetches URL sources
and converts base64 to a `File` automatically.
Gemini's native image generation never fetches URL sources locally β€” they pass
through as `fileData.fileUri` and Gemini retrieves them server-side, so public
HTTPS URLs, [Files API](https://ai.google.dev/gemini-api/docs/files) URIs, and
`gs://` references all work without buffering the image in your runtime's
memory.

Two paths have no URL passthrough and must upload real bytes β€” OpenAI's
`/images/edits` (and Sora `input_reference`), and Gemini **Veo** (its predict
API accepts only inline bytes or a `gs://` reference). For these, an HTTP(S)
URL input would have to be downloaded and buffered in memory, which can OOM
memory-constrained runtimes (e.g. Cloudflare Workers). So by default they
**throw** on an HTTP(S) URL image input rather than fetch it. Pass a `data:`
URI (or a `gs://` reference for Veo), or opt into fetching with `allowUrlFetch`:

```typescript ignore
import { createOpenaiImage } from '@tanstack/ai-openai/adapters'

// Opt into downloading + buffering HTTP(S) URL image inputs (server runtimes
// with headroom). data: URIs always work without this flag.
const adapter = createOpenaiImage('gpt-image-1', apiKey, { allowUrlFetch: true })
```

The same `allowUrlFetch` option exists on `createOpenaiVideo` and
`createGeminiVideo`.

### Role hints via `metadata.role`

Expand Down
72 changes: 24 additions & 48 deletions packages/ai-gemini/src/adapters/image.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { resolveMediaPrompt } from '@tanstack/ai'
import { BaseImageAdapter } from '@tanstack/ai/adapters'
import { arrayBufferToBase64 } from '@tanstack/ai-utils'
import {
createGeminiClient,
generateId,
Expand Down Expand Up @@ -199,7 +198,7 @@ export class GeminiImageAdapter<
}),
}

const contents = await this.buildContents(resolved, numberOfImages)
const contents = this.buildContents(resolved, numberOfImages)

const response = await this.client.models.generateContent({
model,
Expand All @@ -220,10 +219,10 @@ export class GeminiImageAdapter<
* The generateContent API has no numberOfImages parameter, so when more
* than one image is requested a trailing instruction is appended.
*/
private async buildContents(
private buildContents(
resolved: ResolvedMediaPrompt,
numberOfImages: number | undefined,
): Promise<string | Array<Content>> {
): string | Array<Content> {
const countInstruction =
numberOfImages && numberOfImages > 1
? `Generate ${numberOfImages} distinct images.`
Expand All @@ -235,29 +234,25 @@ export class GeminiImageAdapter<
: resolved.text
}

const parts: Array<Part> = await Promise.all(
resolved.parts.map((part) => {
if (part.type === 'text') {
return Promise.resolve<Part>({ text: part.content })
}
if (part.type === 'image') {
return this.imagePartToGeminiPart(part)
}
// Video / audio parts were rejected in generateImages above.
throw new Error(
`gemini: unsupported prompt part type "${part.type}" in image generation.`,
)
}),
)
const parts: Array<Part> = resolved.parts.map((part) => {
if (part.type === 'text') {
return { text: part.content }
}
if (part.type === 'image') {
return this.imagePartToGeminiPart(part)
}
// Video / audio parts were rejected in generateImages above.
throw new Error(
`gemini: unsupported prompt part type "${part.type}" in image generation.`,
)
})
if (countInstruction) {
parts.push({ text: countInstruction })
}
return [{ role: 'user', parts }]
}

private async imagePartToGeminiPart(
part: ImagePart<MediaInputMetadata>,
): Promise<Part> {
private imagePartToGeminiPart(part: ImagePart<MediaInputMetadata>): Part {
if (part.source.type === 'data') {
return {
inlineData: {
Expand All @@ -266,34 +261,15 @@ export class GeminiImageAdapter<
},
}
}
// For URL sources, prefer passing the URL through as `fileData` when it
// looks like a Google Files API URI; otherwise fetch and inline as base64.
if (
part.source.value.startsWith('gs://') ||
/^https?:\/\/generativelanguage\.googleapis\.com\//.test(
part.source.value,
)
) {
return {
fileData: {
fileUri: part.source.value,
...(part.source.mimeType && { mimeType: part.source.mimeType }),
},
}
}
const response = await fetch(part.source.value)
if (!response.ok) {
throw new Error(
`Failed to fetch image input (${response.status} ${response.statusText}): ${part.source.value}`,
)
}
const blob = await response.blob()
const buffer = await blob.arrayBuffer()
const base64 = arrayBufferToBase64(buffer)
// URL sources (public HTTPS, Files API URIs, gs://) pass through as
// `fileData` and Gemini fetches them server-side β€” same as the chat
// adapter. Fetching locally and inlining as base64 double-buffers the
// image and OOMs on memory-constrained runtimes (e.g. Cloudflare
// Workers).
return {
inlineData: {
mimeType: part.source.mimeType || blob.type || 'image/png',
data: base64,
fileData: {
fileUri: part.source.value,
mimeType: part.source.mimeType ?? 'image/jpeg',
},
}
}
Expand Down
37 changes: 33 additions & 4 deletions packages/ai-gemini/src/adapters/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,17 @@ import type { GeminiClientConfig } from '../utils'
*
* @experimental Video generation is an experimental feature and may change.
*/
export interface GeminiVideoConfig extends GeminiClientConfig {}
export interface GeminiVideoConfig extends GeminiClientConfig {
/**
* Opt into fetching HTTP(S) image URL inputs. Veo's predict API accepts
* only inline `imageBytes` or a `gcsUri`, so an HTTP(S) URL has to be
* downloaded and base64-encoded locally β€” which buffers the whole image in
* memory and can OOM constrained runtimes (e.g. Cloudflare Workers). When
* `false` (the default), HTTP(S) URL image inputs throw; pass a `data:` URI
* or a `gs://` reference, or set this to `true` to opt into buffering.
*/
allowUrlFetch?: boolean
}

/**
* Extract a human-readable message from a long-running operation's error,
Expand All @@ -55,9 +65,17 @@ function operationErrorMessage(error: Record<string, unknown>): string {
* Convert a TanStack image prompt part into the genai `Image` shape Veo
* accepts: base64 `imageBytes` (data sources, data: URIs, fetched HTTP
* URLs) or a `gcsUri` passthrough for Cloud Storage references.
*
* Unlike `generateContent` (chat / native image generation), Veo's predict
* API has no `fileData.fileUri` equivalent β€” `Image` only accepts
* `imageBytes` or `gcsUri`. An HTTP(S) URL therefore has to be fetched and
* inlined locally, which buffers the whole image in memory; that only happens
* when the caller opts in via `allowUrlFetch`, otherwise it throws. Prefer a
* `gs://` reference on memory-constrained runtimes.
*/
async function imagePartToVeoImage(
part: ImagePart<MediaInputMetadata>,
allowUrlFetch: boolean,
): Promise<Image> {
if (part.source.type === 'data') {
return {
Expand All @@ -84,6 +102,15 @@ async function imagePartToVeoImage(
mimeType: match[1] || part.source.mimeType || 'image/png',
}
}
if (!allowUrlFetch) {
throw new Error(
`gemini Veo: HTTP(S) URL image inputs are not fetched by default because ` +
`Veo accepts only inline bytes, so the image would be downloaded and ` +
`buffered in memory (risking OOM on constrained runtimes). Pass a ` +
`data: URI or a gs:// reference, or set \`allowUrlFetch: true\` on the ` +
`adapter config to opt into fetching. URL: ${url}`,
)
}
const response = await fetch(url)
if (!response.ok) {
throw new Error(
Expand Down Expand Up @@ -132,10 +159,12 @@ export class GeminiVideoAdapter<
readonly name = 'gemini' as const

protected client: GoogleGenAI
private readonly allowUrlFetch: boolean

constructor(config: GeminiVideoConfig, model: TModel) {
super({}, model)
this.client = createGeminiClient(config)
this.allowUrlFetch = config.allowUrlFetch ?? false
}

async createVideoJob(
Expand Down Expand Up @@ -224,13 +253,13 @@ export class GeminiVideoAdapter<
`${this.name}: Veo accepts at most one 'end_frame' image.`,
)
}
lastFrame = await imagePartToVeoImage(part)
lastFrame = await imagePartToVeoImage(part, this.allowUrlFetch)
break
}
case 'reference':
case 'character': {
referenceImages.push({
image: await imagePartToVeoImage(part),
image: await imagePartToVeoImage(part, this.allowUrlFetch),
referenceType: VideoGenerationReferenceType.ASSET,
})
break
Expand All @@ -242,7 +271,7 @@ export class GeminiVideoAdapter<
`${this.name}: Veo accepts at most one starting image; received multiple 'start_frame'/un-roled images. Use metadata.role ('end_frame', 'reference') to disambiguate the others.`,
)
}
image = await imagePartToVeoImage(part)
image = await imagePartToVeoImage(part, this.allowUrlFetch)
break
}
case 'mask':
Expand Down
72 changes: 48 additions & 24 deletions packages/ai-gemini/tests/image-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,44 +799,68 @@ describe('Gemini Image Adapter', () => {
})
})

it('fetches arbitrary URL sources and inlines them as base64', async () => {
it('passes arbitrary HTTPS URL sources through as fileData without fetching (#907)', async () => {
// Matches the chat adapter: Gemini fetches URL inputs server-side.
// Fetching locally and inlining as base64 OOMs on memory-constrained
// runtimes (e.g. Cloudflare Workers).
const { adapter, mockGenerateContent } = mockedNativeAdapter()
// 'hi' β†’ base64 'aGk='
const fetchMock = vi.fn().mockResolvedValue(
new Response(new Uint8Array([104, 105]), {
headers: { 'content-type': 'image/jpeg' },
}),
)
vi.stubGlobal('fetch', fetchMock)

try {
await generateImage({
adapter,
prompt: [
{ type: 'text', content: 'Edit this' },
{
type: 'image',
source: { type: 'url', value: 'https://example.com/photo.jpg' },
},
],
})
} finally {
vi.unstubAllGlobals()
}
await generateImage({
adapter,
prompt: [
{ type: 'text', content: 'Edit this' },
{
type: 'image',
source: { type: 'url', value: 'https://example.com/photo.jpg' },
},
],
})

expect(fetchMock).toHaveBeenCalledWith('https://example.com/photo.jpg')
const args = mockGenerateContent.mock.calls[0]![0]
expect(args.contents).toEqual([
{
role: 'user',
parts: [
{ text: 'Edit this' },
{ inlineData: { mimeType: 'image/jpeg', data: 'aGk=' } },
{
fileData: {
fileUri: 'https://example.com/photo.jpg',
// No source mimeType β†’ same image/jpeg default as chat.
mimeType: 'image/jpeg',
},
},
],
},
])
})

it('uses the provided mimeType for URL sources', async () => {
const { adapter, mockGenerateContent } = mockedNativeAdapter()

await generateImage({
adapter,
prompt: [
{ type: 'text', content: 'Edit this' },
{
type: 'image',
source: {
type: 'url',
value: 'https://example.com/photo.png',
mimeType: 'image/png',
},
},
],
})

const args = mockGenerateContent.mock.calls[0]![0]
expect(args.contents[0].parts[1]).toEqual({
fileData: {
fileUri: 'https://example.com/photo.png',
mimeType: 'image/png',
},
})
})

it('rejects image prompt parts for Imagen models', async () => {
const adapter = createGeminiImage(
'imagen-4.0-generate-001',
Expand Down
Loading
Loading