@tanstack/ai-gemini 0.29.4 → 0.30.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.
@@ -417,9 +417,7 @@ export class GeminiTextAdapter<
417
417
  try {
418
418
  parsed = JSON.parse(rawText)
419
419
  } catch {
420
- throw new Error(
421
- `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
422
- )
420
+ throw new Error(jsonContentParseError(rawText, 'structured output'))
423
421
  }
424
422
 
425
423
  return {
@@ -442,6 +440,113 @@ export class GeminiTextAdapter<
442
440
  }
443
441
  }
444
442
 
443
+ /**
444
+ * Stream schema-constrained JSON from Gemini natively.
445
+ *
446
+ * `chat({ outputSchema, stream: true })` calls this when the adapter
447
+ * implements it. Without it, the engine buffers `structuredOutput()` and
448
+ * emits one synthetic delta.
449
+ */
450
+ async *structuredOutputStream(
451
+ options: StructuredOutputOptions<GeminiTextProviderOptions>,
452
+ ): AsyncIterable<AdapterYieldChunk> {
453
+ const { chatOptions: requestedChatOptions, outputSchema } = options
454
+ const chatOptions = {
455
+ ...requestedChatOptions,
456
+ runId: requestedChatOptions.runId ?? generateId(this.name),
457
+ }
458
+ const mappedOptions = this.mapCommonOptionsToGemini(chatOptions)
459
+
460
+ try {
461
+ chatOptions.logger.request(
462
+ `activity=structuredOutputStream provider=gemini model=${this.model} messages=${chatOptions.messages.length}`,
463
+ { provider: 'gemini', model: this.model },
464
+ )
465
+ const result = await this.client.models.generateContentStream({
466
+ ...mappedOptions,
467
+ config: {
468
+ ...mappedOptions.config,
469
+ responseMimeType: 'application/json',
470
+ responseSchema: outputSchema,
471
+ },
472
+ })
473
+
474
+ let rawText = ''
475
+ let finished:
476
+ | Extract<AdapterYieldChunk, { type: typeof EventType.RUN_FINISHED }>
477
+ | undefined
478
+ let failed = false
479
+ for await (const chunk of this.processStreamChunks(
480
+ result,
481
+ chatOptions,
482
+ chatOptions.logger,
483
+ )) {
484
+ if (chunk.type === EventType.TEXT_MESSAGE_CONTENT) {
485
+ rawText += chunk.delta
486
+ }
487
+ if (chunk.type === EventType.RUN_ERROR) failed = true
488
+ if (chunk.type === EventType.RUN_FINISHED) {
489
+ finished = chunk
490
+ } else {
491
+ yield chunk
492
+ }
493
+ }
494
+
495
+ if (failed) return
496
+ if (!finished) {
497
+ yield structuredStreamError(
498
+ chatOptions,
499
+ 'Gemini structured-output stream ended without a terminal event',
500
+ 'truncated-stream',
501
+ )
502
+ return
503
+ }
504
+ if (!rawText) {
505
+ yield structuredStreamError(
506
+ chatOptions,
507
+ 'Gemini structured-output stream contained no content',
508
+ 'empty-response',
509
+ )
510
+ return
511
+ }
512
+
513
+ let object: unknown
514
+ try {
515
+ object = JSON.parse(rawText)
516
+ } catch {
517
+ yield structuredStreamError(
518
+ chatOptions,
519
+ jsonContentParseError(rawText, 'Gemini structured-output stream'),
520
+ 'parse-error',
521
+ )
522
+ return
523
+ }
524
+
525
+ yield {
526
+ type: EventType.CUSTOM,
527
+ name: 'structured-output.complete',
528
+ value: { object, raw: rawText },
529
+ model: chatOptions.model,
530
+ timestamp: Date.now(),
531
+ }
532
+ yield { ...finished, timestamp: Date.now() }
533
+ } catch (error) {
534
+ const rawEvent = toRunErrorRawEvent(error)
535
+ const message =
536
+ error instanceof Error
537
+ ? error.message
538
+ : 'An unknown error occurred during structured output streaming.'
539
+ chatOptions.logger.errors('gemini.structuredOutputStream fatal', {
540
+ error,
541
+ source: 'gemini.structuredOutputStream',
542
+ })
543
+ yield {
544
+ ...structuredStreamError(chatOptions, message, 'provider-error'),
545
+ ...(rawEvent !== undefined && { rawEvent }),
546
+ }
547
+ }
548
+ }
549
+
445
550
  /**
446
551
  * Extract text content from a non-streaming response
447
552
  */
@@ -1131,6 +1236,28 @@ export class GeminiTextAdapter<
1131
1236
  }
1132
1237
  }
1133
1238
 
1239
+ function jsonContentParseError(rawText: string, label: string) {
1240
+ const snippet = rawText.slice(0, 200)
1241
+ const ellipsis = rawText.length > 200 ? '...' : ''
1242
+ return `Failed to parse ${label} as JSON. Content: ${snippet}${ellipsis}`
1243
+ }
1244
+
1245
+ function structuredStreamError(
1246
+ options: TextOptions<GeminiTextProviderOptions>,
1247
+ message: string,
1248
+ code: string,
1249
+ ): Extract<AdapterYieldChunk, { type: typeof EventType.RUN_ERROR }> {
1250
+ return {
1251
+ type: EventType.RUN_ERROR,
1252
+ runId: options.runId,
1253
+ model: options.model,
1254
+ timestamp: Date.now(),
1255
+ message,
1256
+ code,
1257
+ error: { message, code },
1258
+ }
1259
+ }
1260
+
1134
1261
  /**
1135
1262
  * Creates a Gemini text adapter with explicit API key.
1136
1263
  * Type resolution happens here at the call site.
@@ -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. */