@tanstack/ai-gemini 0.10.2 → 0.10.4

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.
@@ -1,4 +1,5 @@
1
1
  import { FinishReason } from '@google/genai'
2
+ import { EventType } from '@tanstack/ai'
2
3
  import { BaseTextAdapter } from '@tanstack/ai/adapters'
3
4
  import { convertToolsToProviderFormat } from '../tools/tool-converter'
4
5
  import {
@@ -33,14 +34,12 @@ import type {
33
34
  TextOptions,
34
35
  } from '@tanstack/ai'
35
36
  import type { ExternalTextProviderOptions } from '../text/text-provider-options'
36
- import type { GeminiMessageMetadataByModality } from '../message-types'
37
+ import type {
38
+ GeminiMessageMetadataByModality,
39
+ GeminiToolCallMetadata,
40
+ } from '../message-types'
37
41
  import type { GeminiClientConfig } from '../utils'
38
42
 
39
- /** Cast an event object to StreamChunk. Adapters construct events with string
40
- * literal types which are structurally compatible with the EventType enum. */
41
- const asChunk = (chunk: Record<string, unknown>) =>
42
- chunk as unknown as StreamChunk
43
-
44
43
  /**
45
44
  * Configuration for Gemini text adapter
46
45
  */
@@ -104,7 +103,8 @@ export class GeminiTextAdapter<
104
103
  TProviderOptions,
105
104
  TInputModalities,
106
105
  GeminiMessageMetadataByModality,
107
- TToolCapabilities
106
+ TToolCapabilities,
107
+ GeminiToolCallMetadata
108
108
  > {
109
109
  readonly kind = 'text' as const
110
110
  readonly name = 'gemini' as const
@@ -132,15 +132,14 @@ export class GeminiTextAdapter<
132
132
 
133
133
  yield* this.processStreamChunks(result, options, logger)
134
134
  } catch (error) {
135
- const timestamp = Date.now()
136
135
  logger.errors('gemini.chatStream fatal', {
137
136
  error,
138
137
  source: 'gemini.chatStream',
139
138
  })
140
- yield asChunk({
141
- type: 'RUN_ERROR',
139
+ yield {
140
+ type: EventType.RUN_ERROR,
142
141
  model: options.model,
143
- timestamp,
142
+ timestamp: Date.now(),
144
143
  message:
145
144
  error instanceof Error
146
145
  ? error.message
@@ -151,7 +150,7 @@ export class GeminiTextAdapter<
151
150
  ? error.message
152
151
  : 'An unknown error occurred during the chat stream.',
153
152
  },
154
- })
153
+ }
155
154
  }
156
155
  }
157
156
 
@@ -236,7 +235,6 @@ export class GeminiTextAdapter<
236
235
  logger: InternalLogger,
237
236
  ): AsyncIterable<StreamChunk> {
238
237
  const model = options.model
239
- const timestamp = Date.now()
240
238
  let accumulatedContent = ''
241
239
  let accumulatedThinking = ''
242
240
  const toolCallMap = new Map<
@@ -267,13 +265,13 @@ export class GeminiTextAdapter<
267
265
  // Emit RUN_STARTED on first chunk
268
266
  if (!hasEmittedRunStarted) {
269
267
  hasEmittedRunStarted = true
270
- yield asChunk({
271
- type: 'RUN_STARTED',
268
+ yield {
269
+ type: EventType.RUN_STARTED,
272
270
  runId,
273
271
  threadId,
274
272
  model,
275
- timestamp,
276
- })
273
+ timestamp: Date.now(),
274
+ }
277
275
  }
278
276
 
279
277
  if (chunk.candidates?.[0]?.content?.parts) {
@@ -289,92 +287,92 @@ export class GeminiTextAdapter<
289
287
  reasoningMessageId = generateId(this.name)
290
288
 
291
289
  // Spec REASONING events
292
- yield asChunk({
293
- type: 'REASONING_START',
290
+ yield {
291
+ type: EventType.REASONING_START,
294
292
  messageId: reasoningMessageId,
295
293
  model,
296
- timestamp,
297
- })
298
- yield asChunk({
299
- type: 'REASONING_MESSAGE_START',
294
+ timestamp: Date.now(),
295
+ }
296
+ yield {
297
+ type: EventType.REASONING_MESSAGE_START,
300
298
  messageId: reasoningMessageId,
301
299
  role: 'reasoning' as const,
302
300
  model,
303
- timestamp,
304
- })
301
+ timestamp: Date.now(),
302
+ }
305
303
 
306
304
  // Legacy STEP events (kept during transition)
307
- yield asChunk({
308
- type: 'STEP_STARTED',
305
+ yield {
306
+ type: EventType.STEP_STARTED,
309
307
  stepName: stepId,
310
308
  stepId,
311
309
  model,
312
- timestamp,
310
+ timestamp: Date.now(),
313
311
  stepType: 'thinking',
314
- })
312
+ }
315
313
  }
316
314
 
317
315
  accumulatedThinking += part.text
318
316
 
319
317
  // Spec REASONING content event
320
- yield asChunk({
321
- type: 'REASONING_MESSAGE_CONTENT',
318
+ yield {
319
+ type: EventType.REASONING_MESSAGE_CONTENT,
322
320
  messageId: reasoningMessageId!,
323
321
  delta: part.text,
324
322
  model,
325
- timestamp,
326
- })
323
+ timestamp: Date.now(),
324
+ }
327
325
 
328
326
  // Legacy STEP event
329
- yield asChunk({
330
- type: 'STEP_FINISHED',
327
+ yield {
328
+ type: EventType.STEP_FINISHED,
331
329
  stepName: stepId || generateId(this.name),
332
330
  stepId: stepId || generateId(this.name),
333
331
  model,
334
- timestamp,
332
+ timestamp: Date.now(),
335
333
  delta: part.text,
336
334
  content: accumulatedThinking,
337
- })
335
+ }
338
336
  } else if (part.text.trim()) {
339
337
  // Close reasoning before text starts
340
338
  if (reasoningMessageId && !hasClosedReasoning) {
341
339
  hasClosedReasoning = true
342
- yield asChunk({
343
- type: 'REASONING_MESSAGE_END',
340
+ yield {
341
+ type: EventType.REASONING_MESSAGE_END,
344
342
  messageId: reasoningMessageId,
345
343
  model,
346
- timestamp,
347
- })
348
- yield asChunk({
349
- type: 'REASONING_END',
344
+ timestamp: Date.now(),
345
+ }
346
+ yield {
347
+ type: EventType.REASONING_END,
350
348
  messageId: reasoningMessageId,
351
349
  model,
352
- timestamp,
353
- })
350
+ timestamp: Date.now(),
351
+ }
354
352
  }
355
353
 
356
354
  // Skip whitespace-only text parts (e.g. "\n" during auto-continuation)
357
355
  // Emit TEXT_MESSAGE_START on first text content
358
356
  if (!hasEmittedTextMessageStart) {
359
357
  hasEmittedTextMessageStart = true
360
- yield asChunk({
361
- type: 'TEXT_MESSAGE_START',
358
+ yield {
359
+ type: EventType.TEXT_MESSAGE_START,
362
360
  messageId,
363
361
  model,
364
- timestamp,
362
+ timestamp: Date.now(),
365
363
  role: 'assistant',
366
- })
364
+ }
367
365
  }
368
366
 
369
367
  accumulatedContent += part.text
370
- yield asChunk({
371
- type: 'TEXT_MESSAGE_CONTENT',
368
+ yield {
369
+ type: EventType.TEXT_MESSAGE_CONTENT,
372
370
  messageId,
373
371
  model,
374
- timestamp,
372
+ timestamp: Date.now(),
375
373
  delta: part.text,
376
374
  content: accumulatedContent,
377
- })
375
+ }
378
376
  }
379
377
  }
380
378
 
@@ -385,6 +383,11 @@ export class GeminiTextAdapter<
385
383
  `${functionCall.name}_${Date.now()}_${nextToolIndex}`
386
384
  const functionArgs = functionCall.args || {}
387
385
 
386
+ // Gemini emits thoughtSignature as a Part-level sibling of
387
+ // functionCall (per @google/genai Part type), not nested inside
388
+ // functionCall itself.
389
+ const partThoughtSignature = part.thoughtSignature || undefined
390
+
388
391
  let toolCallData = toolCallMap.get(toolCallId)
389
392
  if (!toolCallData) {
390
393
  toolCallData = {
@@ -395,11 +398,13 @@ export class GeminiTextAdapter<
395
398
  : JSON.stringify(functionArgs),
396
399
  index: nextToolIndex++,
397
400
  started: false,
398
- thoughtSignature:
399
- (functionCall as any).thoughtSignature || undefined,
401
+ thoughtSignature: partThoughtSignature,
400
402
  }
401
403
  toolCallMap.set(toolCallId, toolCallData)
402
404
  } else {
405
+ if (!toolCallData.thoughtSignature && partThoughtSignature) {
406
+ toolCallData.thoughtSignature = partThoughtSignature
407
+ }
403
408
  try {
404
409
  const existingArgs = JSON.parse(toolCallData.args)
405
410
  const newArgs =
@@ -419,31 +424,31 @@ export class GeminiTextAdapter<
419
424
  // Emit TOOL_CALL_START if not already started
420
425
  if (!toolCallData.started) {
421
426
  toolCallData.started = true
422
- yield asChunk({
423
- type: 'TOOL_CALL_START',
427
+ yield {
428
+ type: EventType.TOOL_CALL_START,
424
429
  toolCallId,
425
430
  toolCallName: toolCallData.name,
426
431
  toolName: toolCallData.name,
427
432
  model,
428
- timestamp,
433
+ timestamp: Date.now(),
429
434
  index: toolCallData.index,
430
435
  ...(toolCallData.thoughtSignature && {
431
- providerMetadata: {
436
+ metadata: {
432
437
  thoughtSignature: toolCallData.thoughtSignature,
433
- },
438
+ } satisfies GeminiToolCallMetadata,
434
439
  }),
435
- })
440
+ }
436
441
  }
437
442
 
438
443
  // Emit TOOL_CALL_ARGS
439
- yield asChunk({
440
- type: 'TOOL_CALL_ARGS',
444
+ yield {
445
+ type: EventType.TOOL_CALL_ARGS,
441
446
  toolCallId,
442
447
  model,
443
- timestamp,
448
+ timestamp: Date.now(),
444
449
  delta: toolCallData.args,
445
450
  args: toolCallData.args,
446
- })
451
+ }
447
452
  }
448
453
  }
449
454
  } else if (chunk.data && chunk.data.trim()) {
@@ -451,24 +456,24 @@ export class GeminiTextAdapter<
451
456
  // Emit TEXT_MESSAGE_START on first text content
452
457
  if (!hasEmittedTextMessageStart) {
453
458
  hasEmittedTextMessageStart = true
454
- yield asChunk({
455
- type: 'TEXT_MESSAGE_START',
459
+ yield {
460
+ type: EventType.TEXT_MESSAGE_START,
456
461
  messageId,
457
462
  model,
458
- timestamp,
463
+ timestamp: Date.now(),
459
464
  role: 'assistant',
460
- })
465
+ }
461
466
  }
462
467
 
463
468
  accumulatedContent += chunk.data
464
- yield asChunk({
465
- type: 'TEXT_MESSAGE_CONTENT',
469
+ yield {
470
+ type: EventType.TEXT_MESSAGE_CONTENT,
466
471
  messageId,
467
472
  model,
468
- timestamp,
473
+ timestamp: Date.now(),
469
474
  delta: chunk.data,
470
475
  content: accumulatedContent,
471
- })
476
+ }
472
477
  }
473
478
 
474
479
  if (chunk.candidates?.[0]?.finishReason) {
@@ -497,15 +502,15 @@ export class GeminiTextAdapter<
497
502
  })
498
503
 
499
504
  // Emit TOOL_CALL_START
500
- yield asChunk({
501
- type: 'TOOL_CALL_START',
505
+ yield {
506
+ type: EventType.TOOL_CALL_START,
502
507
  toolCallId,
503
508
  toolCallName: functionCall.name || '',
504
509
  toolName: functionCall.name || '',
505
510
  model,
506
- timestamp,
511
+ timestamp: Date.now(),
507
512
  index: nextToolIndex - 1,
508
- })
513
+ }
509
514
 
510
515
  // Emit TOOL_CALL_END with parsed input
511
516
  let parsedInput: unknown = {}
@@ -520,15 +525,15 @@ export class GeminiTextAdapter<
520
525
  parsedInput = {}
521
526
  }
522
527
 
523
- yield asChunk({
524
- type: 'TOOL_CALL_END',
528
+ yield {
529
+ type: EventType.TOOL_CALL_END,
525
530
  toolCallId,
526
531
  toolCallName: functionCall.name || '',
527
532
  toolName: functionCall.name || '',
528
533
  model,
529
- timestamp,
534
+ timestamp: Date.now(),
530
535
  input: parsedInput,
531
- })
536
+ }
532
537
  }
533
538
  }
534
539
  }
@@ -544,15 +549,15 @@ export class GeminiTextAdapter<
544
549
  parsedInput = {}
545
550
  }
546
551
 
547
- yield asChunk({
548
- type: 'TOOL_CALL_END',
552
+ yield {
553
+ type: EventType.TOOL_CALL_END,
549
554
  toolCallId,
550
555
  toolCallName: toolCallData.name,
551
556
  toolName: toolCallData.name,
552
557
  model,
553
- timestamp,
558
+ timestamp: Date.now(),
554
559
  input: parsedInput,
555
- })
560
+ }
556
561
  }
557
562
 
558
563
  // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls
@@ -561,11 +566,11 @@ export class GeminiTextAdapter<
561
566
  }
562
567
 
563
568
  if (finishReason === FinishReason.MAX_TOKENS) {
564
- yield asChunk({
565
- type: 'RUN_ERROR',
569
+ yield {
570
+ type: EventType.RUN_ERROR,
566
571
  runId,
567
572
  model,
568
- timestamp,
573
+ timestamp: Date.now(),
569
574
  message:
570
575
  'The response was cut off because the maximum token limit was reached.',
571
576
  code: 'max_tokens',
@@ -574,42 +579,42 @@ export class GeminiTextAdapter<
574
579
  'The response was cut off because the maximum token limit was reached.',
575
580
  code: 'max_tokens',
576
581
  },
577
- })
582
+ }
578
583
  }
579
584
 
580
585
  // Close reasoning events if still open
581
586
  if (reasoningMessageId && !hasClosedReasoning) {
582
587
  hasClosedReasoning = true
583
- yield asChunk({
584
- type: 'REASONING_MESSAGE_END',
588
+ yield {
589
+ type: EventType.REASONING_MESSAGE_END,
585
590
  messageId: reasoningMessageId,
586
591
  model,
587
- timestamp,
588
- })
589
- yield asChunk({
590
- type: 'REASONING_END',
592
+ timestamp: Date.now(),
593
+ }
594
+ yield {
595
+ type: EventType.REASONING_END,
591
596
  messageId: reasoningMessageId,
592
597
  model,
593
- timestamp,
594
- })
598
+ timestamp: Date.now(),
599
+ }
595
600
  }
596
601
 
597
602
  // Emit TEXT_MESSAGE_END if we had text content
598
603
  if (hasEmittedTextMessageStart) {
599
- yield asChunk({
600
- type: 'TEXT_MESSAGE_END',
604
+ yield {
605
+ type: EventType.TEXT_MESSAGE_END,
601
606
  messageId,
602
607
  model,
603
- timestamp,
604
- })
608
+ timestamp: Date.now(),
609
+ }
605
610
  }
606
611
 
607
- yield asChunk({
608
- type: 'RUN_FINISHED',
612
+ yield {
613
+ type: EventType.RUN_FINISHED,
609
614
  runId,
610
615
  threadId,
611
616
  model,
612
- timestamp,
617
+ timestamp: Date.now(),
613
618
  finishReason: toolCallMap.size > 0 ? 'tool_calls' : 'stop',
614
619
  usage: chunk.usageMetadata
615
620
  ? {
@@ -618,7 +623,7 @@ export class GeminiTextAdapter<
618
623
  totalTokens: chunk.usageMetadata.totalTokenCount ?? 0,
619
624
  }
620
625
  : undefined,
621
- })
626
+ }
622
627
  }
623
628
  }
624
629
  }
@@ -707,16 +712,24 @@ export class GeminiTextAdapter<
707
712
  >
708
713
  }
709
714
 
710
- const thoughtSignature = toolCall.providerMetadata
711
- ?.thoughtSignature as string | undefined
712
- parts.push({
715
+ const thoughtSignature = (
716
+ toolCall.metadata as GeminiToolCallMetadata | undefined
717
+ )?.thoughtSignature
718
+ // Gemini requires thoughtSignature at the Part level (sibling of
719
+ // functionCall), not nested inside functionCall. Nesting it causes
720
+ // the API to reject the next turn with
721
+ // "Function call is missing a thought_signature".
722
+ const part: Part = {
713
723
  functionCall: {
714
724
  id: toolCall.id,
715
725
  name: toolCall.function.name,
716
726
  args: parsedArgs,
717
- ...(thoughtSignature && { thoughtSignature }),
718
- } as any,
719
- })
727
+ },
728
+ }
729
+ if (thoughtSignature) {
730
+ part.thoughtSignature = thoughtSignature
731
+ }
732
+ parts.push(part)
720
733
  }
721
734
  }
722
735
 
package/src/index.ts CHANGED
@@ -11,15 +11,12 @@ export {
11
11
  type GeminiTextProviderOptions,
12
12
  } from './adapters/text'
13
13
 
14
- // Summarize adapter
14
+ // Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter
15
15
  export {
16
- GeminiSummarizeAdapter,
17
- GeminiSummarizeModels,
18
16
  createGeminiSummarize,
19
17
  geminiSummarize,
20
- type GeminiSummarizeAdapterOptions,
18
+ type GeminiSummarizeConfig,
21
19
  type GeminiSummarizeModel,
22
- type GeminiSummarizeProviderOptions,
23
20
  } from './adapters/summarize'
24
21
 
25
22
  // Image adapter
@@ -130,3 +130,17 @@ export interface GeminiMessageMetadataByModality {
130
130
  video: GeminiVideoMetadata
131
131
  document: GeminiDocumentMetadata
132
132
  }
133
+
134
+ /**
135
+ * Provider-specific metadata that round-trips with each Gemini tool call.
136
+ *
137
+ * `thoughtSignature` is emitted by Gemini 3.x (and 2.5 thinking) models on
138
+ * the Part containing the `functionCall`. The same signature must be echoed
139
+ * back at the Part level on the next turn or the API rejects the request
140
+ * with `400 INVALID_ARGUMENT: "Function call is missing a thought_signature"`.
141
+ *
142
+ * @see https://ai.google.dev/gemini-api/docs/thinking
143
+ */
144
+ export interface GeminiToolCallMetadata {
145
+ thoughtSignature?: string
146
+ }