@tanstack/ai-gemini 0.32.1 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import {
2
+ BaseFilesAdapter,
3
+ normalizeFileUploadInput,
4
+ } from '@tanstack/ai/adapters'
5
+ import { createGeminiClient, getGeminiApiKeyFromEnv } from '../utils/client'
6
+ import type { File as GeminiFile, GoogleGenAI } from '@google/genai'
7
+ import type { FileHandle, FileUploadInput } from '@tanstack/ai/adapters'
8
+ import type { GeminiClientConfig } from '../utils/client'
9
+
10
+ export interface GeminiFilesConfig extends GeminiClientConfig {}
11
+
12
+ /**
13
+ * Gemini Files adapter — uploads media to the Gemini Files API and references
14
+ * it by its file URI. Pair with `geminiText()` / `geminiImage()`: reference the
15
+ * returned handle via `fileSourceFromHandle(handle)`, which uses the handle URI
16
+ * (Gemini fetches it server-side as `fileData.fileUri`).
17
+ */
18
+ export class GeminiFilesAdapter extends BaseFilesAdapter<'gemini'> {
19
+ readonly name = 'gemini' as const
20
+ private readonly client: GoogleGenAI
21
+
22
+ constructor(config: GeminiFilesConfig) {
23
+ super()
24
+ this.client = createGeminiClient(config)
25
+ }
26
+
27
+ async upload(input: FileUploadInput): Promise<FileHandle<'gemini'>> {
28
+ const { blob, mimeType } = normalizeFileUploadInput(input)
29
+ const file = await this.client.files.upload({
30
+ file: blob,
31
+ ...(mimeType ? { config: { mimeType } } : {}),
32
+ })
33
+ return toFileHandle(file)
34
+ }
35
+
36
+ async get(id: string): Promise<FileHandle<'gemini'>> {
37
+ return toFileHandle(await this.client.files.get({ name: id }))
38
+ }
39
+
40
+ async delete(id: string): Promise<void> {
41
+ await this.client.files.delete({ name: id })
42
+ }
43
+ }
44
+
45
+ function toFileHandle(file: GeminiFile): FileHandle<'gemini'> {
46
+ // `name` (e.g. "files/abc-123") is the lifecycle id; `uri` is the URL Gemini
47
+ // fetches when the handle is referenced in a message.
48
+ if (!file.name) {
49
+ throw new Error('gemini: files.upload returned a file without a name')
50
+ }
51
+ const expiresAt = file.expirationTime
52
+ ? Date.parse(file.expirationTime)
53
+ : undefined
54
+ return {
55
+ id: file.name,
56
+ provider: 'gemini',
57
+ ...(file.uri ? { uri: file.uri } : {}),
58
+ ...(file.mimeType ? { mimeType: file.mimeType } : {}),
59
+ ...(file.sizeBytes ? { sizeBytes: Number(file.sizeBytes) } : {}),
60
+ ...(expiresAt !== undefined && !Number.isNaN(expiresAt)
61
+ ? { expiresAt }
62
+ : {}),
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Create a Gemini Files adapter with an explicit API key.
68
+ */
69
+ export function createGeminiFiles(
70
+ apiKey: string,
71
+ config?: Omit<GeminiFilesConfig, 'apiKey'>,
72
+ ): GeminiFilesAdapter {
73
+ return new GeminiFilesAdapter({ ...config, apiKey })
74
+ }
75
+
76
+ /**
77
+ * Create a Gemini Files adapter, reading the API key from `GOOGLE_API_KEY` /
78
+ * `GEMINI_API_KEY`.
79
+ */
80
+ export function geminiFiles(
81
+ config?: Omit<GeminiFilesConfig, 'apiKey'>,
82
+ ): GeminiFilesAdapter {
83
+ return createGeminiFiles(getGeminiApiKeyFromEnv(), config)
84
+ }
@@ -1,4 +1,8 @@
1
- import { resolveMediaPrompt } from '@tanstack/ai'
1
+ import {
2
+ fileReferenceFor,
3
+ isFileSource,
4
+ resolveMediaPrompt,
5
+ } from '@tanstack/ai'
2
6
  import { BaseImageAdapter } from '@tanstack/ai/adapters'
3
7
  import {
4
8
  createGeminiClient,
@@ -75,6 +79,8 @@ export class GeminiImageAdapter<
75
79
  > {
76
80
  override readonly kind = 'image' as const
77
81
  readonly name = 'gemini' as const
82
+ // Consumes Gemini Files API references (geminiFiles()) as fileData.fileUri.
83
+ override readonly supportsFileSources = true
78
84
 
79
85
  // Type-only property - never assigned at runtime
80
86
  declare '~types': {
@@ -270,10 +276,14 @@ export class GeminiImageAdapter<
270
276
  // `fileData` and Gemini fetches them server-side — same as the chat
271
277
  // adapter. Fetching locally and inlining as base64 double-buffers the
272
278
  // image and OOMs on memory-constrained runtimes (e.g. Cloudflare
273
- // Workers).
279
+ // Workers). A file source's handle is the file URI (throws when another
280
+ // provider issued it).
281
+ const fileUri = isFileSource(part.source)
282
+ ? fileReferenceFor(part.source, this.name)
283
+ : part.source.value
274
284
  return {
275
285
  fileData: {
276
- fileUri: part.source.value,
286
+ fileUri,
277
287
  mimeType: part.source.mimeType ?? 'image/jpeg',
278
288
  },
279
289
  }
@@ -1,5 +1,10 @@
1
1
  import { FinishReason } from '@google/genai'
2
- import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
2
+ import {
3
+ EventType,
4
+ fileReferenceFor,
5
+ isFileSource,
6
+ normalizeSystemPrompts,
7
+ } from '@tanstack/ai'
3
8
  import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
4
9
  import { BaseTextAdapter } from '@tanstack/ai/adapters'
5
10
  import { convertToolsToProviderFormat } from '../tools/tool-converter'
@@ -26,6 +31,7 @@ import type {
26
31
  GenerateContentParameters,
27
32
  GenerateContentResponse,
28
33
  GoogleGenAI,
34
+ GroundingMetadata,
29
35
  Part,
30
36
  ThinkingLevel,
31
37
  VideoMetadata,
@@ -35,6 +41,7 @@ import type {
35
41
  Modality,
36
42
  ModelMessage,
37
43
  AdapterYieldChunk,
44
+ ProviderExecutedToolSource,
38
45
  TextOptions,
39
46
  } from '@tanstack/ai'
40
47
  import type { ExternalTextProviderOptions } from '../text/text-provider-options'
@@ -56,6 +63,26 @@ const DEFAULT_MEDIA_MIME_TYPES = {
56
63
  document: 'application/pdf',
57
64
  } as const
58
65
 
66
+ function getGroundingSources(
67
+ metadata: GroundingMetadata,
68
+ ): Array<ProviderExecutedToolSource> {
69
+ const sources = new Map<string, ProviderExecutedToolSource>()
70
+ for (const chunk of metadata.groundingChunks ?? []) {
71
+ const url = chunk.web?.uri
72
+ if (!url) continue
73
+ const existing = sources.get(url)
74
+ if (existing) {
75
+ if (!existing.title && chunk.web?.title) existing.title = chunk.web.title
76
+ continue
77
+ }
78
+ sources.set(url, {
79
+ url,
80
+ ...(chunk.web?.title ? { title: chunk.web.title } : {}),
81
+ })
82
+ }
83
+ return [...sources.values()]
84
+ }
85
+
59
86
  /**
60
87
  * Content block shape for an Interactions API `input` step. The installed
61
88
  * @google/genai types predate the video `processing` field, so we model the
@@ -107,8 +134,11 @@ function contentPartToInteraction(part: ContentPart): InteractionContent {
107
134
  source.type === 'data'
108
135
  ? source.mimeType
109
136
  : (source.mimeType ?? DEFAULT_MEDIA_MIME_TYPES[part.type])
110
- const base =
111
- source.type === 'data'
137
+ // A Gemini Files API handle maps to the `uri` field, same as a public URL;
138
+ // `fileReferenceFor` throws when another provider issued it.
139
+ const base = isFileSource(source)
140
+ ? { uri: fileReferenceFor(source, 'gemini'), mime_type: mimeType }
141
+ : source.type === 'data'
112
142
  ? { data: source.value, mime_type: mimeType }
113
143
  : { uri: source.value, mime_type: mimeType }
114
144
 
@@ -216,6 +246,8 @@ export class GeminiTextAdapter<
216
246
  > {
217
247
  override readonly kind = 'text' as const
218
248
  readonly name = 'gemini' as const
249
+ // Consumes Gemini Files API references (geminiFiles()) as fileData.fileUri.
250
+ override readonly supportsFileSources = true
219
251
 
220
252
  private readonly client: GoogleGenAI
221
253
 
@@ -594,6 +626,51 @@ export class GeminiTextAdapter<
594
626
  let hasEmittedRunStarted = false
595
627
  let hasEmittedTextMessageStart = false
596
628
  let hasEmittedStepStarted = false
629
+ let groundingMetadata: GroundingMetadata | undefined
630
+ let groundingCallEmitted = false
631
+ const adapterName = this.name
632
+
633
+ const emitGroundingToolCall = function* (): Generator<AdapterYieldChunk> {
634
+ if (!groundingMetadata || groundingCallEmitted) return
635
+ const sources = getGroundingSources(groundingMetadata)
636
+ const hasGoogleSearchEvidence =
637
+ sources.length > 0 ||
638
+ (groundingMetadata.webSearchQueries?.length ?? 0) > 0 ||
639
+ groundingMetadata.searchEntryPoint !== undefined
640
+ if (!hasGoogleSearchEvidence) return
641
+
642
+ groundingCallEmitted = true
643
+ const toolCallId = generateId(adapterName)
644
+ const metadata: GeminiToolCallMetadata = {
645
+ providerExecuted: true,
646
+ sources,
647
+ gemini: { groundingMetadata },
648
+ }
649
+ yield {
650
+ type: EventType.TOOL_CALL_START,
651
+ toolCallId,
652
+ toolCallName: 'google_search',
653
+ toolName: 'google_search',
654
+ parentMessageId: messageId,
655
+ model,
656
+ timestamp: Date.now(),
657
+ index: toolCallMap.size,
658
+ metadata,
659
+ }
660
+ yield {
661
+ type: EventType.TOOL_CALL_END,
662
+ toolCallId,
663
+ toolCallName: 'google_search',
664
+ toolName: 'google_search',
665
+ model,
666
+ timestamp: Date.now(),
667
+ input: {
668
+ ...(groundingMetadata.webSearchQueries && {
669
+ queries: groundingMetadata.webSearchQueries,
670
+ }),
671
+ },
672
+ }
673
+ }
597
674
 
598
675
  for await (const chunk of result) {
599
676
  logger.provider(`provider=gemini`, { chunk })
@@ -610,6 +687,10 @@ export class GeminiTextAdapter<
610
687
  }
611
688
  }
612
689
 
690
+ if (chunk.candidates?.[0]?.groundingMetadata) {
691
+ groundingMetadata = chunk.candidates[0].groundingMetadata
692
+ }
693
+
613
694
  if (chunk.candidates?.[0]?.content?.parts) {
614
695
  const parts = chunk.candidates[0].content.parts
615
696
 
@@ -820,6 +901,7 @@ export class GeminiTextAdapter<
820
901
  }
821
902
 
822
903
  if (chunk.candidates?.[0]?.finishReason) {
904
+ yield* emitGroundingToolCall()
823
905
  const finishReason = chunk.candidates[0].finishReason
824
906
 
825
907
  // Emit TOOL_CALL_END for all tracked tool calls. functionCall parts on
@@ -910,6 +992,8 @@ export class GeminiTextAdapter<
910
992
  }
911
993
  }
912
994
  }
995
+
996
+ yield* emitGroundingToolCall()
913
997
  }
914
998
 
915
999
  private convertContentPartToGemini(part: ContentPart): Part {
@@ -920,6 +1004,13 @@ export class GeminiTextAdapter<
920
1004
  case 'audio':
921
1005
  case 'video':
922
1006
  case 'document': {
1007
+ // File references (Gemini Files API) and public URLs both pass
1008
+ // through as `fileData`; Gemini fetches the URI server-side. A
1009
+ // file source's handle is the file URI (throws when another provider
1010
+ // issued it).
1011
+ const fileUri = isFileSource(part.source)
1012
+ ? fileReferenceFor(part.source, this.name)
1013
+ : part.source.value
923
1014
  const geminiPart: Part =
924
1015
  part.source.type === 'data'
925
1016
  ? {
@@ -930,7 +1021,7 @@ export class GeminiTextAdapter<
930
1021
  }
931
1022
  : {
932
1023
  fileData: {
933
- fileUri: part.source.value,
1024
+ fileUri,
934
1025
  // For URL sources, use provided mimeType or fall back to
935
1026
  // reasonable defaults.
936
1027
  mimeType:
@@ -994,6 +1085,11 @@ export class GeminiTextAdapter<
994
1085
 
995
1086
  if (msg.role === 'assistant' && msg.toolCalls?.length) {
996
1087
  for (const toolCall of msg.toolCalls) {
1088
+ const metadata = toolCall.metadata as
1089
+ | GeminiToolCallMetadata
1090
+ | undefined
1091
+ if (metadata?.providerExecuted) continue
1092
+
997
1093
  let parsedArgs: Record<string, unknown> = {}
998
1094
  try {
999
1095
  parsedArgs = toolCall.function.arguments
@@ -1006,9 +1102,7 @@ export class GeminiTextAdapter<
1006
1102
  parsedArgs = {}
1007
1103
  }
1008
1104
 
1009
- const thoughtSignature = (
1010
- toolCall.metadata as GeminiToolCallMetadata | undefined
1011
- )?.thoughtSignature
1105
+ const thoughtSignature = metadata?.thoughtSignature
1012
1106
  // Gemini requires thoughtSignature at the Part level (sibling of
1013
1107
  // functionCall), not nested inside functionCall. Nesting it causes
1014
1108
  // the API to reject the next turn with
@@ -1045,6 +1139,9 @@ export class GeminiTextAdapter<
1045
1139
  },
1046
1140
  })
1047
1141
  } else {
1142
+ const fileUri = isFileSource(part.source)
1143
+ ? fileReferenceFor(part.source, this.name)
1144
+ : part.source.value
1048
1145
  const defaultMimeType = {
1049
1146
  image: 'image/jpeg',
1050
1147
  audio: 'audio/mp3',
@@ -1053,7 +1150,7 @@ export class GeminiTextAdapter<
1053
1150
  }[part.type]
1054
1151
  mediaParts.push({
1055
1152
  fileData: {
1056
- fileUri: part.source.value,
1153
+ fileUri,
1057
1154
  mimeType: part.source.mimeType ?? defaultMimeType,
1058
1155
  },
1059
1156
  })
@@ -2,7 +2,12 @@ import {
2
2
  GenerateVideosOperation,
3
3
  VideoGenerationReferenceType,
4
4
  } from '@google/genai'
5
- import { resolveMediaPrompt } from '@tanstack/ai'
5
+ import {
6
+ fileReferenceFor,
7
+ isFileSource,
8
+ resolveMediaPrompt,
9
+ unsupportedFileSourceError,
10
+ } from '@tanstack/ai'
6
11
  import { BaseVideoAdapter, snapToDurationOption } from '@tanstack/ai/adapters'
7
12
  import { arrayBufferToBase64 } from '@tanstack/ai-utils'
8
13
  import { createGeminiClient, getGeminiApiKeyFromEnv } from '../utils'
@@ -93,6 +98,14 @@ async function imagePartToVeoImage(
93
98
  mimeType: part.source.mimeType || 'image/png',
94
99
  }
95
100
  }
101
+ if (isFileSource(part.source)) {
102
+ // Veo's predict API accepts only inline bytes or a gs:// reference — there's
103
+ // no way to reference a Files API handle here.
104
+ throw unsupportedFileSourceError(
105
+ 'gemini',
106
+ 'for Veo video generation, which needs inline image bytes or a gs:// reference — pass a data: URI or gs:// URL',
107
+ )
108
+ }
96
109
  const url = part.source.value
97
110
  if (url.startsWith('gs://')) {
98
111
  return {
@@ -143,15 +156,21 @@ async function imagePartToVeoImage(
143
156
  function mediaPartToInteractionsContent(
144
157
  part: ImagePart<MediaInputMetadata> | VideoPart<MediaInputMetadata>,
145
158
  ): InteractionContent {
159
+ // A Gemini Files API reference maps to the `uri` field, same as a public
160
+ // URL (mirrors the Interactions text adapter). `fileReferenceFor` throws
161
+ // when another provider issued the handle.
162
+ const sourceValue = isFileSource(part.source)
163
+ ? fileReferenceFor(part.source, 'gemini')
164
+ : part.source.value
146
165
  const mimeType = part.source.mimeType
147
166
  if (part.type === 'image') {
148
167
  return part.source.type === 'data'
149
- ? { type: 'image', data: part.source.value, mime_type: mimeType }
150
- : { type: 'image', uri: part.source.value, mime_type: mimeType }
168
+ ? { type: 'image', data: sourceValue, mime_type: mimeType }
169
+ : { type: 'image', uri: sourceValue, mime_type: mimeType }
151
170
  }
152
171
  return part.source.type === 'data'
153
- ? { type: 'video', data: part.source.value, mime_type: mimeType }
154
- : { type: 'video', uri: part.source.value, mime_type: mimeType }
172
+ ? { type: 'video', data: sourceValue, mime_type: mimeType }
173
+ : { type: 'video', uri: sourceValue, mime_type: mimeType }
155
174
  }
156
175
 
157
176
  /**
@@ -255,6 +274,9 @@ export class GeminiVideoAdapter<
255
274
  GeminiVideoModelDurationByName
256
275
  > {
257
276
  readonly name = 'gemini' as const
277
+ // The Interactions path consumes Gemini Files API references as content
278
+ // `uri`s; the Veo path still rejects them (raw bytes / gs:// only).
279
+ override readonly supportsFileSources = true
258
280
 
259
281
  protected client: GoogleGenAI
260
282
  private readonly allowUrlFetch: boolean
@@ -1,4 +1,4 @@
1
- import { EventType } from '@tanstack/ai'
1
+ import { EventType, fileReferenceFor, isFileSource } from '@tanstack/ai'
2
2
  import { BaseTextAdapter } from '@tanstack/ai/adapters'
3
3
  import { parse as parsePartialJSON } from 'partial-json'
4
4
  import {
@@ -200,6 +200,8 @@ export class GeminiTextInteractionsAdapter<
200
200
  > {
201
201
  override readonly kind = 'text' as const
202
202
  override readonly name = 'gemini-text-interactions' as const
203
+ // Consumes Gemini Files API references (geminiFiles()) as content `uri`s.
204
+ override readonly supportsFileSources = true
203
205
 
204
206
  private readonly client: GoogleGenAI
205
207
  // Tracks the most recent server-assigned interaction id per threadId
@@ -911,6 +913,13 @@ function contentPartToBlock(part: ContentPart): ContentBlock {
911
913
  if (part.type === 'text') {
912
914
  return { type: 'text', text: part.content }
913
915
  }
916
+ // A Gemini Files API handle maps to the `uri` field (isData stays false),
917
+ // same as a public URL. `fileReferenceFor` checks the issuer against
918
+ // 'gemini', the name geminiFiles() stamps on its handles, not this
919
+ // adapter's own name.
920
+ const sourceValue = isFileSource(part.source)
921
+ ? fileReferenceFor(part.source, 'gemini')
922
+ : part.source.value
914
923
  const isData = part.source.type === 'data'
915
924
  switch (part.type) {
916
925
  case 'image': {
@@ -920,8 +929,8 @@ function contentPartToBlock(part: ContentPart): ContentBlock {
920
929
  'image',
921
930
  )
922
931
  return isData
923
- ? { type: 'image', data: part.source.value, mime_type }
924
- : { type: 'image', uri: part.source.value, mime_type }
932
+ ? { type: 'image', data: sourceValue, mime_type }
933
+ : { type: 'image', uri: sourceValue, mime_type }
925
934
  }
926
935
  case 'audio': {
927
936
  const mime_type = validateMime(
@@ -930,8 +939,8 @@ function contentPartToBlock(part: ContentPart): ContentBlock {
930
939
  'audio',
931
940
  )
932
941
  return isData
933
- ? { type: 'audio', data: part.source.value, mime_type }
934
- : { type: 'audio', uri: part.source.value, mime_type }
942
+ ? { type: 'audio', data: sourceValue, mime_type }
943
+ : { type: 'audio', uri: sourceValue, mime_type }
935
944
  }
936
945
  case 'video': {
937
946
  const mime_type = validateMime(
@@ -940,8 +949,8 @@ function contentPartToBlock(part: ContentPart): ContentBlock {
940
949
  'video',
941
950
  )
942
951
  return isData
943
- ? { type: 'video', data: part.source.value, mime_type }
944
- : { type: 'video', uri: part.source.value, mime_type }
952
+ ? { type: 'video', data: sourceValue, mime_type }
953
+ : { type: 'video', uri: sourceValue, mime_type }
945
954
  }
946
955
  case 'document': {
947
956
  const mime_type = validateMime(
@@ -950,8 +959,8 @@ function contentPartToBlock(part: ContentPart): ContentBlock {
950
959
  'document',
951
960
  )
952
961
  return isData
953
- ? { type: 'document', data: part.source.value, mime_type }
954
- : { type: 'document', uri: part.source.value, mime_type }
962
+ ? { type: 'document', data: sourceValue, mime_type }
963
+ : { type: 'document', uri: sourceValue, mime_type }
955
964
  }
956
965
  }
957
966
  }
package/src/index.ts CHANGED
@@ -19,6 +19,14 @@ export {
19
19
  type GeminiSummarizeModel,
20
20
  } from './adapters/summarize'
21
21
 
22
+ // Files adapter - upload media to the Gemini Files API and reference by file URI
23
+ export {
24
+ GeminiFilesAdapter,
25
+ createGeminiFiles,
26
+ geminiFiles,
27
+ type GeminiFilesConfig,
28
+ } from './adapters/files'
29
+
22
30
  // Image adapter
23
31
  export {
24
32
  GeminiImageAdapter,
@@ -1,3 +1,6 @@
1
+ import type { ProviderExecutedToolMetadata } from '@tanstack/ai'
2
+ import type { GroundingMetadata } from '@google/genai'
3
+
1
4
  /**
2
5
  * Gemini-specific metadata types for multimodal content parts.
3
6
  * These types extend the base ContentPart metadata with Gemini-specific options.
@@ -178,6 +181,9 @@ export interface GeminiMessageMetadataByModality {
178
181
  *
179
182
  * @see https://ai.google.dev/gemini-api/docs/thinking
180
183
  */
181
- export interface GeminiToolCallMetadata {
184
+ export interface GeminiToolCallMetadata extends ProviderExecutedToolMetadata {
182
185
  thoughtSignature?: string
186
+ gemini?: {
187
+ groundingMetadata: GroundingMetadata
188
+ }
183
189
  }