ai 7.0.149 → 7.0.150

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.
@@ -3,6 +3,13 @@ import { generateSpeech } from './generate-speech';
3
3
  import { logWarnings } from '../logger/log-warnings';
4
4
 
5
5
  export { generateSpeech } from './generate-speech';
6
+ export { streamSpeech as experimental_streamSpeech } from './stream-speech';
7
+ export type {
8
+ SpeechStreamPart as Experimental_SpeechStreamPart,
9
+ SpeechStreamUsage as Experimental_SpeechStreamUsage,
10
+ SpeechTextPart as Experimental_SpeechTextPart,
11
+ StreamSpeechResult as Experimental_StreamSpeechResult,
12
+ } from './stream-speech-result';
6
13
  export type { SpeechResult } from './generate-speech-result';
7
14
  export type { GeneratedAudioFile } from './generated-audio-file';
8
15
  export type {
@@ -0,0 +1,17 @@
1
+ import type {
2
+ Experimental_StreamingSpeechModelV4,
3
+ SpeechModelV4,
4
+ } from '@ai-sdk/provider';
5
+
6
+ /**
7
+ * Whether a resolved speech model implements the experimental streaming
8
+ * speech extension (`Experimental_StreamingSpeechModelV4`).
9
+ */
10
+ export function isStreamingSpeechModel(
11
+ model: SpeechModelV4,
12
+ ): model is Experimental_StreamingSpeechModelV4 {
13
+ return (
14
+ typeof (model as Partial<Experimental_StreamingSpeechModelV4>).doStream ===
15
+ 'function'
16
+ );
17
+ }
@@ -0,0 +1,95 @@
1
+ import type {
2
+ Experimental_SpeechModelV4StreamUsage,
3
+ JSONObject,
4
+ SharedV4AudioFormat,
5
+ SharedV4ProviderMetadata,
6
+ } from '@ai-sdk/provider';
7
+ import type { ProviderOptions } from '@ai-sdk/provider-utils';
8
+ import type { SpeechModelResponseMetadata } from '../types/speech-model-response-metadata';
9
+ import type { Warning } from '../types/warning';
10
+ import type { AsyncIterableStream } from '../util/async-iterable-stream';
11
+
12
+ /**
13
+ * A piece of text to synthesize. Pass a stream of these (or mix them with
14
+ * plain strings) to give parts their own voice, e.g. for dialogue.
15
+ */
16
+ export type SpeechTextPart = {
17
+ type: 'text';
18
+ text: string;
19
+
20
+ /**
21
+ * The voice that speaks this part (provider-specific, like the call's
22
+ * `voice`). Single-voice providers reject a voice that differs from the
23
+ * call's voice.
24
+ */
25
+ voice?: string;
26
+
27
+ /**
28
+ * Additional provider-specific options for this part.
29
+ */
30
+ providerOptions?: ProviderOptions;
31
+ };
32
+
33
+ export type SpeechStreamPart =
34
+ | {
35
+ /**
36
+ * Audio chunk in generation order. Chunks are not necessarily
37
+ * standalone files; concatenate them to reconstruct the audio. A chunk
38
+ * is empty only when it carries `providerMetadata` (e.g. a turn marker).
39
+ */
40
+ type: 'audio';
41
+ id?: string;
42
+ audio: Uint8Array;
43
+ audioFormat: SharedV4AudioFormat;
44
+ providerMetadata?: SharedV4ProviderMetadata;
45
+ }
46
+ | {
47
+ type: 'raw';
48
+ rawValue: unknown;
49
+ }
50
+ | {
51
+ type: 'error';
52
+ error: unknown;
53
+ };
54
+
55
+ export type SpeechStreamUsage = Experimental_SpeechModelV4StreamUsage;
56
+
57
+ export interface StreamSpeechResult {
58
+ /**
59
+ * Usage information reported for the call, if available.
60
+ */
61
+ readonly usage: PromiseLike<SpeechStreamUsage>;
62
+
63
+ /**
64
+ * Warnings for the call, e.g. unsupported settings.
65
+ */
66
+ readonly warnings: PromiseLike<Array<Warning>>;
67
+
68
+ /**
69
+ * Response metadata.
70
+ */
71
+ readonly responses: PromiseLike<Array<SpeechModelResponseMetadata>>;
72
+
73
+ /**
74
+ * Additional provider-specific metadata.
75
+ */
76
+ readonly providerMetadata: PromiseLike<Record<string, JSONObject>>;
77
+
78
+ /**
79
+ * Stream of audio bytes in generation order.
80
+ *
81
+ * `audioStream` and `fullStream` share one live stream: only one of them
82
+ * can be accessed, and only once. Access it before any result promise;
83
+ * accessing a result promise first consumes the stream internally.
84
+ */
85
+ readonly audioStream: AsyncIterableStream<Uint8Array>;
86
+
87
+ /**
88
+ * Full stream of speech parts (audio, raw, and error parts).
89
+ *
90
+ * `audioStream` and `fullStream` share one live stream: only one of them
91
+ * can be accessed, and only once. Access it before any result promise;
92
+ * accessing a result promise first consumes the stream internally.
93
+ */
94
+ readonly fullStream: AsyncIterableStream<SpeechStreamPart>;
95
+ }
@@ -0,0 +1,569 @@
1
+ import {
2
+ UnsupportedFunctionalityError,
3
+ type Experimental_SpeechModelV4StreamPart,
4
+ type Experimental_SpeechModelV4StreamTextPart,
5
+ type JSONObject,
6
+ type SharedV4AudioFormat,
7
+ } from '@ai-sdk/provider';
8
+ import {
9
+ convertAsyncIteratorToReadableStream,
10
+ convertBase64ToUint8Array,
11
+ DelayedPromise,
12
+ withUserAgentSuffix,
13
+ type ProviderOptions,
14
+ } from '@ai-sdk/provider-utils';
15
+ import { InvalidArgumentError } from '../error/invalid-argument-error';
16
+ import { NoSpeechGeneratedError } from '../error/no-speech-generated-error';
17
+ import { StreamProviderError } from '../error/stream-provider-error';
18
+ import { logWarnings } from '../logger/log-warnings';
19
+ import { resolveSpeechModel } from '../model/resolve-model';
20
+ import type { SpeechModel } from '../types/speech-model';
21
+ import type { SpeechModelResponseMetadata } from '../types/speech-model-response-metadata';
22
+ import type { Warning } from '../types/warning';
23
+ import { asAsyncIterableStream } from '../util/async-iterable-stream';
24
+ import { mergeAbortSignals } from '../util/merge-abort-signals';
25
+ import { VERSION } from '../version';
26
+ import { isStreamingSpeechModel } from './is-streaming-speech-model';
27
+ import type {
28
+ SpeechStreamPart,
29
+ SpeechStreamUsage,
30
+ SpeechTextPart,
31
+ StreamSpeechResult,
32
+ } from './stream-speech-result';
33
+
34
+ /**
35
+ * Streams speech audio using a speech model.
36
+ *
37
+ * @param model - The speech model to use. It must support streaming speech.
38
+ * @param text - The text to convert to speech: a complete string, or a stream of text deltas
39
+ * (e.g. the text stream of `streamText`) or text parts that is synthesized as it arrives.
40
+ * A text part can set its own `voice` on providers that support several voices per stream.
41
+ * @param voice - The voice to use for speech generation.
42
+ * @param outputFormat - The format of the output audio chunks, e.g. `{ type: 'audio/pcm', rate: 24000 }`.
43
+ * @param instructions - Instructions for the speech generation e.g. "Speak in a slow and steady tone".
44
+ * @param speed - The speed of the speech generation.
45
+ * @param language - The language for speech generation (ISO 639-1 code e.g. "en", "es", "fr") or "auto" for automatic detection.
46
+ * @param providerOptions - Additional provider-specific options.
47
+ * @param abortSignal - An optional abort signal that can be used to cancel the call.
48
+ * @param headers - Additional HTTP/WebSocket headers to send when supported by the provider.
49
+ * @param includeRawChunks - When true, raw provider chunks are included in `fullStream`.
50
+ * @param onError - Called for each provider error part; defaults to `console.error`.
51
+ *
52
+ * @returns A result object with the audio stream and the final usage and metadata.
53
+ */
54
+ export function streamSpeech({
55
+ model,
56
+ text,
57
+ voice,
58
+ outputFormat,
59
+ instructions,
60
+ speed,
61
+ language,
62
+ providerOptions = {},
63
+ abortSignal,
64
+ headers,
65
+ includeRawChunks,
66
+ onError = ({ error }) => {
67
+ console.error(error);
68
+ },
69
+ _internal: { currentDate = () => new Date() } = {},
70
+ }: {
71
+ /**
72
+ * The speech model to use.
73
+ */
74
+ model: SpeechModel;
75
+
76
+ /**
77
+ * The text to convert to speech: a complete string, or an array, iterable,
78
+ * or stream of text deltas and/or text parts.
79
+ */
80
+ text:
81
+ | string
82
+ | Iterable<string | SpeechTextPart>
83
+ | AsyncIterable<string | SpeechTextPart>
84
+ | ReadableStream<string | SpeechTextPart>;
85
+
86
+ /**
87
+ * The voice to use for speech generation.
88
+ */
89
+ voice?: string;
90
+
91
+ /**
92
+ * The desired format of the output audio chunks, e.g.
93
+ * `{ type: 'audio/pcm', rate: 24000 }` or `{ type: 'audio/mpeg' }`.
94
+ * Supported formats are provider-specific.
95
+ */
96
+ outputFormat?: SharedV4AudioFormat;
97
+
98
+ /**
99
+ * Instructions for the speech generation e.g. "Speak in a slow and steady tone".
100
+ */
101
+ instructions?: string;
102
+
103
+ /**
104
+ * The speed of the speech generation.
105
+ */
106
+ speed?: number;
107
+
108
+ /**
109
+ * The language for speech generation. This should be an ISO 639-1 language code (e.g. "en", "es", "fr")
110
+ * or "auto" for automatic language detection. Provider support varies.
111
+ */
112
+ language?: string;
113
+
114
+ /**
115
+ * Additional provider-specific options.
116
+ */
117
+ providerOptions?: ProviderOptions;
118
+
119
+ /**
120
+ * Abort signal.
121
+ */
122
+ abortSignal?: AbortSignal;
123
+
124
+ /**
125
+ * Additional headers to include in the request, if supported by the provider.
126
+ */
127
+ headers?: Record<string, string>;
128
+
129
+ /**
130
+ * When true, providers should include raw provider chunks in the stream.
131
+ */
132
+ includeRawChunks?: boolean;
133
+
134
+ /**
135
+ * Called for each error part the provider reports. Error parts are not
136
+ * fatal: `fullStream` includes them and `audioStream` skips them. The
137
+ * callback is not awaited, and errors it throws are ignored.
138
+ * Defaults to logging the error with `console.error`.
139
+ */
140
+ onError?: (event: { error: unknown }) => void | PromiseLike<void>;
141
+
142
+ /**
143
+ * Internal test hooks.
144
+ */
145
+ _internal?: {
146
+ currentDate?: () => Date;
147
+ };
148
+ }): StreamSpeechResult {
149
+ const resolvedModel = resolveSpeechModel(model);
150
+ if (!resolvedModel) {
151
+ throw new Error('Model could not be resolved');
152
+ }
153
+
154
+ if (!isStreamingSpeechModel(resolvedModel)) {
155
+ throw new UnsupportedFunctionalityError({
156
+ functionality: 'streaming speech',
157
+ message:
158
+ `The ${resolvedModel.provider} model "${resolvedModel.modelId}" does not support streaming speech.` +
159
+ (typeof model === 'string'
160
+ ? ' String model IDs resolve through the global provider (AI Gateway by default).' +
161
+ ' If that provider does not support streaming speech, pass a provider model' +
162
+ " instance instead (e.g. elevenLabs.speech('eleven_flash_v2_5'))" +
163
+ ' or upgrade @ai-sdk/gateway to a version with streaming speech support.'
164
+ : ''),
165
+ });
166
+ }
167
+
168
+ const textStream = toTextStream(text);
169
+ const headersWithUserAgent = withUserAgentSuffix(
170
+ headers ?? {},
171
+ `ai/${VERSION}`,
172
+ );
173
+
174
+ const usagePromise = new DelayedPromise<SpeechStreamUsage>();
175
+ const warningsPromise = new DelayedPromise<Array<Warning>>();
176
+ const responsesPromise = new DelayedPromise<
177
+ Array<SpeechModelResponseMetadata>
178
+ >();
179
+ const providerMetadataPromise = new DelayedPromise<
180
+ Record<string, JSONObject>
181
+ >();
182
+
183
+ const rejectPendingPromises = (error: unknown) => {
184
+ for (const promise of [
185
+ usagePromise,
186
+ warningsPromise,
187
+ responsesPromise,
188
+ providerMetadataPromise,
189
+ ]) {
190
+ if (promise.isPending()) {
191
+ promise.reject(error);
192
+ }
193
+ }
194
+ };
195
+
196
+ const startedAt = currentDate();
197
+ let response: SpeechModelResponseMetadata | undefined;
198
+ const currentResponseMetadata = () =>
199
+ response ?? { timestamp: startedAt, modelId: resolvedModel.modelId };
200
+
201
+ const resolveWarnings = (warnings: Array<Warning>) => {
202
+ warningsPromise.resolve(warnings);
203
+ logWarnings({
204
+ warnings,
205
+ provider: resolvedModel.provider,
206
+ model: resolvedModel.modelId,
207
+ });
208
+ };
209
+
210
+ // Cancelling the output stream aborts the model pipe with a defined reason
211
+ // (an undefined reason surfaces as an unhandled rejection on newer Node.js).
212
+ const pipeAbortController = new AbortController();
213
+ let hasAudio = false;
214
+ let finished = false;
215
+ // Kept so a failed stream reports the provider error that caused it.
216
+ let lastError: unknown;
217
+
218
+ // A stream that ends without a finish part: when nothing was heard it is a
219
+ // no-speech failure; after audio it surfaces the provider's error instead.
220
+ const incompleteStreamError = () =>
221
+ hasAudio
222
+ ? (lastError ??
223
+ new StreamProviderError({
224
+ message: 'The speech stream ended before it finished.',
225
+ }))
226
+ : new NoSpeechGeneratedError({
227
+ responses: [currentResponseMetadata()],
228
+ cause: lastError,
229
+ });
230
+
231
+ // `Transformer.cancel` is supported at runtime but missing from the
232
+ // ambient `Transformer` type, so widen it here.
233
+ const transformer: Transformer<
234
+ Experimental_SpeechModelV4StreamPart,
235
+ SpeechStreamPart
236
+ > & { cancel?: (reason?: unknown) => void } = {
237
+ transform(value, controller) {
238
+ switch (value.type) {
239
+ case 'stream-start': {
240
+ resolveWarnings(value.warnings);
241
+ break;
242
+ }
243
+
244
+ case 'response-metadata': {
245
+ response = {
246
+ timestamp: value.timestamp ?? currentResponseMetadata().timestamp,
247
+ modelId: value.modelId ?? currentResponseMetadata().modelId,
248
+ headers: value.headers ?? response?.headers,
249
+ body: value.body ?? response?.body,
250
+ };
251
+ break;
252
+ }
253
+
254
+ case 'audio': {
255
+ const audio =
256
+ typeof value.audio === 'string'
257
+ ? convertBase64ToUint8Array(value.audio)
258
+ : value.audio;
259
+ // Empty chunks are dropped unless they carry provider metadata
260
+ // (e.g. an end-of-turn marker); those never count as audio.
261
+ if (audio.length === 0 && value.providerMetadata == null) {
262
+ break;
263
+ }
264
+ hasAudio ||= audio.length > 0;
265
+ controller.enqueue({
266
+ type: 'audio',
267
+ ...(value.id != null && { id: value.id }),
268
+ audio,
269
+ audioFormat: value.audioFormat,
270
+ ...(value.providerMetadata != null && {
271
+ providerMetadata: value.providerMetadata,
272
+ }),
273
+ });
274
+ break;
275
+ }
276
+
277
+ case 'raw': {
278
+ controller.enqueue(value);
279
+ break;
280
+ }
281
+
282
+ case 'error': {
283
+ lastError = value.error;
284
+ void Promise.resolve()
285
+ .then(() => onError({ error: value.error }))
286
+ .catch(() => {});
287
+ controller.enqueue(value);
288
+ break;
289
+ }
290
+
291
+ case 'finish': {
292
+ if (!warningsPromise.isResolved()) {
293
+ resolveWarnings([]);
294
+ }
295
+
296
+ if (!hasAudio) {
297
+ throw new NoSpeechGeneratedError({
298
+ responses: [currentResponseMetadata()],
299
+ cause: lastError,
300
+ });
301
+ }
302
+
303
+ finished = true;
304
+ usagePromise.resolve(value.usage ?? {});
305
+ responsesPromise.resolve([currentResponseMetadata()]);
306
+ providerMetadataPromise.resolve(value.providerMetadata ?? {});
307
+ break;
308
+ }
309
+ }
310
+ },
311
+
312
+ flush() {
313
+ if (!finished) {
314
+ throw incompleteStreamError();
315
+ }
316
+ },
317
+
318
+ cancel(reason) {
319
+ const cancelReason = reason ?? new Error('Speech stream was cancelled.');
320
+ // Reject now rather than when the provider reacts to the abort, which
321
+ // may be late while doStream is still pending.
322
+ rejectPendingPromises(cancelReason);
323
+ pipeAbortController.abort(cancelReason);
324
+ },
325
+ };
326
+
327
+ const transform = new TransformStream<
328
+ Experimental_SpeechModelV4StreamPart,
329
+ SpeechStreamPart
330
+ >(transformer);
331
+
332
+ // A user abort settles the result and errors the output immediately, even
333
+ // while doStream is pending or the provider ignores the signal.
334
+ if (abortSignal != null) {
335
+ const onAbort = () => {
336
+ const reason = abortSignal.reason;
337
+ rejectPendingPromises(reason);
338
+ pipeAbortController.abort(reason);
339
+ // Rejects when the pipe already holds the writable; the pipe aborts it.
340
+ transform.writable.abort(reason).catch(() => {});
341
+ };
342
+ if (abortSignal.aborted) {
343
+ onAbort();
344
+ } else {
345
+ abortSignal.addEventListener('abort', onAbort, { once: true });
346
+ }
347
+ }
348
+
349
+ // Piping (instead of an eager read loop) preserves consumer backpressure
350
+ // and propagates cancellation of the output stream to the model stream.
351
+ void (async () => {
352
+ const result = await resolvedModel.doStream({
353
+ text: textStream,
354
+ voice,
355
+ outputFormat,
356
+ instructions,
357
+ speed,
358
+ language,
359
+ providerOptions,
360
+ // merged so cancelling the output stream also aborts a pending doStream
361
+ abortSignal: mergeAbortSignals(abortSignal, pipeAbortController.signal),
362
+ headers: headersWithUserAgent,
363
+ includeRawChunks,
364
+ });
365
+
366
+ response = {
367
+ timestamp: result.response?.timestamp ?? startedAt,
368
+ modelId: result.response?.modelId ?? resolvedModel.modelId,
369
+ headers: result.response?.headers,
370
+ body: result.response?.body,
371
+ };
372
+
373
+ await result.stream.pipeTo(transform.writable, {
374
+ signal: pipeAbortController.signal,
375
+ });
376
+ })().catch(error => {
377
+ const reason =
378
+ error ?? new Error('Speech stream was cancelled or errored.');
379
+ rejectPendingPromises(reason);
380
+ // When doStream rejects before the model took a reader, nothing owns the
381
+ // text stream yet, so cancel it here; otherwise the model's cleanup does.
382
+ textStream.cancel(reason).catch(() => {});
383
+ transform.writable.abort(reason).catch(() => {
384
+ // the writable is already errored when the model stream failed mid-pipe
385
+ });
386
+ });
387
+
388
+ // Speech streams can be unbounded (e.g. text from a long-running LLM
389
+ // stream), so the output has exactly one owner and is never buffered.
390
+ let streamOwner:
391
+ | 'unclaimed'
392
+ | 'audio-stream'
393
+ | 'full-stream'
394
+ | 'result-promises' = 'unclaimed';
395
+
396
+ function consumeStream() {
397
+ if (streamOwner !== 'unclaimed') {
398
+ return;
399
+ }
400
+ streamOwner = 'result-promises';
401
+ const reader = transform.readable.getReader();
402
+ void (async () => {
403
+ while (!(await reader.read()).done) {
404
+ // drain; results surface via the promises
405
+ }
406
+ })().catch(() => {
407
+ // stream errors reject the promises via the pipe handler above
408
+ });
409
+ }
410
+
411
+ function claimStream(owner: 'audio-stream' | 'full-stream') {
412
+ if (streamOwner !== 'unclaimed') {
413
+ throw new Error(
414
+ streamOwner === 'result-promises'
415
+ ? `${owner === 'full-stream' ? 'fullStream' : 'audioStream'} cannot be accessed after a result promise.`
416
+ : 'Only one of audioStream or fullStream can be accessed, and only once.',
417
+ );
418
+ }
419
+ streamOwner = owner;
420
+ return transform.readable;
421
+ }
422
+
423
+ return {
424
+ get usage() {
425
+ consumeStream();
426
+ return usagePromise.promise;
427
+ },
428
+ get warnings() {
429
+ consumeStream();
430
+ return warningsPromise.promise;
431
+ },
432
+ get responses() {
433
+ consumeStream();
434
+ return responsesPromise.promise;
435
+ },
436
+ get providerMetadata() {
437
+ consumeStream();
438
+ return providerMetadataPromise.promise;
439
+ },
440
+ get fullStream() {
441
+ return asAsyncIterableStream(claimStream('full-stream'));
442
+ },
443
+ get audioStream() {
444
+ return asAsyncIterableStream(
445
+ createAudioByteStream(claimStream('audio-stream')),
446
+ );
447
+ },
448
+ };
449
+ }
450
+
451
+ /**
452
+ * Projects speech parts to audio bytes with a pull-based stream rather than a
453
+ * second `pipeThrough`, so an early consumer cancel cancels the part stream
454
+ * directly instead of surfacing an unhandled rejection from a chained pipe.
455
+ */
456
+ function createAudioByteStream(
457
+ parts: ReadableStream<SpeechStreamPart>,
458
+ ): ReadableStream<Uint8Array> {
459
+ let reader: ReadableStreamDefaultReader<SpeechStreamPart> | undefined;
460
+
461
+ return new ReadableStream<Uint8Array>({
462
+ async pull(controller) {
463
+ reader ??= parts.getReader();
464
+ for (;;) {
465
+ const { done, value } = await reader.read();
466
+ if (done) {
467
+ controller.close();
468
+ return;
469
+ }
470
+ // Error parts are non-fatal (reported via `onError`), as for the
471
+ // text stream of `streamText`; only stream failures end the audio.
472
+ if (value.type === 'audio' && value.audio.length > 0) {
473
+ controller.enqueue(value.audio);
474
+ return;
475
+ }
476
+ }
477
+ },
478
+ async cancel(reason) {
479
+ // Aborting the active model pipe can reject the part stream's cancel
480
+ // promise; result promises already surface that cancellation reason.
481
+ if (reader != null) {
482
+ await reader.cancel(reason).catch(() => {});
483
+ } else {
484
+ await parts.cancel(reason).catch(() => {});
485
+ }
486
+ },
487
+ });
488
+ }
489
+
490
+ function toTextStream(
491
+ text:
492
+ | string
493
+ | Iterable<string | SpeechTextPart>
494
+ | AsyncIterable<string | SpeechTextPart>
495
+ | ReadableStream<string | SpeechTextPart>,
496
+ ): ReadableStream<Experimental_SpeechModelV4StreamTextPart> {
497
+ if (typeof text === 'string') {
498
+ return new ReadableStream<Experimental_SpeechModelV4StreamTextPart>({
499
+ start(controller) {
500
+ controller.enqueue({ type: 'text', text });
501
+ controller.close();
502
+ },
503
+ });
504
+ }
505
+ const source = toSourceStream(text);
506
+ return source.pipeThrough(
507
+ new TransformStream<
508
+ string | SpeechTextPart,
509
+ Experimental_SpeechModelV4StreamTextPart
510
+ >({
511
+ transform(value, controller) {
512
+ controller.enqueue(toTextPart(value));
513
+ },
514
+ }),
515
+ );
516
+ }
517
+
518
+ function toSourceStream(
519
+ text:
520
+ | Iterable<string | SpeechTextPart>
521
+ | AsyncIterable<string | SpeechTextPart>
522
+ | ReadableStream<string | SpeechTextPart>,
523
+ ): ReadableStream<string | SpeechTextPart> {
524
+ if (text instanceof ReadableStream) {
525
+ return text;
526
+ }
527
+ if (
528
+ typeof (text as Partial<AsyncIterable<unknown>>)?.[Symbol.asyncIterator] ===
529
+ 'function'
530
+ ) {
531
+ const iterable = text as AsyncIterable<string | SpeechTextPart>;
532
+ return convertAsyncIteratorToReadableStream(
533
+ iterable[Symbol.asyncIterator](),
534
+ );
535
+ }
536
+ if (
537
+ typeof (text as Partial<Iterable<unknown>>)?.[Symbol.iterator] ===
538
+ 'function'
539
+ ) {
540
+ const iterable = text as Iterable<string | SpeechTextPart>;
541
+ return convertAsyncIteratorToReadableStream(
542
+ (async function* () {
543
+ yield* iterable;
544
+ })(),
545
+ );
546
+ }
547
+ throw new InvalidArgumentError({
548
+ parameter: 'text',
549
+ value: text,
550
+ message:
551
+ 'Expected a string, an iterable or async iterable, or a ReadableStream of strings or text parts.',
552
+ });
553
+ }
554
+
555
+ function toTextPart(
556
+ value: string | SpeechTextPart,
557
+ ): Experimental_SpeechModelV4StreamTextPart {
558
+ if (typeof value === 'string') {
559
+ return { type: 'text', text: value };
560
+ }
561
+ return {
562
+ type: 'text',
563
+ text: value.text,
564
+ ...(value.voice != null && { voice: value.voice }),
565
+ ...(value.providerOptions != null && {
566
+ providerOptions: value.providerOptions,
567
+ }),
568
+ };
569
+ }