ai 7.0.148 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/index.d.ts +168 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +325 -35
  5. package/dist/index.js.map +1 -1
  6. package/dist/internal/index.d.ts +2 -1
  7. package/dist/internal/index.d.ts.map +1 -1
  8. package/dist/internal/index.js +4 -1
  9. package/dist/internal/index.js.map +1 -1
  10. package/dist/test/index.d.ts +17 -2
  11. package/dist/test/index.d.ts.map +1 -1
  12. package/dist/test/index.js +12 -1
  13. package/dist/test/index.js.map +1 -1
  14. package/docs/03-ai-sdk-core/15-tools-and-tool-calling.mdx +7 -0
  15. package/docs/03-ai-sdk-core/18-code-mode.mdx +3 -1
  16. package/docs/03-ai-sdk-core/36-transcription.mdx +40 -39
  17. package/docs/03-ai-sdk-core/37-speech.mdx +66 -0
  18. package/docs/03-ai-sdk-core/60-telemetry.mdx +25 -2
  19. package/docs/03-ai-sdk-harnesses/05-harness-adapters.mdx +1 -1
  20. package/docs/07-reference/01-ai-sdk-core/01-generate-text.mdx +4 -4
  21. package/docs/07-reference/01-ai-sdk-core/02-stream-text.mdx +7 -7
  22. package/docs/07-reference/01-ai-sdk-core/12-stream-speech.mdx +195 -0
  23. package/docs/07-reference/01-ai-sdk-core/index.mdx +5 -0
  24. package/docs/07-reference/02-ai-sdk-ui/01-use-chat.mdx +1 -1
  25. package/docs/07-reference/02-ai-sdk-ui/40-create-ui-message-stream.mdx +1 -1
  26. package/docs/07-reference/03-ai-sdk-rsc/01-stream-ui.mdx +1 -1
  27. package/docs/07-reference/05-ai-sdk-errors/ai-tool-choice-violation-error.mdx +12 -7
  28. package/package.json +12 -12
  29. package/src/error/no-speech-generated-error.ts +5 -1
  30. package/src/error/tool-choice-violation-error.ts +6 -4
  31. package/src/generate-speech/index.ts +7 -0
  32. package/src/generate-speech/is-streaming-speech-model.ts +17 -0
  33. package/src/generate-speech/stream-speech-result.ts +95 -0
  34. package/src/generate-speech/stream-speech.ts +569 -0
  35. package/src/generate-text/collect-tool-approvals.ts +10 -0
  36. package/src/generate-text/generate-text.ts +24 -12
  37. package/src/generate-text/stream-language-model-call.ts +37 -12
  38. package/src/generate-text/stream-text.ts +4 -1
  39. package/src/test/mock-streaming-speech-model-v4.ts +28 -0
  40. package/src/types/language-model.ts +2 -0
  41. package/src/ui/chat.ts +40 -12
  42. package/src/ui-message-stream/ui-message-chunks.ts +1 -0
@@ -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
+ }
@@ -88,6 +88,7 @@ export function collectToolApprovals<TOOLS extends ToolSet>({
88
88
 
89
89
  const approvedToolApprovals: Array<CollectedToolApprovals<TOOLS>> = [];
90
90
  const deniedToolApprovals: Array<CollectedToolApprovals<TOOLS>> = [];
91
+ const collectedToolCallIds = new Set<string>();
91
92
 
92
93
  const approvalResponses = lastMessage.content.filter(
93
94
  part => part.type === 'tool-approval-response',
@@ -119,6 +120,15 @@ export function collectToolApprovals<TOOLS extends ToolSet>({
119
120
  });
120
121
  }
121
122
 
123
+ // Only the first response for a tool call is collected. Later responses
124
+ // (replays, conflicting decisions, or responses to another approval of the
125
+ // same call) would otherwise execute the tool twice or both execute and
126
+ // deny it.
127
+ if (collectedToolCallIds.has(toolCall.toolCallId)) {
128
+ continue;
129
+ }
130
+ collectedToolCallIds.add(toolCall.toolCallId);
131
+
122
132
  const approval: CollectedToolApprovals<TOOLS> = {
123
133
  approvalRequest,
124
134
  approvalResponse,
@@ -939,7 +939,7 @@ export async function generateText<
939
939
  activeTools: prepareStepResult?.activeTools ?? activeTools,
940
940
  });
941
941
  const {
942
- executionTools: stepExecutionTools,
942
+ executionTools,
943
943
  modelTools: stepModelTools,
944
944
  toolCallerMessages,
945
945
  } = prepareToolsForToolCallers({
@@ -969,6 +969,8 @@ export async function generateText<
969
969
  const stepToolChoice = prepareToolChoice({
970
970
  toolChoice: prepareStepResult?.toolChoice ?? toolChoice,
971
971
  });
972
+ const stepExecutionTools =
973
+ stepToolChoice.type === 'none' ? undefined : executionTools;
972
974
 
973
975
  const stepMessages = appendToolCallerMessages({
974
976
  messages: prepareStepResult?.messages ?? stepInputMessages,
@@ -1097,8 +1099,16 @@ export async function generateText<
1097
1099
  parseToolCall({
1098
1100
  toolCall,
1099
1101
  tools: stepModelTools as TOOLS,
1100
- repairToolCall,
1101
- refineToolInput,
1102
+ // tool calls are rejected with tool choice none,
1103
+ // so user callbacks must not process them:
1104
+ repairToolCall:
1105
+ stepToolChoice.type === 'none'
1106
+ ? undefined
1107
+ : repairToolCall,
1108
+ refineToolInput:
1109
+ stepToolChoice.type === 'none'
1110
+ ? undefined
1111
+ : refineToolInput,
1102
1112
  instructions: stepInstructions,
1103
1113
  messages: stepMessages,
1104
1114
  abortSignal: mergedAbortSignal,
@@ -1171,18 +1181,19 @@ export async function generateText<
1171
1181
  });
1172
1182
 
1173
1183
  const enforcedToolChoice =
1174
- stepToolChoice.type === 'required' ||
1175
- stepToolChoice.type === 'tool'
1176
- ? stepToolChoice
1177
- : undefined;
1184
+ stepToolChoice.type === 'auto' ? undefined : stepToolChoice;
1178
1185
 
1179
1186
  if (
1180
1187
  enforcedToolChoice != null &&
1181
- !stepToolCalls.some(
1182
- toolCall =>
1183
- enforcedToolChoice.type === 'required' ||
1184
- toolCall.toolName === enforcedToolChoice.toolName,
1185
- )
1188
+ (enforcedToolChoice.type === 'none'
1189
+ ? stepToolCalls.length > 0
1190
+ : currentModelResponse.finishReason.unified !==
1191
+ 'interrupted' &&
1192
+ !stepToolCalls.some(
1193
+ toolCall =>
1194
+ enforcedToolChoice.type === 'required' ||
1195
+ toolCall.toolName === enforcedToolChoice.toolName,
1196
+ ))
1186
1197
  ) {
1187
1198
  throw new ToolChoiceViolationError({
1188
1199
  toolChoice: enforcedToolChoice,
@@ -1531,6 +1542,7 @@ export async function generateText<
1531
1542
  }
1532
1543
  }
1533
1544
  } while (
1545
+ steps[steps.length - 1].finishReason !== 'interrupted' &&
1534
1546
  // Continue only after all client tool calls have been executed or denied,
1535
1547
  // and if there are client results or pending deferred provider results.
1536
1548
  clientToolOutputs.length + deniedToolApprovalResponses.length ===