@tanstack/ai-gemini 0.29.4 → 0.31.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.
@@ -7,7 +7,7 @@ import {
7
7
  import { GEMINI_TTS_VOICES } from '../model-meta'
8
8
  import { buildGeminiUsage } from '../usage'
9
9
  import type { GEMINI_TTS_MODELS, GeminiTTSVoice } from '../model-meta'
10
- import type { TTSOptions, TTSResult } from '@tanstack/ai'
10
+ import type { TTSCapabilities, TTSOptions, TTSResult } from '@tanstack/ai'
11
11
  import type { GoogleGenAI, SpeechConfig } from '@google/genai'
12
12
  import type { GeminiClientConfig } from '../utils/client'
13
13
 
@@ -122,6 +122,11 @@ export class GeminiTTSAdapter<
122
122
  > extends BaseTTSAdapter<TModel, GeminiTTSProviderOptions> {
123
123
  readonly name = 'gemini' as const
124
124
 
125
+ /**
126
+ * Gemini multi-speaker TTS tops out at 2 voices, and reports no timings.
127
+ */
128
+ override readonly capabilities: TTSCapabilities = { maxSpeakers: 2 }
129
+
125
130
  private readonly client: GoogleGenAI
126
131
 
127
132
  constructor(config: GeminiTTSConfig, model: TModel) {
@@ -138,7 +143,7 @@ export class GeminiTTSAdapter<
138
143
  async generateSpeech(
139
144
  options: TTSOptions<GeminiTTSProviderOptions>,
140
145
  ): Promise<TTSResult> {
141
- const { model, text, modelOptions, voice, logger } = options
146
+ const { model, modelOptions, voice, logger, turns } = options
142
147
 
143
148
  logger.request(`activity=generateSpeech provider=gemini model=${model}`, {
144
149
  provider: 'gemini',
@@ -146,8 +151,24 @@ export class GeminiTTSAdapter<
146
151
  })
147
152
 
148
153
  const speechConfig: SpeechConfig = {}
149
-
150
- if (modelOptions?.multiSpeakerVoiceConfig) {
154
+ // Gemini has no turn structure on the wire: speakers are named in the
155
+ // prompt and mapped to voices in the config. `turns` carries voice names,
156
+ // so the voice name doubles as the speaker label.
157
+ const text = turns
158
+ ? turns.map((turn) => `${turn.voice}: ${turn.text}`).join('\n')
159
+ : options.text
160
+
161
+ if (turns) {
162
+ const voiceNames = [...new Set(turns.map((turn) => turn.voice))].map(
163
+ toGeminiVoice,
164
+ )
165
+ speechConfig.multiSpeakerVoiceConfig = {
166
+ speakerVoiceConfigs: voiceNames.map((voiceName) => ({
167
+ speaker: voiceName,
168
+ voiceConfig: { prebuiltVoiceConfig: { voiceName } },
169
+ })),
170
+ }
171
+ } else if (modelOptions?.multiSpeakerVoiceConfig) {
151
172
  // Validate multi-speaker config: 1 or 2 speakers allowed.
152
173
  const speakerConfigs =
153
174
  modelOptions.multiSpeakerVoiceConfig.speakerVoiceConfigs
@@ -168,15 +189,8 @@ export class GeminiTTSAdapter<
168
189
  // modelOptions.voiceConfig is supplied its values win — but we still
169
190
  // fall back to `voice` / 'Kore' if the supplied voiceConfig is missing
170
191
  // prebuiltVoiceConfig.voiceName.
171
- if (
172
- voice !== undefined &&
173
- !(GEMINI_TTS_VOICES as ReadonlyArray<string>).includes(voice)
174
- ) {
175
- throw new Error(
176
- `Invalid Gemini TTS voice "${voice}". Valid voices are: ${GEMINI_TTS_VOICES.join(', ')}.`,
177
- )
178
- }
179
- const defaultVoiceName = (voice as GeminiTTSVoice | undefined) ?? 'Kore'
192
+ const defaultVoiceName =
193
+ voice !== undefined ? toGeminiVoice(voice) : 'Kore'
180
194
  const supplied = modelOptions?.voiceConfig
181
195
  const resolvedVoiceName =
182
196
  supplied?.prebuiltVoiceConfig?.voiceName ?? defaultVoiceName
@@ -282,6 +296,17 @@ export class GeminiTTSAdapter<
282
296
  }
283
297
  }
284
298
 
299
+ /** Narrow a caller-supplied voice string to a known Gemini voice, or throw. */
300
+ function toGeminiVoice(voice: string): GeminiTTSVoice {
301
+ const match = GEMINI_TTS_VOICES.find((known) => known === voice)
302
+ if (!match) {
303
+ throw new Error(
304
+ `Invalid Gemini TTS voice "${voice}". Valid voices are: ${GEMINI_TTS_VOICES.join(', ')}.`,
305
+ )
306
+ }
307
+ return match
308
+ }
309
+
285
310
  function parsePcmMimeType(
286
311
  mimeType: string,
287
312
  ): { sampleRate: number; channels: number; bitsPerSample: number } | undefined {
@@ -279,7 +279,7 @@ export class GeminiTextInteractionsAdapter<
279
279
  const stream = (await this.client.interactions.create(
280
280
  { ...request, stream: true } as GeminiInteractionsRequestBody &
281
281
  Parameters<typeof this.client.interactions.create>[0],
282
- { signal: options.abortController?.signal },
282
+ { signal: abortSignalFromOptions(options) },
283
283
  )) as AsyncIterable<InteractionSSEEvent>
284
284
 
285
285
  for await (const chunk of translateInteractionEvents(
@@ -427,7 +427,7 @@ export class GeminiTextInteractionsAdapter<
427
427
  )
428
428
  const result = (await this.client.interactions.create(
429
429
  request as Parameters<typeof this.client.interactions.create>[0],
430
- { signal: chatOptions.abortController?.signal },
430
+ { signal: abortSignalFromOptions(chatOptions) },
431
431
  )) as Interaction
432
432
 
433
433
  const rawText = extractTextFromInteraction(result)
@@ -442,9 +442,7 @@ export class GeminiTextInteractionsAdapter<
442
442
  try {
443
443
  parsed = JSON.parse(rawText)
444
444
  } catch {
445
- throw new Error(
446
- `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
447
- )
445
+ throw new Error(jsonContentParseError(rawText, 'structured output'))
448
446
  }
449
447
 
450
448
  return { data: parsed, rawText }
@@ -463,6 +461,166 @@ export class GeminiTextInteractionsAdapter<
463
461
  )
464
462
  }
465
463
  }
464
+
465
+ async *structuredOutputStream(
466
+ options: StructuredOutputOptions<GeminiTextInteractionsProviderOptions>,
467
+ ): AsyncIterable<AdapterYieldChunk> {
468
+ const { chatOptions, outputSchema } = options
469
+ const runId = chatOptions.runId ?? generateId(this.name)
470
+ const threadId = chatOptions.threadId ?? generateId(this.name)
471
+ const timestamp = Date.now()
472
+ const effectivePreviousInteractionId =
473
+ chatOptions.modelOptions?.previous_interaction_id ??
474
+ this.interactionIdByThread.get(threadId)
475
+ const baseRequest = buildInteractionsRequest({
476
+ ...chatOptions,
477
+ modelOptions: {
478
+ ...chatOptions.modelOptions,
479
+ previous_interaction_id: effectivePreviousInteractionId,
480
+ },
481
+ })
482
+ const request: GeminiInteractionsRequestBody = {
483
+ ...baseRequest,
484
+ stream: true,
485
+ response_format: {
486
+ type: 'text',
487
+ mime_type: 'application/json',
488
+ schema: outputSchema,
489
+ },
490
+ }
491
+
492
+ try {
493
+ chatOptions.logger.request(
494
+ `activity=structuredOutputStream provider=gemini-text-interactions model=${this.model} messages=${chatOptions.messages.length}`,
495
+ { provider: this.name, model: this.model, request },
496
+ )
497
+ const stream = (await this.client.interactions.create(
498
+ request as GeminiInteractionsRequestBody &
499
+ Parameters<typeof this.client.interactions.create>[0],
500
+ { signal: abortSignalFromOptions(chatOptions) },
501
+ )) as AsyncIterable<InteractionSSEEvent>
502
+
503
+ let rawText = ''
504
+ let finished:
505
+ | Extract<AdapterYieldChunk, { type: typeof EventType.RUN_FINISHED }>
506
+ | undefined
507
+ let failed = false
508
+ for await (const chunk of translateInteractionEvents(
509
+ stream,
510
+ chatOptions.model,
511
+ runId,
512
+ threadId,
513
+ chatOptions.parentRunId,
514
+ timestamp,
515
+ this.name,
516
+ chatOptions.logger,
517
+ )) {
518
+ if (chunk.type === EventType.TEXT_MESSAGE_CONTENT)
519
+ rawText += chunk.delta
520
+ if (
521
+ chunk.type === EventType.CUSTOM &&
522
+ chunk.name === 'gemini.interactionId'
523
+ ) {
524
+ const value =
525
+ chunk.value as GeminiInteractionsCustomEventValue<'gemini.interactionId'>
526
+ this.interactionIdByThread.set(threadId, value.interactionId)
527
+ }
528
+ if (chunk.type === EventType.RUN_ERROR) failed = true
529
+ if (chunk.type === EventType.RUN_FINISHED) finished = chunk
530
+ else yield chunk
531
+ }
532
+
533
+ if (failed) return
534
+ if (!finished) {
535
+ yield interactionsStructuredStreamError(
536
+ chatOptions,
537
+ runId,
538
+ 'Gemini Interactions structured-output stream ended without a terminal event',
539
+ 'truncated-stream',
540
+ )
541
+ return
542
+ }
543
+ if (!rawText) {
544
+ yield interactionsStructuredStreamError(
545
+ chatOptions,
546
+ runId,
547
+ 'Gemini Interactions structured-output stream contained no content',
548
+ 'empty-response',
549
+ )
550
+ return
551
+ }
552
+ let object: unknown
553
+ try {
554
+ object = JSON.parse(rawText)
555
+ } catch {
556
+ yield interactionsStructuredStreamError(
557
+ chatOptions,
558
+ runId,
559
+ jsonContentParseError(
560
+ rawText,
561
+ 'Gemini Interactions structured-output stream',
562
+ ),
563
+ 'parse-error',
564
+ )
565
+ return
566
+ }
567
+ yield {
568
+ type: EventType.CUSTOM,
569
+ name: 'structured-output.complete',
570
+ value: { object, raw: rawText },
571
+ model: chatOptions.model,
572
+ timestamp: Date.now(),
573
+ }
574
+ yield { ...finished, timestamp: Date.now() }
575
+ } catch (error) {
576
+ const message =
577
+ error instanceof Error
578
+ ? error.message
579
+ : 'An unknown error occurred during structured output streaming.'
580
+ chatOptions.logger.errors(
581
+ 'gemini-text-interactions.structuredOutputStream fatal',
582
+ { error, source: 'gemini-text-interactions.structuredOutputStream' },
583
+ )
584
+ yield interactionsStructuredStreamError(
585
+ chatOptions,
586
+ runId,
587
+ message,
588
+ 'provider-error',
589
+ )
590
+ }
591
+ }
592
+ }
593
+
594
+ function abortSignalFromOptions(
595
+ options: Pick<
596
+ TextOptions<GeminiTextInteractionsProviderOptions>,
597
+ 'request' | 'abortController'
598
+ >,
599
+ ) {
600
+ return options.request?.signal ?? options.abortController?.signal
601
+ }
602
+
603
+ function jsonContentParseError(rawText: string, label: string) {
604
+ const snippet = rawText.slice(0, 200)
605
+ const ellipsis = rawText.length > 200 ? '...' : ''
606
+ return `Failed to parse ${label} as JSON. Content: ${snippet}${ellipsis}`
607
+ }
608
+
609
+ function interactionsStructuredStreamError(
610
+ options: TextOptions<GeminiTextInteractionsProviderOptions>,
611
+ runId: string,
612
+ message: string,
613
+ code: string,
614
+ ): Extract<AdapterYieldChunk, { type: typeof EventType.RUN_ERROR }> {
615
+ return {
616
+ type: EventType.RUN_ERROR,
617
+ runId,
618
+ model: options.model,
619
+ timestamp: Date.now(),
620
+ message,
621
+ code,
622
+ error: { message, code },
623
+ }
466
624
  }
467
625
 
468
626
  /** @experimental Interactions API is in Beta. */