@tanstack/ai-gemini 0.9.0 → 0.10.0

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.
@@ -74,34 +74,50 @@ export class GeminiImageAdapter<
74
74
  private client: GoogleGenAI
75
75
 
76
76
  constructor(config: GeminiImageConfig, model: TModel) {
77
- super({}, model)
77
+ super(model, config)
78
78
  this.client = createGeminiClient(config)
79
79
  }
80
80
 
81
81
  async generateImages(
82
82
  options: ImageGenerationOptions<GeminiImageProviderOptions>,
83
83
  ): Promise<ImageGenerationResult> {
84
- const { model, prompt } = options
84
+ const { model, prompt, logger } = options
85
85
 
86
- validatePrompt({ prompt, model })
86
+ logger.request(
87
+ `activity=generateImage provider=gemini model=${this.model}`,
88
+ {
89
+ provider: 'gemini',
90
+ model: this.model,
91
+ },
92
+ )
87
93
 
88
- if (this.isGeminiImageModel(model)) {
89
- return this.generateWithGeminiApi(options)
90
- }
94
+ try {
95
+ validatePrompt({ prompt, model })
91
96
 
92
- // Imagen models path (generateImages API)
93
- validateImageSize(model, options.size)
94
- validateNumberOfImages(model, options.numberOfImages)
97
+ if (this.isGeminiImageModel(model)) {
98
+ return await this.generateWithGeminiApi(options)
99
+ }
95
100
 
96
- const config = this.buildImagenConfig(options)
101
+ // Imagen models path (generateImages API)
102
+ validateImageSize(model, options.size)
103
+ validateNumberOfImages(model, options.numberOfImages)
97
104
 
98
- const response = await this.client.models.generateImages({
99
- model,
100
- prompt,
101
- config,
102
- })
105
+ const config = this.buildImagenConfig(options)
103
106
 
104
- return this.transformImagenResponse(model, response)
107
+ const response = await this.client.models.generateImages({
108
+ model,
109
+ prompt,
110
+ config,
111
+ })
112
+
113
+ return this.transformImagenResponse(model, response)
114
+ } catch (error) {
115
+ logger.errors('gemini.generateImage fatal', {
116
+ error,
117
+ source: 'gemini.generateImage',
118
+ })
119
+ throw error
120
+ }
105
121
  }
106
122
 
107
123
  private isGeminiImageModel(model: string): boolean {
@@ -122,8 +138,24 @@ export class GeminiImageAdapter<
122
138
  ? `${prompt} Generate ${numberOfImages} distinct images.`
123
139
  : prompt
124
140
 
141
+ // GeminiImageProviderOptions is Imagen-shaped — most fields
142
+ // (personGeneration, safetyFilterLevel, addWatermark, outputMimeType,
143
+ // outputCompressionQuality, guidanceScale, enhancePrompt,
144
+ // includeSafetyAttributes, includeRaiReason, outputGcsUri, labels,
145
+ // negativePrompt, language) are only valid on GenerateImagesConfig and
146
+ // would be rejected by the Gemini-native generateContent path. Pick only
147
+ // the fields that are valid on GenerateContentConfig instead of spreading
148
+ // the whole options object.
149
+ const nativeConfig: GenerateContentConfig = {}
150
+ if (modelOptions?.seed !== undefined) {
151
+ nativeConfig.seed = modelOptions.seed
152
+ }
153
+
125
154
  const config: GenerateContentConfig = {
126
- // Include TEXT so the model can interleave descriptions between images
155
+ ...nativeConfig,
156
+ // Include TEXT so the model can interleave descriptions between images.
157
+ // IMPORTANT: responseModalities is a protected default — set it AFTER
158
+ // nativeConfig so nothing can silently disable image output.
127
159
  responseModalities: ['TEXT', 'IMAGE'],
128
160
  ...(parsedSize && {
129
161
  imageConfig: {
@@ -135,7 +167,6 @@ export class GeminiImageAdapter<
135
167
  }),
136
168
  },
137
169
  }),
138
- ...modelOptions,
139
170
  }
140
171
 
141
172
  const response = await this.client.models.generateContent({
@@ -152,6 +183,7 @@ export class GeminiImageAdapter<
152
183
  response: GenerateContentResponse,
153
184
  ): ImageGenerationResult {
154
185
  const images: Array<GeneratedImage> = []
186
+ const textParts: Array<string> = []
155
187
  const parts = response.candidates?.[0]?.content?.parts ?? []
156
188
 
157
189
  for (const part of parts) {
@@ -161,9 +193,23 @@ export class GeminiImageAdapter<
161
193
  part.inlineData.data.length > 0
162
194
  ) {
163
195
  images.push({ b64Json: part.inlineData.data })
196
+ } else if (typeof part.text === 'string' && part.text.length > 0) {
197
+ textParts.push(part.text)
164
198
  }
165
199
  }
166
200
 
201
+ // If the model returned only text parts (for example a safety refusal
202
+ // or a "can't do that" message), surface the text instead of silently
203
+ // resolving to an empty images array — otherwise callers can't tell a
204
+ // generation failure apart from a genuine empty response.
205
+ if (images.length === 0) {
206
+ const reason =
207
+ textParts.length > 0
208
+ ? `: ${textParts.join(' ').trim()}`
209
+ : ' (no inline image or text parts were returned).'
210
+ throw new Error(`Gemini ${model} returned no images${reason}`)
211
+ }
212
+
167
213
  return {
168
214
  id: generateId(this.name),
169
215
  model,
@@ -189,12 +235,43 @@ export class GeminiImageAdapter<
189
235
  model: string,
190
236
  response: GenerateImagesResponse,
191
237
  ): ImageGenerationResult {
192
- const images: Array<GeneratedImage> = (response.generatedImages ?? []).map(
193
- (item) => ({
194
- b64Json: item.image?.imageBytes,
195
- revisedPrompt: item.enhancedPrompt,
196
- }),
197
- )
238
+ const entries = response.generatedImages ?? []
239
+ const images: Array<GeneratedImage> = []
240
+ const filterReasons: Array<string> = []
241
+
242
+ for (const item of entries) {
243
+ const b64Json = item.image?.imageBytes
244
+ if (b64Json) {
245
+ images.push({ b64Json, revisedPrompt: item.enhancedPrompt })
246
+ continue
247
+ }
248
+ // Imagen can drop individual entries with a raiFilteredReason when
249
+ // Responsible-AI filters fire. Preserve the reason so callers can
250
+ // surface it instead of silently getting back fewer images.
251
+ const reason = (item as { raiFilteredReason?: string }).raiFilteredReason
252
+ if (reason) {
253
+ filterReasons.push(reason)
254
+ }
255
+ }
256
+
257
+ // Every entry was filtered — no usable images to return. Throw rather
258
+ // than resolve to an empty array so the caller is forced to handle the
259
+ // failure mode explicitly.
260
+ if (entries.length > 0 && images.length === 0) {
261
+ const joined = filterReasons.length > 0 ? filterReasons.join('; ') : ''
262
+ throw new Error(
263
+ `Imagen ${model} returned no images: all ${entries.length} generated image(s) were filtered by Responsible-AI${joined ? ` (${joined})` : ''}.`,
264
+ )
265
+ }
266
+
267
+ // Partial filter: surface via console.warn since ImageGenerationResult
268
+ // has no warnings field. Callers that care can still inspect the count
269
+ // mismatch between requested and returned images.
270
+ if (filterReasons.length > 0 && typeof console !== 'undefined') {
271
+ console.warn(
272
+ `[gemini-image] ${filterReasons.length} of ${entries.length} images from ${model} were filtered by Responsible-AI: ${filterReasons.join('; ')}`,
273
+ )
274
+ }
198
275
 
199
276
  return {
200
277
  id: generateId(this.name),
@@ -81,8 +81,14 @@ export class GeminiSummarizeAdapter<
81
81
  }
82
82
 
83
83
  async summarize(options: SummarizationOptions): Promise<SummarizationResult> {
84
+ const { logger } = options
84
85
  const model = options.model
85
86
 
87
+ logger.request(`activity=summarize provider=gemini`, {
88
+ provider: 'gemini',
89
+ model,
90
+ })
91
+
86
92
  // Build the system prompt based on format
87
93
  const formatInstructions = this.getFormatInstructions(options.style)
88
94
  const lengthInstructions = options.maxLength
@@ -91,40 +97,49 @@ export class GeminiSummarizeAdapter<
91
97
 
92
98
  const systemPrompt = `You are a helpful assistant that summarizes text. ${formatInstructions}${lengthInstructions}`
93
99
 
94
- const response = await this.client.models.generateContent({
95
- model,
96
- contents: [
97
- {
98
- role: 'user',
99
- parts: [
100
- { text: `Please summarize the following:\n\n${options.text}` },
101
- ],
100
+ try {
101
+ const response = await this.client.models.generateContent({
102
+ model,
103
+ contents: [
104
+ {
105
+ role: 'user',
106
+ parts: [
107
+ { text: `Please summarize the following:\n\n${options.text}` },
108
+ ],
109
+ },
110
+ ],
111
+ config: {
112
+ systemInstruction: systemPrompt,
102
113
  },
103
- ],
104
- config: {
105
- systemInstruction: systemPrompt,
106
- },
107
- })
114
+ })
108
115
 
109
- const summary = response.text ?? ''
110
- const inputTokens = response.usageMetadata?.promptTokenCount ?? 0
111
- const outputTokens = response.usageMetadata?.candidatesTokenCount ?? 0
116
+ const summary = response.text ?? ''
117
+ const inputTokens = response.usageMetadata?.promptTokenCount ?? 0
118
+ const outputTokens = response.usageMetadata?.candidatesTokenCount ?? 0
112
119
 
113
- return {
114
- id: generateId('sum'),
115
- model,
116
- summary,
117
- usage: {
118
- promptTokens: inputTokens,
119
- completionTokens: outputTokens,
120
- totalTokens: inputTokens + outputTokens,
121
- },
120
+ return {
121
+ id: generateId('sum'),
122
+ model,
123
+ summary,
124
+ usage: {
125
+ promptTokens: inputTokens,
126
+ completionTokens: outputTokens,
127
+ totalTokens: inputTokens + outputTokens,
128
+ },
129
+ }
130
+ } catch (error) {
131
+ logger.errors('gemini.summarize fatal', {
132
+ error,
133
+ source: 'gemini.summarize',
134
+ })
135
+ throw error
122
136
  }
123
137
  }
124
138
 
125
139
  async *summarizeStream(
126
140
  options: SummarizationOptions,
127
141
  ): AsyncIterable<StreamChunk> {
142
+ const { logger } = options
128
143
  const model = options.model
129
144
  const id = generateId('sum')
130
145
  let accumulatedContent = ''
@@ -139,69 +154,85 @@ export class GeminiSummarizeAdapter<
139
154
 
140
155
  const systemPrompt = `You are a helpful assistant that summarizes text. ${formatInstructions}${lengthInstructions}`
141
156
 
142
- const result = await this.client.models.generateContentStream({
157
+ logger.request(`activity=summarize provider=gemini`, {
158
+ provider: 'gemini',
143
159
  model,
144
- contents: [
145
- {
146
- role: 'user',
147
- parts: [
148
- { text: `Please summarize the following:\n\n${options.text}` },
149
- ],
150
- },
151
- ],
152
- config: {
153
- systemInstruction: systemPrompt,
154
- },
160
+ stream: true,
155
161
  })
156
162
 
157
- for await (const chunk of result) {
158
- // Track usage metadata
159
- if (chunk.usageMetadata) {
160
- inputTokens = chunk.usageMetadata.promptTokenCount ?? inputTokens
161
- outputTokens = chunk.usageMetadata.candidatesTokenCount ?? outputTokens
162
- }
163
+ try {
164
+ const result = await this.client.models.generateContentStream({
165
+ model,
166
+ contents: [
167
+ {
168
+ role: 'user',
169
+ parts: [
170
+ { text: `Please summarize the following:\n\n${options.text}` },
171
+ ],
172
+ },
173
+ ],
174
+ config: {
175
+ systemInstruction: systemPrompt,
176
+ },
177
+ })
163
178
 
164
- if (chunk.candidates?.[0]?.content?.parts) {
165
- for (const part of chunk.candidates[0].content.parts) {
166
- if (part.text) {
167
- accumulatedContent += part.text
168
- yield asChunk({
169
- type: 'TEXT_MESSAGE_CONTENT',
170
- messageId: id,
171
- model,
172
- timestamp: Date.now(),
173
- delta: part.text,
174
- content: accumulatedContent,
175
- })
179
+ for await (const chunk of result) {
180
+ logger.provider(`provider=gemini`, { chunk })
181
+ // Track usage metadata
182
+ if (chunk.usageMetadata) {
183
+ inputTokens = chunk.usageMetadata.promptTokenCount ?? inputTokens
184
+ outputTokens =
185
+ chunk.usageMetadata.candidatesTokenCount ?? outputTokens
186
+ }
187
+
188
+ if (chunk.candidates?.[0]?.content?.parts) {
189
+ for (const part of chunk.candidates[0].content.parts) {
190
+ if (part.text) {
191
+ accumulatedContent += part.text
192
+ yield asChunk({
193
+ type: 'TEXT_MESSAGE_CONTENT',
194
+ messageId: id,
195
+ model,
196
+ timestamp: Date.now(),
197
+ delta: part.text,
198
+ content: accumulatedContent,
199
+ })
200
+ }
176
201
  }
177
202
  }
178
- }
179
203
 
180
- // Check for finish reason
181
- const finishReason = chunk.candidates?.[0]?.finishReason
182
- if (
183
- finishReason === FinishReason.STOP ||
184
- finishReason === FinishReason.MAX_TOKENS ||
185
- finishReason === FinishReason.SAFETY
186
- ) {
187
- yield asChunk({
188
- type: 'RUN_FINISHED',
189
- runId: id,
190
- model,
191
- timestamp: Date.now(),
192
- finishReason:
193
- finishReason === FinishReason.STOP
194
- ? 'stop'
195
- : finishReason === FinishReason.MAX_TOKENS
196
- ? 'length'
197
- : 'content_filter',
198
- usage: {
199
- promptTokens: inputTokens,
200
- completionTokens: outputTokens,
201
- totalTokens: inputTokens + outputTokens,
202
- },
203
- })
204
+ // Check for finish reason
205
+ const finishReason = chunk.candidates?.[0]?.finishReason
206
+ if (
207
+ finishReason === FinishReason.STOP ||
208
+ finishReason === FinishReason.MAX_TOKENS ||
209
+ finishReason === FinishReason.SAFETY
210
+ ) {
211
+ yield asChunk({
212
+ type: 'RUN_FINISHED',
213
+ runId: id,
214
+ model,
215
+ timestamp: Date.now(),
216
+ finishReason:
217
+ finishReason === FinishReason.STOP
218
+ ? 'stop'
219
+ : finishReason === FinishReason.MAX_TOKENS
220
+ ? 'length'
221
+ : 'content_filter',
222
+ usage: {
223
+ promptTokens: inputTokens,
224
+ completionTokens: outputTokens,
225
+ totalTokens: inputTokens + outputTokens,
226
+ },
227
+ })
228
+ }
204
229
  }
230
+ } catch (error) {
231
+ logger.errors('gemini.summarize fatal', {
232
+ error,
233
+ source: 'gemini.summarize',
234
+ })
235
+ throw error
205
236
  }
206
237
  }
207
238
 
@@ -16,6 +16,7 @@ import type {
16
16
  StructuredOutputOptions,
17
17
  StructuredOutputResult,
18
18
  } from '@tanstack/ai/adapters'
19
+ import type { InternalLogger } from '@tanstack/ai/adapter-internals'
19
20
  import type {
20
21
  Content,
21
22
  GenerateContentParameters,
@@ -119,14 +120,23 @@ export class GeminiTextAdapter<
119
120
  options: TextOptions<GeminiTextProviderOptions>,
120
121
  ): AsyncIterable<StreamChunk> {
121
122
  const mappedOptions = this.mapCommonOptionsToGemini(options)
123
+ const { logger } = options
122
124
 
123
125
  try {
126
+ logger.request(
127
+ `activity=chat provider=gemini model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`,
128
+ { provider: 'gemini', model: this.model },
129
+ )
124
130
  const result =
125
131
  await this.client.models.generateContentStream(mappedOptions)
126
132
 
127
- yield* this.processStreamChunks(result, options)
133
+ yield* this.processStreamChunks(result, options, logger)
128
134
  } catch (error) {
129
135
  const timestamp = Date.now()
136
+ logger.errors('gemini.chatStream fatal', {
137
+ error,
138
+ source: 'gemini.chatStream',
139
+ })
130
140
  yield asChunk({
131
141
  type: 'RUN_ERROR',
132
142
  model: options.model,
@@ -154,10 +164,15 @@ export class GeminiTextAdapter<
154
164
  options: StructuredOutputOptions<GeminiTextProviderOptions>,
155
165
  ): Promise<StructuredOutputResult<unknown>> {
156
166
  const { chatOptions, outputSchema } = options
167
+ const { logger } = chatOptions
157
168
 
158
169
  const mappedOptions = this.mapCommonOptionsToGemini(chatOptions)
159
170
 
160
171
  try {
172
+ logger.request(
173
+ `activity=chat provider=gemini model=${this.model} messages=${chatOptions.messages.length} tools=${chatOptions.tools?.length ?? 0} stream=false`,
174
+ { provider: 'gemini', model: this.model },
175
+ )
161
176
  // Add structured output configuration
162
177
  const result = await this.client.models.generateContent({
163
178
  ...mappedOptions,
@@ -186,6 +201,10 @@ export class GeminiTextAdapter<
186
201
  rawText,
187
202
  }
188
203
  } catch (error) {
204
+ logger.errors('gemini.structuredOutput fatal', {
205
+ error,
206
+ source: 'gemini.structuredOutput',
207
+ })
189
208
  throw new Error(
190
209
  error instanceof Error
191
210
  ? error.message
@@ -214,6 +233,7 @@ export class GeminiTextAdapter<
214
233
  private async *processStreamChunks(
215
234
  result: AsyncGenerator<GenerateContentResponse, unknown, unknown>,
216
235
  options: TextOptions<GeminiTextProviderOptions>,
236
+ logger: InternalLogger,
217
237
  ): AsyncIterable<StreamChunk> {
218
238
  const model = options.model
219
239
  const timestamp = Date.now()
@@ -243,6 +263,7 @@ export class GeminiTextAdapter<
243
263
  let hasEmittedStepStarted = false
244
264
 
245
265
  for await (const chunk of result) {
266
+ logger.provider(`provider=gemini`, { chunk })
246
267
  // Emit RUN_STARTED on first chunk
247
268
  if (!hasEmittedRunStarted) {
248
269
  hasEmittedRunStarted = true