@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.
- package/README.md +42 -16
- package/dist/esm/adapters/text.d.ts +9 -1
- package/dist/esm/adapters/text.js +102 -6
- package/dist/esm/adapters/text.js.map +1 -1
- package/dist/esm/adapters/tts.d.ts +5 -1
- package/dist/esm/adapters/tts.js +18 -4
- package/dist/esm/adapters/tts.js.map +1 -1
- package/dist/esm/experimental/text-interactions/adapter.d.ts +1 -0
- package/dist/esm/experimental/text-interactions/adapter.js +103 -3
- package/dist/esm/experimental/text-interactions/adapter.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/text.ts +140 -9
- package/src/adapters/tts.ts +38 -13
- package/src/experimental/text-interactions/adapter.ts +163 -5
package/src/adapters/tts.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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. */
|