@ai-sdk/provider-utils 5.0.49 → 5.0.50

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/provider-utils",
3
- "version": "5.0.49",
3
+ "version": "5.0.50",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -37,7 +37,7 @@
37
37
  }
38
38
  },
39
39
  "dependencies": {
40
- "@ai-sdk/provider": "4.0.18",
40
+ "@ai-sdk/provider": "4.0.19",
41
41
  "@standard-schema/spec": "^1.1.0",
42
42
  "@workflow/serde": "4.1.0",
43
43
  "eventsource-parser": "^3.0.8",
@@ -0,0 +1,100 @@
1
+ type ArgumentStructure =
2
+ | { kind: 'undetermined' }
3
+ | { kind: 'other' }
4
+ | {
5
+ kind: 'structured';
6
+ stack: Array<'{' | '['>;
7
+ inString: boolean;
8
+ escaped: boolean;
9
+ complete: boolean;
10
+ };
11
+
12
+ export function startsWithStructuredValue(
13
+ value: string | null | undefined,
14
+ ): boolean {
15
+ if (typeof value !== 'string') {
16
+ return false;
17
+ }
18
+
19
+ const firstCharacter = value.trimStart()[0];
20
+ return firstCharacter === '{' || firstCharacter === '[';
21
+ }
22
+
23
+ /**
24
+ * Incrementally tracks whether streamed tool-call arguments contain a complete
25
+ * structured JSON value. This is intentionally structural rather than a JSON
26
+ * parse: a currently parsable scalar can still be the prefix of a later value.
27
+ */
28
+ export class StreamingToolCallArgumentState {
29
+ private structure: ArgumentStructure = { kind: 'undetermined' };
30
+
31
+ constructor(initialValue = '') {
32
+ this.append(initialValue);
33
+ }
34
+
35
+ get hasCompleteStructuredValue(): boolean {
36
+ return (
37
+ this.structure.kind === 'structured' && this.structure.complete === true
38
+ );
39
+ }
40
+
41
+ append(delta: string): void {
42
+ let nextStructure = this.structure;
43
+
44
+ for (const character of delta) {
45
+ if (nextStructure.kind === 'undetermined') {
46
+ if (/\s/.test(character)) {
47
+ continue;
48
+ }
49
+
50
+ if (character !== '{' && character !== '[') {
51
+ nextStructure = { kind: 'other' };
52
+ continue;
53
+ }
54
+
55
+ nextStructure = {
56
+ kind: 'structured',
57
+ stack: [character],
58
+ inString: false,
59
+ escaped: false,
60
+ complete: false,
61
+ };
62
+ continue;
63
+ }
64
+
65
+ if (nextStructure.kind !== 'structured' || nextStructure.complete) {
66
+ continue;
67
+ }
68
+
69
+ if (nextStructure.inString) {
70
+ if (nextStructure.escaped) {
71
+ nextStructure.escaped = false;
72
+ } else if (character === '\\') {
73
+ nextStructure.escaped = true;
74
+ } else if (character === '"') {
75
+ nextStructure.inString = false;
76
+ }
77
+ continue;
78
+ }
79
+
80
+ if (character === '"') {
81
+ nextStructure.inString = true;
82
+ } else if (character === '{' || character === '[') {
83
+ nextStructure.stack.push(character);
84
+ } else if (character === '}' || character === ']') {
85
+ const expectedOpening = character === '}' ? '{' : '[';
86
+ if (nextStructure.stack.at(-1) !== expectedOpening) {
87
+ nextStructure = { kind: 'other' };
88
+ continue;
89
+ }
90
+
91
+ nextStructure.stack.pop();
92
+ if (nextStructure.stack.length === 0) {
93
+ nextStructure.complete = true;
94
+ }
95
+ }
96
+ }
97
+
98
+ this.structure = nextStructure;
99
+ }
100
+ }
@@ -4,6 +4,10 @@ import {
4
4
  type SharedV4ProviderMetadata,
5
5
  } from '@ai-sdk/provider';
6
6
  import { generateId as defaultGenerateId } from './generate-id';
7
+ import {
8
+ startsWithStructuredValue,
9
+ StreamingToolCallArgumentState,
10
+ } from './streaming-tool-call-argument-state';
7
11
 
8
12
  /**
9
13
  * Minimal interface for a streaming tool call delta from an OpenAI-compatible API.
@@ -23,6 +27,7 @@ export interface StreamingToolCallTrackerOptions<
23
27
  > {
24
28
  /**
25
29
  * ID generator function for tool call IDs.
30
+ * Blank or repeated outputs are converted to usable unique IDs.
26
31
  * Defaults to the standard generateId.
27
32
  */
28
33
  generateId?: () => string;
@@ -55,12 +60,20 @@ export interface StreamingToolCallTrackerOptions<
55
60
 
56
61
  interface TrackedToolCall {
57
62
  id: string;
63
+ index?: number;
64
+ sequence: number;
58
65
  type: 'function';
59
66
  function: { name: string; arguments: string };
67
+ argumentState: StreamingToolCallArgumentState;
60
68
  hasFinished: boolean;
61
69
  metadata?: SharedV4ProviderMetadata;
62
70
  }
63
71
 
72
+ type ToolCallResolution =
73
+ | { kind: 'existing'; toolCall: TrackedToolCall }
74
+ | { kind: 'new' }
75
+ | { kind: 'ambiguous' };
76
+
64
77
  type StreamingToolCallTrackerController = Pick<
65
78
  TransformStreamDefaultController<LanguageModelV4StreamPart>,
66
79
  'enqueue'
@@ -72,15 +85,17 @@ type StreamingToolCallTrackerController = Pick<
72
85
  * emits tool-input-start/delta/end and tool-call events, and finalizes
73
86
  * unfinished tool calls on flush.
74
87
  *
75
- * Used by openai, openai-compatible, groq, deepseek, and alibaba providers.
88
+ * Used by openai, openai-compatible, groq, deepseek, alibaba, mistral, and
89
+ * moonshotai providers.
76
90
  */
77
91
  export class StreamingToolCallTracker<
78
92
  DELTA extends StreamingToolCallDelta = StreamingToolCallDelta,
79
93
  > {
80
- private toolCalls = new Set<TrackedToolCall>();
81
- private toolCallsById = new Map<string, TrackedToolCall>();
82
- private toolCallsByIndex = new Map<number, TrackedToolCall>();
83
- private latestToolCall: TrackedToolCall | undefined;
94
+ private toolCalls: TrackedToolCall[] = [];
95
+ private toolCallsById = new Map<string, Set<TrackedToolCall>>();
96
+ private toolCallsByIndex = new Map<number, Set<TrackedToolCall>>();
97
+ private usedToolCallIds = new Set<string>();
98
+ private nextGeneratedIdSuffixes = new Map<string, number>();
84
99
  private readonly controller: StreamingToolCallTrackerController;
85
100
  private readonly _generateId: () => string;
86
101
  private readonly typeValidation: 'none' | 'if-present' | 'required';
@@ -108,24 +123,51 @@ export class StreamingToolCallTracker<
108
123
  * events as appropriate.
109
124
  */
110
125
  processDelta(toolCallDelta: DELTA): void {
111
- const { id, index } = toolCallDelta;
112
- let toolCall =
113
- id != null && id.length > 0
114
- ? this.toolCallsById.get(id)
115
- : index != null
116
- ? this.toolCallsByIndex.get(index)
117
- : this.latestToolCall;
118
-
119
- if (toolCall == null) {
120
- toolCall = this.processNewToolCall(toolCallDelta);
126
+ const wireName = toolCallDelta.function?.name;
127
+ const hasBlankName =
128
+ typeof wireName === 'string' && wireName.trim().length === 0;
129
+ const wireId = this.getNonBlankString(toolCallDelta.id);
130
+ const name = this.getNonBlankString(wireName);
131
+ const { index } = toolCallDelta;
132
+
133
+ const resolution = this.resolveToolCall({
134
+ wireId,
135
+ index,
136
+ name,
137
+ hasExplicitCallStart:
138
+ name != null &&
139
+ startsWithStructuredValue(toolCallDelta.function?.arguments),
140
+ });
141
+
142
+ if (resolution.kind === 'ambiguous') {
143
+ return;
144
+ }
145
+
146
+ let toolCall: TrackedToolCall;
147
+ if (resolution.kind === 'new') {
148
+ // Blank names cannot start a usable call, but some providers repeat a
149
+ // blank name on continuations. Correlate those continuations first, then
150
+ // ignore only the unmatched blank-name delta.
151
+ if (hasBlankName) {
152
+ return;
153
+ }
154
+
155
+ toolCall = this.processNewToolCall(toolCallDelta, {
156
+ wireId,
157
+ index,
158
+ name,
159
+ });
121
160
  } else {
161
+ toolCall = resolution.toolCall;
162
+ if (wireId != null) {
163
+ this.associateWireId(toolCall, wireId);
164
+ }
122
165
  this.processExistingToolCall(toolCall, toolCallDelta);
123
166
  }
124
167
 
125
168
  if (index != null) {
126
- this.toolCallsByIndex.set(index, toolCall);
169
+ this.associateIndex(toolCall, index);
127
170
  }
128
- this.latestToolCall = toolCall;
129
171
  }
130
172
 
131
173
  /**
@@ -133,14 +175,191 @@ export class StreamingToolCallTracker<
133
175
  * flush handler to ensure all tool calls are properly completed.
134
176
  */
135
177
  flush(): void {
136
- for (const toolCall of this.toolCalls) {
178
+ // Index order is reliable only when every call has an index. For mixed
179
+ // streams, keep insertion order rather than moving all index-less calls
180
+ // behind indexed calls.
181
+ const toolCalls = this.toolCalls.every(toolCall => toolCall.index != null)
182
+ ? [...this.toolCalls].sort(
183
+ (a, b) => a.index! - b.index! || a.sequence - b.sequence,
184
+ )
185
+ : this.toolCalls;
186
+
187
+ for (const toolCall of toolCalls) {
137
188
  if (!toolCall.hasFinished) {
138
189
  this.finishToolCall(toolCall);
139
190
  }
140
191
  }
141
192
  }
142
193
 
143
- private processNewToolCall(toolCallDelta: DELTA): TrackedToolCall {
194
+ /**
195
+ * Correlation precedence for streamed deltas:
196
+ *
197
+ * | ID evidence | index/name evidence | start evidence | resolution |
198
+ * | --- | --- | --- | --- |
199
+ * | known | matching | any | matching call, new call, or ambiguity |
200
+ * | known | conflicting | named | new call |
201
+ * | unseen | matching | structured start | new call |
202
+ * | unseen | matching | continuation | matching call or ambiguity |
203
+ * | absent | matching | any | matching call, new call, or ambiguity |
204
+ * | absent | absent | named | new call |
205
+ * | absent | absent | unnamed | sole unfinished call, new call, or ambiguity |
206
+ */
207
+ private resolveToolCall({
208
+ wireId,
209
+ index,
210
+ name,
211
+ hasExplicitCallStart,
212
+ }: {
213
+ wireId: string | undefined;
214
+ index: number | null | undefined;
215
+ name: string | undefined;
216
+ hasExplicitCallStart: boolean;
217
+ }): ToolCallResolution {
218
+ const indexedToolCalls =
219
+ index != null ? this.toolCallsByIndex.get(index) : undefined;
220
+ const matchingIndexedToolCalls = this.filterToolCallsByName(
221
+ indexedToolCalls,
222
+ name,
223
+ );
224
+
225
+ if (wireId != null) {
226
+ const toolCallsWithId = this.toolCallsById.get(wireId);
227
+
228
+ if (toolCallsWithId != null) {
229
+ if (index != null) {
230
+ const matchingToolCalls = matchingIndexedToolCalls.filter(toolCall =>
231
+ toolCallsWithId.has(toolCall),
232
+ );
233
+ const matchingToolCall = this.resolveMatchingToolCall(
234
+ matchingToolCalls,
235
+ hasExplicitCallStart,
236
+ );
237
+ if (matchingToolCall.kind !== 'new') {
238
+ return matchingToolCall;
239
+ }
240
+
241
+ // A named delta with a distinct index starts a new call even when its
242
+ // wire ID and function name repeat. Providers may reuse IDs across
243
+ // parallel calls, so the ID/name pair cannot override index evidence.
244
+ if (name != null) {
245
+ return { kind: 'new' };
246
+ }
247
+
248
+ // Conflicting labels on a continuation cannot be resolved safely.
249
+ if (indexedToolCalls != null) {
250
+ return { kind: 'ambiguous' };
251
+ }
252
+
253
+ return this.resolveMatchingToolCall([...toolCallsWithId], false);
254
+ }
255
+
256
+ if (name != null) {
257
+ const matchingToolCalls = [...toolCallsWithId].filter(
258
+ toolCall => toolCall.function.name === name,
259
+ );
260
+
261
+ return this.resolveMatchingToolCall(
262
+ matchingToolCalls,
263
+ hasExplicitCallStart,
264
+ );
265
+ }
266
+
267
+ return this.resolveMatchingToolCall([...toolCallsWithId], false);
268
+ }
269
+
270
+ if (matchingIndexedToolCalls.length > 0) {
271
+ // A previously unseen ID plus a named structured argument start is
272
+ // stronger evidence of a distinct call than a reused index/name. This
273
+ // also keeps interleaved same-name calls separate while still allowing
274
+ // IDs to change on ordinary continuation fragments.
275
+ return hasExplicitCallStart
276
+ ? { kind: 'new' }
277
+ : this.resolveMatchingToolCall(matchingIndexedToolCalls, false);
278
+ }
279
+
280
+ return { kind: 'new' };
281
+ }
282
+
283
+ if (indexedToolCalls != null) {
284
+ // Repeated names are valid on continuations. A different name at the
285
+ // same index is evidence of a new call from a provider that reuses
286
+ // indices across parallel calls.
287
+ return this.resolveMatchingToolCall(
288
+ matchingIndexedToolCalls,
289
+ hasExplicitCallStart,
290
+ );
291
+ }
292
+
293
+ if (name != null) {
294
+ return { kind: 'new' };
295
+ }
296
+
297
+ const unfinishedToolCalls = this.toolCalls.filter(
298
+ toolCall => !toolCall.hasFinished,
299
+ );
300
+ if (unfinishedToolCalls.length === 1) {
301
+ return { kind: 'existing', toolCall: unfinishedToolCalls[0] };
302
+ }
303
+ return unfinishedToolCalls.length > 1
304
+ ? { kind: 'ambiguous' }
305
+ : { kind: 'new' };
306
+ }
307
+
308
+ private filterToolCallsByName(
309
+ toolCalls: Set<TrackedToolCall> | undefined,
310
+ name: string | undefined,
311
+ ): TrackedToolCall[] {
312
+ if (toolCalls == null) {
313
+ return [];
314
+ }
315
+
316
+ return [...toolCalls].filter(
317
+ toolCall => name == null || toolCall.function.name === name,
318
+ );
319
+ }
320
+
321
+ private resolveMatchingToolCall(
322
+ toolCalls: TrackedToolCall[],
323
+ hasExplicitCallStart: boolean,
324
+ ): ToolCallResolution {
325
+ if (toolCalls.length === 0) {
326
+ return { kind: 'new' };
327
+ }
328
+
329
+ if (!hasExplicitCallStart) {
330
+ return toolCalls.length === 1
331
+ ? { kind: 'existing', toolCall: toolCalls[0] }
332
+ : { kind: 'ambiguous' };
333
+ }
334
+
335
+ // A repeated name can occur on continuations. A fresh structured
336
+ // argument prefix is evidence of another call only after the matching call
337
+ // has completed its own structured argument payload.
338
+ const continuableToolCalls = toolCalls.filter(
339
+ toolCall => !toolCall.argumentState.hasCompleteStructuredValue,
340
+ );
341
+
342
+ if (continuableToolCalls.length === 1) {
343
+ return { kind: 'existing', toolCall: continuableToolCalls[0] };
344
+ }
345
+
346
+ return continuableToolCalls.length > 1
347
+ ? { kind: 'ambiguous' }
348
+ : { kind: 'new' };
349
+ }
350
+
351
+ private processNewToolCall(
352
+ toolCallDelta: DELTA,
353
+ {
354
+ wireId,
355
+ index,
356
+ name,
357
+ }: {
358
+ wireId: string | undefined;
359
+ index: number | null | undefined;
360
+ name: string | undefined;
361
+ },
362
+ ): TrackedToolCall {
144
363
  if (this.typeValidation === 'required') {
145
364
  if (toolCallDelta.type !== 'function') {
146
365
  throw new InvalidResponseDataError({
@@ -157,41 +376,40 @@ export class StreamingToolCallTracker<
157
376
  }
158
377
  }
159
378
 
160
- if (toolCallDelta.id == null) {
161
- throw new InvalidResponseDataError({
162
- data: toolCallDelta,
163
- message: `Expected 'id' to be a string.`,
164
- });
165
- }
166
-
167
- if (toolCallDelta.function?.name == null) {
379
+ if (name == null) {
168
380
  throw new InvalidResponseDataError({
169
381
  data: toolCallDelta,
170
382
  message: `Expected 'function.name' to be a string.`,
171
383
  });
172
384
  }
173
385
 
386
+ const id = this.createToolCallId(wireId);
387
+
174
388
  this.controller.enqueue({
175
389
  type: 'tool-input-start',
176
- id: toolCallDelta.id,
177
- toolName: toolCallDelta.function.name,
390
+ id,
391
+ toolName: name,
178
392
  });
179
393
 
180
394
  const metadata = this.extractMetadata?.(toolCallDelta);
181
395
 
396
+ const initialArguments = toolCallDelta.function?.arguments ?? '';
182
397
  const toolCall: TrackedToolCall = {
183
- id: toolCallDelta.id,
398
+ id,
399
+ index: index ?? undefined,
400
+ sequence: this.toolCalls.length,
184
401
  type: 'function',
185
402
  function: {
186
- name: toolCallDelta.function.name,
187
- arguments: toolCallDelta.function.arguments ?? '',
403
+ name,
404
+ arguments: initialArguments,
188
405
  },
406
+ argumentState: new StreamingToolCallArgumentState(initialArguments),
189
407
  hasFinished: false,
190
408
  metadata,
191
409
  };
192
- this.toolCalls.add(toolCall);
193
- if (toolCall.id.length > 0) {
194
- this.toolCallsById.set(toolCall.id, toolCall);
410
+ this.toolCalls.push(toolCall);
411
+ if (wireId != null) {
412
+ this.associateWireId(toolCall, wireId);
195
413
  }
196
414
 
197
415
  // Emit initial delta if arguments already present
@@ -210,6 +428,64 @@ export class StreamingToolCallTracker<
210
428
  return toolCall;
211
429
  }
212
430
 
431
+ private associateWireId(toolCall: TrackedToolCall, wireId: string): void {
432
+ let toolCallsWithId = this.toolCallsById.get(wireId);
433
+ if (toolCallsWithId == null) {
434
+ toolCallsWithId = new Set();
435
+ this.toolCallsById.set(wireId, toolCallsWithId);
436
+ }
437
+ toolCallsWithId.add(toolCall);
438
+ }
439
+
440
+ private associateIndex(toolCall: TrackedToolCall, index: number): void {
441
+ let toolCallsWithIndex = this.toolCallsByIndex.get(index);
442
+ if (toolCallsWithIndex == null) {
443
+ toolCallsWithIndex = new Set();
444
+ this.toolCallsByIndex.set(index, toolCallsWithIndex);
445
+ }
446
+ toolCallsWithIndex.add(toolCall);
447
+ }
448
+
449
+ private createToolCallId(wireId: string | undefined): string {
450
+ if (wireId != null && !this.usedToolCallIds.has(wireId)) {
451
+ this.usedToolCallIds.add(wireId);
452
+ return wireId;
453
+ }
454
+
455
+ const generatedId =
456
+ this.getNonBlankString(this._generateId()) ?? 'tool-call';
457
+
458
+ if (!this.usedToolCallIds.has(generatedId)) {
459
+ this.usedToolCallIds.add(generatedId);
460
+ return generatedId;
461
+ }
462
+
463
+ // Resume after the last suffix checked for this generated value. This
464
+ // keeps deterministic generators bounded without repeatedly rescanning
465
+ // the same occupied suffixes.
466
+ const initialSuffix = this.nextGeneratedIdSuffixes.get(generatedId) ?? 1;
467
+ const maximumSuffix = initialSuffix + this.usedToolCallIds.size;
468
+ for (let suffix = initialSuffix; suffix <= maximumSuffix; suffix++) {
469
+ const suffixedId = `${generatedId}-${suffix}`;
470
+ if (!this.usedToolCallIds.has(suffixedId)) {
471
+ this.usedToolCallIds.add(suffixedId);
472
+ this.nextGeneratedIdSuffixes.set(generatedId, suffix + 1);
473
+ return suffixedId;
474
+ }
475
+ }
476
+
477
+ // The bounded search above is guaranteed to return by the pigeonhole
478
+ // principle. This guards against future changes invalidating that
479
+ // invariant without restoring an unbounded retry loop.
480
+ throw new Error('Failed to create a unique tool call ID.');
481
+ }
482
+
483
+ private getNonBlankString(
484
+ value: string | null | undefined,
485
+ ): string | undefined {
486
+ return value != null && value.trim().length > 0 ? value : undefined;
487
+ }
488
+
213
489
  private processExistingToolCall(
214
490
  toolCall: TrackedToolCall,
215
491
  toolCallDelta: DELTA,
@@ -219,6 +495,7 @@ export class StreamingToolCallTracker<
219
495
  }
220
496
 
221
497
  if (toolCallDelta.function?.arguments != null) {
498
+ toolCall.argumentState.append(toolCallDelta.function.arguments);
222
499
  toolCall.function.arguments += toolCallDelta.function.arguments;
223
500
 
224
501
  this.controller.enqueue({
@@ -241,7 +518,7 @@ export class StreamingToolCallTracker<
241
518
 
242
519
  this.controller.enqueue({
243
520
  type: 'tool-call',
244
- toolCallId: toolCall.id ?? this._generateId(),
521
+ toolCallId: toolCall.id,
245
522
  toolName: toolCall.function.name,
246
523
  input: toolCall.function.arguments,
247
524
  ...(providerMetadata ? { providerMetadata } : {}),