ai 7.0.107 → 7.0.109

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.
@@ -26,7 +26,7 @@ For example, here’s how you can generate text with various models using the AI
26
26
  The AI SDK has these primary surfaces:
27
27
 
28
28
  - **[AI SDK Core](/docs/ai-sdk-core):** A unified API for generating text, structured objects, tool calls, and building agents with LLMs.
29
- - **[AI SDK UI](/docs/ai-sdk-ui):** A set of framework-agnostic hooks for quickly building chat and generative user interface.
29
+ - **[AI SDK UI](/docs/ai-sdk-ui):** A set of framework-agnostic hooks for quickly building chat and generative user interfaces.
30
30
  - **[AI SDK Harnesses](/docs/ai-sdk-harnesses):** A uniform API for running established agent harnesses with `HarnessAgent`.
31
31
 
32
32
  ## Model Providers
@@ -153,7 +153,7 @@ Pick the approach that best matches how you want to manage providers across your
153
153
 
154
154
  ## Wire up the UI
155
155
 
156
- Now that you have an API route that can query an LLM, it's time to setup your frontend. The AI SDK's [ UI ](/docs/ai-sdk-ui) package abstract the complexity of a chat interface into one hook, [`useChat`](/docs/reference/ai-sdk-ui/use-chat).
156
+ Now that you have an API route that can query an LLM, it's time to set up your frontend. The AI SDK's [ UI ](/docs/ai-sdk-ui) package abstracts the complexity of a chat interface into one hook, [`useChat`](/docs/reference/ai-sdk-ui/use-chat).
157
157
 
158
158
  Update your root page (`pages/index.tsx`) with the following code to show a list of chat messages and provide a user message input:
159
159
 
@@ -154,7 +154,7 @@ model: openai('gpt-5.1');
154
154
 
155
155
  ## Wire up the UI
156
156
 
157
- Now that you have an API route that can query an LLM, it's time to setup your frontend. The AI SDK's [ UI ](/docs/ai-sdk-ui/overview) package abstract the complexity of a chat interface into one hook, [`useChat`](/docs/reference/ai-sdk-ui/use-chat).
157
+ Now that you have an API route that can query an LLM, it's time to set up your frontend. The AI SDK's [ UI ](/docs/ai-sdk-ui/overview) package abstracts the complexity of a chat interface into one hook, [`useChat`](/docs/reference/ai-sdk-ui/use-chat).
158
158
 
159
159
  Update your root page (`pages/index.vue`) with the following code to show a list of chat messages and provide a user message input:
160
160
 
@@ -236,6 +236,37 @@ client infers `native` for loopback, localhost, and custom-scheme redirects,
236
236
  and `web` for remote HTTP(S) redirects. You can override this by setting
237
237
  `application_type` in the provider's `clientMetadata`.
238
238
 
239
+ ### Dynamic Client Credential Recovery
240
+
241
+ To let the SDK replace invalid credentials created through dynamic client
242
+ registration, implement `isClientInformationDynamicallyRegistered` on the
243
+ OAuth client provider. Return `true` only when the current client information
244
+ came from a dynamic registration response. Persist this provenance alongside
245
+ the credentials when the provider is recreated between requests.
246
+
247
+ ```typescript
248
+ const myOAuthClientProvider = {
249
+ // ...other OAuthClientProvider methods
250
+
251
+ async saveClientInformation(clientInformation) {
252
+ await saveClientRecord({
253
+ clientInformation,
254
+ dynamicallyRegistered: true,
255
+ });
256
+ },
257
+
258
+ async isClientInformationDynamicallyRegistered() {
259
+ return (await loadClientRecord())?.dynamicallyRegistered === true;
260
+ },
261
+ };
262
+ ```
263
+
264
+ When this method is omitted or returns `false`, client information is treated
265
+ as pre-registered and is preserved after `invalid_client` and
266
+ `unauthorized_client` errors. The SDK does not infer provenance from fields such
267
+ as `redirect_uris`, because pre-registered clients can contain the same
268
+ metadata as dynamically registered clients.
269
+
239
270
  ### Retrying Transient Tool Failures
240
271
 
241
272
  MCP tool calls can fail for transient transport reasons, such as rate limits,
@@ -362,6 +362,8 @@ When you only have raw continuation state from `suspendTurn()`, resume with
362
362
  const session = await agent.createSession({
363
363
  sessionId: chatId,
364
364
  continueFrom: continuationState,
365
+ // Rebind this when the suspended turn used host-only tool context:
366
+ toolsContext,
365
367
  });
366
368
 
367
369
  const result = await agent.continueStream({ session });
@@ -370,6 +372,11 @@ const result = await agent.continueStream({ session });
370
372
  Use `continueStream()` for incremental output, or `continueGenerate()` to drain
371
373
  the continued turn and return a `GenerateTextResult`.
372
374
 
375
+ `toolsContext` is intentionally not stored in continuation state because it can
376
+ contain credentials or non-serializable host objects. When a suspended turn
377
+ used static or `prepareCall`-derived tool context, pass the same per-tool map to
378
+ `createSession` to rebind it in the process that continues the turn.
379
+
373
380
  ## Stop After a Harness Step
374
381
 
375
382
  Use `stopWhen` to opt into semantic step boundaries. Predicates run after real
@@ -93,6 +93,52 @@ const agent = new HarnessAgent({
93
93
  When the harness calls `weather`, `HarnessAgent` executes the tool in your host
94
94
  process, then submits the result back to the harness runtime.
95
95
 
96
+ Host-executed tools can declare a `contextSchema` and receive turn-scoped
97
+ context through `toolsContext`:
98
+
99
+ ```ts
100
+ const lookupAccount = tool({
101
+ inputSchema: z.object({}),
102
+ contextSchema: z.object({ userId: z.string() }),
103
+ execute: async (_, { context }) => {
104
+ return loadAccount(context.userId);
105
+ },
106
+ });
107
+
108
+ const agent = new HarnessAgent({
109
+ harness: claudeCode,
110
+ tools: { lookupAccount },
111
+ toolsContext: {
112
+ lookupAccount: { userId: 'user-123' },
113
+ },
114
+ });
115
+ ```
116
+
117
+ Use `prepareCall` to replace `toolsContext` when the value depends on custom
118
+ call options. The type of `toolsContext` follows the tool set: it is rejected
119
+ when no tool declares a context schema, required when a tool requires a
120
+ context object, and optional when every context object is optional.
121
+ `HarnessAgent` validates each entry against the tool's `contextSchema` before
122
+ execution and exposes the configured map in step results and lifecycle
123
+ callbacks.
124
+
125
+ Tool context remains host-only and is not serialized into suspended-turn state.
126
+ When recreating a session for an unfinished turn, rebind it explicitly:
127
+
128
+ ```ts
129
+ const session = await agent.createSession({
130
+ sessionId,
131
+ continueFrom,
132
+ toolsContext: {
133
+ lookupAccount: { userId: 'user-123' },
134
+ },
135
+ });
136
+ ```
137
+
138
+ Missing or invalid context fails validation before the host tool executes. The
139
+ validation error returned to the harness runtime is intentionally generic so
140
+ host-only context values and schema details are not disclosed to the model.
141
+
96
142
  ## Client-Side Tools
97
143
 
98
144
  Omit `execute` when a browser, user interaction, or another external process
@@ -323,17 +323,32 @@ When the user clicks the "Regenerate" button, the AI provider will regenerate th
323
323
 
324
324
  ### Throttling UI Updates
325
325
 
326
- <Note>This feature is currently only available for React.</Note>
327
-
328
326
  By default, the `useChat` hook will trigger a render every time a new chunk is received.
329
- You can throttle the UI updates with the `throttle` option.
330
-
331
- ```tsx filename="page.tsx" highlight="2-3"
332
- const { messages, ... } = useChat({
333
- // Throttle the messages and data updates to 50ms:
334
- throttle: 50
335
- })
336
- ```
327
+ React and Vue applications can throttle reactive message updates with the `throttle` option.
328
+ Stream processing and event callbacks remain immediate, and the latest messages are published before the chat enters a terminal `ready` or `error` status.
329
+
330
+ <Tabs items={['React', 'Vue']}>
331
+ <Tab>
332
+ ```tsx filename="page.tsx" highlight="2-3"
333
+ const { messages, ... } = useChat({
334
+ // Throttle reactive message updates to 50ms:
335
+ throttle: 50,
336
+ });
337
+ ```
338
+ </Tab>
339
+ <Tab>
340
+ ```vue filename="pages/index.vue" highlight="4-6"
341
+ <script setup lang="ts">
342
+ import { useChat } from '@ai-sdk/vue';
343
+
344
+ const { messages } = useChat({
345
+ throttle: 50,
346
+ });
347
+ </script>
348
+ ```
349
+
350
+ </Tab>
351
+ </Tabs>
337
352
 
338
353
  ## Event Callbacks
339
354
 
@@ -369,7 +384,7 @@ It's worth noting that you can abort the processing by throwing an error in the
369
384
 
370
385
  ### Custom headers, body, and credentials
371
386
 
372
- By default, the `useChat` hook sends a HTTP POST request to the `/api/chat` endpoint with the message list as the request body. You can customize the request in two ways:
387
+ By default, the `useChat` hook sends an HTTP POST request to the `/api/chat` endpoint with the message list as the request body. You can customize the request in two ways:
373
388
 
374
389
  #### Hook-Level Configuration (Applied to all requests)
375
390
 
@@ -169,7 +169,7 @@ const { ... } = useCompletion({
169
169
 
170
170
  ## Configure Request Options
171
171
 
172
- By default, the `useCompletion` hook sends a HTTP POST request to the `/api/completion` endpoint with the prompt as part of the request body. You can customize the request by passing additional options to the `useCompletion` hook:
172
+ By default, the `useCompletion` hook sends an HTTP POST request to the `/api/completion` endpoint with the prompt as part of the request body. You can customize the request by passing additional options to the `useCompletion` hook:
173
173
 
174
174
  ```tsx
175
175
  const { messages, input, handleInputChange, handleSubmit } = useCompletion({
@@ -929,6 +929,13 @@ To see `generateText` in action, check out [these examples](#examples).
929
929
  description:
930
930
  'The error that occurred while parsing the tool call.',
931
931
  },
932
+ {
933
+ name: 'abortSignal',
934
+ type: 'AbortSignal',
935
+ isOptional: true,
936
+ description:
937
+ 'An optional signal for cancelling the tool call repair.',
938
+ },
932
939
  ],
933
940
  },
934
941
  ],
@@ -980,6 +980,13 @@ To see `streamText` in action, check out [these examples](#examples).
980
980
  description:
981
981
  'The error that occurred while parsing the tool call.',
982
982
  },
983
+ {
984
+ name: 'abortSignal',
985
+ type: 'AbortSignal',
986
+ isOptional: true,
987
+ description:
988
+ 'An optional signal for cancelling the tool call repair.',
989
+ },
983
990
  ],
984
991
  },
985
992
  ],
@@ -338,7 +338,7 @@ Allows you to easily create a conversational user interface for your chatbot app
338
338
  type: 'number',
339
339
  isOptional: true,
340
340
  description:
341
- 'Custom throttle wait in ms for the chat messages and data updates. Default is undefined, which disables throttling.',
341
+ 'React and Vue only. Custom throttle wait time in milliseconds for reactive chat message updates. Positive values reduce UI update frequency without delaying stream processing or callbacks, and the latest messages are published before a ready or error status. Default is undefined, which disables throttling.',
342
342
  },
343
343
  {
344
344
  name: 'resume',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai",
3
- "version": "7.0.107",
3
+ "version": "7.0.109",
4
4
  "type": "module",
5
5
  "description": "AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.",
6
6
  "license": "Apache-2.0",
@@ -42,20 +42,20 @@
42
42
  }
43
43
  },
44
44
  "dependencies": {
45
- "@ai-sdk/gateway": "4.0.87",
45
+ "@ai-sdk/gateway": "4.0.88",
46
46
  "@ai-sdk/provider": "4.0.17",
47
47
  "@ai-sdk/provider-utils": "5.0.45"
48
48
  },
49
49
  "devDependencies": {
50
- "@ai-sdk/amazon-bedrock": "5.0.88",
51
- "@ai-sdk/deepseek": "3.0.49",
50
+ "@ai-sdk/amazon-bedrock": "5.0.90",
51
+ "@ai-sdk/deepseek": "3.0.50",
52
52
  "@ai-sdk/google": "4.0.76",
53
53
  "@ai-sdk/groq": "4.0.46",
54
54
  "@ai-sdk/huggingface": "2.0.53",
55
55
  "@ai-sdk/moonshotai": "3.0.54",
56
- "@ai-sdk/openai": "4.0.71",
56
+ "@ai-sdk/openai": "4.0.72",
57
57
  "@ai-sdk/test-server": "2.0.1",
58
- "@ai-sdk/xai": "5.0.4",
58
+ "@ai-sdk/xai": "5.0.5",
59
59
  "@edge-runtime/vm": "^5.0.0",
60
60
  "@smithy/eventstream-codec": "^4.3.3",
61
61
  "@smithy/util-utf8": "^4.3.3",
@@ -1079,11 +1079,12 @@ export async function generateText<
1079
1079
  .map(toolCall =>
1080
1080
  parseToolCall({
1081
1081
  toolCall,
1082
- tools: stepExecutionTools as TOOLS,
1082
+ tools: stepModelTools as TOOLS,
1083
1083
  repairToolCall,
1084
1084
  refineToolInput,
1085
1085
  instructions: stepInstructions,
1086
1086
  messages: stepMessages,
1087
+ abortSignal: mergedAbortSignal,
1087
1088
  }),
1088
1089
  ),
1089
1090
  );
@@ -23,6 +23,7 @@ export async function parseToolCall<TOOLS extends ToolSet>({
23
23
  refineToolInput,
24
24
  messages,
25
25
  instructions,
26
+ abortSignal,
26
27
  }: {
27
28
  toolCall: LanguageModelV4ToolCall;
28
29
  tools: TOOLS | undefined;
@@ -30,6 +31,7 @@ export async function parseToolCall<TOOLS extends ToolSet>({
30
31
  refineToolInput?: ToolInputRefinement<TOOLS> | undefined;
31
32
  instructions: Instructions | undefined;
32
33
  messages: ModelMessage[];
34
+ abortSignal?: AbortSignal;
33
35
  }): Promise<TypedToolCall<TOOLS>> {
34
36
  try {
35
37
  if (tools == null) {
@@ -63,19 +65,25 @@ export async function parseToolCall<TOOLS extends ToolSet>({
63
65
  let repairedToolCall: LanguageModelV4ToolCall | null = null;
64
66
 
65
67
  try {
66
- repairedToolCall = await repairToolCall({
67
- toolCall,
68
- tools,
69
- inputSchema: async ({ toolName }) => {
70
- const inputSchema = getOwn(tools, toolName)?.inputSchema;
71
- return await asSchema(inputSchema).jsonSchema;
72
- },
73
- instructions,
74
- system: instructions,
75
- messages,
76
- error,
68
+ abortSignal?.throwIfAborted();
69
+ repairedToolCall = await waitForPromiseWithAbortSignal({
70
+ promise: repairToolCall({
71
+ toolCall,
72
+ tools,
73
+ inputSchema: async ({ toolName }) => {
74
+ const inputSchema = getOwn(tools, toolName)?.inputSchema;
75
+ return await asSchema(inputSchema).jsonSchema;
76
+ },
77
+ instructions,
78
+ system: instructions,
79
+ messages,
80
+ error,
81
+ abortSignal,
82
+ }),
83
+ abortSignal,
77
84
  });
78
85
  } catch (repairError) {
86
+ abortSignal?.throwIfAborted();
79
87
  throw new ToolCallRepairError({
80
88
  cause: repairError,
81
89
  originalError: error,
@@ -87,12 +95,18 @@ export async function parseToolCall<TOOLS extends ToolSet>({
87
95
  throw error;
88
96
  }
89
97
 
90
- return await refineParsedToolCallInput({
98
+ const parsedRepairedToolCall = await refineParsedToolCallInput({
91
99
  toolCall: await doParseToolCall({ toolCall: repairedToolCall, tools }),
92
100
  refineToolInput,
93
101
  });
102
+
103
+ abortSignal?.throwIfAborted();
104
+
105
+ return parsedRepairedToolCall;
94
106
  }
95
107
  } catch (error) {
108
+ abortSignal?.throwIfAborted();
109
+
96
110
  // use parsed input when possible
97
111
  const parsedInput = await safeParseJSON({ text: toolCall.input });
98
112
  const input = parsedInput.success ? parsedInput.value : toolCall.input;
@@ -115,6 +129,44 @@ export async function parseToolCall<TOOLS extends ToolSet>({
115
129
  }
116
130
  }
117
131
 
132
+ async function waitForPromiseWithAbortSignal<T>({
133
+ promise,
134
+ abortSignal,
135
+ }: {
136
+ promise: PromiseLike<T>;
137
+ abortSignal: AbortSignal | undefined;
138
+ }): Promise<T> {
139
+ if (abortSignal == null) {
140
+ return await promise;
141
+ }
142
+
143
+ return await new Promise<T>((resolve, reject) => {
144
+ const cleanup = () => {
145
+ abortSignal.removeEventListener('abort', onAbort);
146
+ };
147
+ const onAbort = () => {
148
+ cleanup();
149
+ reject(abortSignal.reason);
150
+ };
151
+
152
+ Promise.resolve(promise)
153
+ .then(value => {
154
+ cleanup();
155
+ resolve(value);
156
+ })
157
+ .catch(error => {
158
+ cleanup();
159
+ reject(error);
160
+ });
161
+
162
+ abortSignal.addEventListener('abort', onAbort, { once: true });
163
+
164
+ if (abortSignal.aborted) {
165
+ onAbort();
166
+ }
167
+ });
168
+ }
169
+
118
170
  async function refineParsedToolCallInput<TOOLS extends ToolSet>({
119
171
  toolCall,
120
172
  refineToolInput,
@@ -373,6 +373,7 @@ export async function streamLanguageModelCall<
373
373
  messages: standardizedPrompt.messages,
374
374
  repairToolCall,
375
375
  refineToolInput,
376
+ abortSignal,
376
377
  callId: effectiveCallId,
377
378
  provider: resolvedModel.provider,
378
379
  modelId: resolvedModel.modelId,
@@ -400,6 +401,7 @@ function createLanguageModelV4StreamPartToLanguageModelStreamPartTransform<
400
401
  messages,
401
402
  repairToolCall,
402
403
  refineToolInput,
404
+ abortSignal,
403
405
  callId,
404
406
  provider,
405
407
  modelId,
@@ -414,6 +416,7 @@ function createLanguageModelV4StreamPartToLanguageModelStreamPartTransform<
414
416
  messages: ModelMessage[];
415
417
  repairToolCall: ToolCallRepairFunction<TOOLS> | undefined;
416
418
  refineToolInput: ToolInputRefinement<TOOLS> | undefined;
419
+ abortSignal: AbortSignal | undefined;
417
420
  callId: string;
418
421
  provider: string;
419
422
  modelId: string;
@@ -637,40 +640,47 @@ function createLanguageModelV4StreamPartToLanguageModelStreamPartTransform<
637
640
  callbacks: onLanguageModelCallEnd,
638
641
  });
639
642
 
640
- // Preserve the completed model call's usage, metadata, and
641
- // performance even when response validation below surfaces a
642
- // semantic error.
643
- controller.enqueue({
644
- type: 'model-call-end',
645
- finishReason: chunk.finishReason.unified,
646
- rawFinishReason: chunk.finishReason.raw,
647
- usage,
648
- providerMetadata: chunk.providerMetadata,
649
- performance,
650
- });
651
-
652
643
  const enforcedToolChoice =
653
644
  toolChoice.type === 'required' || toolChoice.type === 'tool'
654
645
  ? toolChoice
655
646
  : undefined;
656
647
 
657
- if (
648
+ const toolChoiceViolationError =
658
649
  enforcedToolChoice != null &&
659
650
  ![...toolCallsByToolCallId.values()].some(
660
651
  toolCall =>
661
652
  enforcedToolChoice.type === 'required' ||
662
653
  toolCall.toolName === enforcedToolChoice.toolName,
663
654
  )
664
- ) {
655
+ ? new ToolChoiceViolationError({
656
+ toolChoice: enforcedToolChoice,
657
+ finishReason: chunk.finishReason.unified,
658
+ provider,
659
+ modelId,
660
+ content: rawModelCallContent,
661
+ })
662
+ : undefined;
663
+
664
+ // Preserve the completed model call's usage, metadata, and
665
+ // performance even when response validation below surfaces a
666
+ // semantic error. Prevent invalid tool calls from being executed
667
+ // when the model-call-end event reaches the tool executor.
668
+ controller.enqueue({
669
+ type: 'model-call-end',
670
+ finishReason:
671
+ toolChoiceViolationError == null
672
+ ? chunk.finishReason.unified
673
+ : 'error',
674
+ rawFinishReason: chunk.finishReason.raw,
675
+ usage,
676
+ providerMetadata: chunk.providerMetadata,
677
+ performance,
678
+ });
679
+
680
+ if (toolChoiceViolationError != null) {
665
681
  controller.enqueue({
666
682
  type: 'error',
667
- error: new ToolChoiceViolationError({
668
- toolChoice: enforcedToolChoice,
669
- finishReason: chunk.finishReason.unified,
670
- provider,
671
- modelId,
672
- content: rawModelCallContent,
673
- }),
683
+ error: toolChoiceViolationError,
674
684
  });
675
685
  break;
676
686
  }
@@ -688,6 +698,7 @@ function createLanguageModelV4StreamPartToLanguageModelStreamPartTransform<
688
698
  refineToolInput,
689
699
  instructions,
690
700
  messages,
701
+ abortSignal,
691
702
  });
692
703
 
693
704
  toolCallsByToolCallId.set(toolCall.toolCallId, toolCall);
@@ -17,6 +17,7 @@ import type { ToolSet } from '@ai-sdk/provider-utils';
17
17
  * @param options.tools - The tools that are available.
18
18
  * @param options.inputSchema - A function that returns the JSON Schema for a tool.
19
19
  * @param options.error - The error that occurred while parsing the tool call.
20
+ * @param options.abortSignal - An optional signal for cancelling the repair.
20
21
  */
21
22
  export type ToolCallRepairFunction<TOOLS extends ToolSet> = (options: {
22
23
  instructions: Instructions | undefined;
@@ -29,4 +30,5 @@ export type ToolCallRepairFunction<TOOLS extends ToolSet> = (options: {
29
30
  tools: TOOLS;
30
31
  inputSchema: (options: { toolName: string }) => PromiseLike<JSONSchema7>;
31
32
  error: NoSuchToolError | InvalidToolInputError;
33
+ abortSignal?: AbortSignal;
32
34
  }) => Promise<LanguageModelV4ToolCall | null>;
@@ -10,13 +10,19 @@ import type {
10
10
  type IsEmptyObject<OBJECT> = keyof OBJECT extends never ? true : false;
11
11
 
12
12
  /**
13
- * Helper type to make the toolsContext parameter optional, required, or
14
- * unavailable based on the tool set.
13
+ * Makes the toolsContext setting optional, required, or unavailable based on
14
+ * the tool set.
15
+ */
16
+ export type ToolsContextSettings<TOOLS extends ToolSet> =
17
+ IsEmptyObject<InferToolSetContext<TOOLS>> extends true
18
+ ? { toolsContext?: never }
19
+ : HasRequiredKey<InferToolSetContext<TOOLS>> extends true
20
+ ? { toolsContext: InferToolSetContext<TOOLS> }
21
+ : { toolsContext?: InferToolSetContext<TOOLS> };
22
+
23
+ /**
24
+ * Helper type for request options that include both tools and their context.
15
25
  */
16
26
  export type ToolsContextParameter<TOOLS extends ToolSet> = {
17
27
  tools?: TOOLS;
18
- } & (IsEmptyObject<InferToolSetContext<TOOLS>> extends true
19
- ? { toolsContext?: never }
20
- : HasRequiredKey<InferToolSetContext<TOOLS>> extends true
21
- ? { toolsContext: InferToolSetContext<TOOLS> }
22
- : { toolsContext?: InferToolSetContext<TOOLS> });
28
+ } & ToolsContextSettings<TOOLS>;
@@ -90,12 +90,17 @@ export function extractReasoningMiddleware({
90
90
  afterSwitch: boolean;
91
91
  isReasoning: boolean;
92
92
  buffer: string;
93
- idCounter: number;
93
+ reasoningId: string | undefined;
94
94
  textId: string;
95
95
  }
96
96
  > = createIdMap();
97
97
 
98
- let delayedTextStart: LanguageModelV4StreamPart | undefined;
98
+ let reasoningIdCounter = 0;
99
+
100
+ const delayedTextStarts: Record<
101
+ string,
102
+ Extract<LanguageModelV4StreamPart, { type: 'text-start' }>
103
+ > = createIdMap();
99
104
 
100
105
  return {
101
106
  stream: stream.pipeThrough(
@@ -107,13 +112,16 @@ export function extractReasoningMiddleware({
107
112
  // do not send `text-start` before `reasoning-start`
108
113
  // https://github.com/vercel/ai/issues/7774
109
114
  if (chunk.type === 'text-start') {
110
- delayedTextStart = chunk;
115
+ delayedTextStarts[chunk.id] = chunk;
111
116
  return;
112
117
  }
113
118
 
114
- if (chunk.type === 'text-end' && delayedTextStart) {
115
- controller.enqueue(delayedTextStart);
116
- delayedTextStart = undefined;
119
+ if (
120
+ chunk.type === 'text-end' &&
121
+ delayedTextStarts[chunk.id] != null
122
+ ) {
123
+ controller.enqueue(delayedTextStarts[chunk.id]);
124
+ delete delayedTextStarts[chunk.id];
117
125
  }
118
126
 
119
127
  if (chunk.type !== 'text-delta') {
@@ -128,7 +136,7 @@ export function extractReasoningMiddleware({
128
136
  afterSwitch: false,
129
137
  isReasoning: startWithReasoning,
130
138
  buffer: '',
131
- idCounter: 0,
139
+ reasoningId: undefined,
132
140
  textId: chunk.id,
133
141
  };
134
142
  }
@@ -137,6 +145,10 @@ export function extractReasoningMiddleware({
137
145
 
138
146
  activeExtraction.buffer += chunk.delta;
139
147
 
148
+ function getReasoningId() {
149
+ return (activeExtraction.reasoningId ??= `reasoning-${reasoningIdCounter++}`);
150
+ }
151
+
140
152
  function publish(text: string) {
141
153
  if (text.length > 0) {
142
154
  const prefix =
@@ -154,7 +166,7 @@ export function extractReasoningMiddleware({
154
166
  ) {
155
167
  controller.enqueue({
156
168
  type: 'reasoning-start',
157
- id: `reasoning-${activeExtraction.idCounter}`,
169
+ id: getReasoningId(),
158
170
  });
159
171
  }
160
172
 
@@ -162,12 +174,14 @@ export function extractReasoningMiddleware({
162
174
  controller.enqueue({
163
175
  type: 'reasoning-delta',
164
176
  delta: prefix + text,
165
- id: `reasoning-${activeExtraction.idCounter}`,
177
+ id: getReasoningId(),
166
178
  });
167
179
  } else {
168
- if (delayedTextStart) {
169
- controller.enqueue(delayedTextStart);
170
- delayedTextStart = undefined;
180
+ if (delayedTextStarts[activeExtraction.textId] != null) {
181
+ controller.enqueue(
182
+ delayedTextStarts[activeExtraction.textId],
183
+ );
184
+ delete delayedTextStarts[activeExtraction.textId];
171
185
  }
172
186
  controller.enqueue({
173
187
  type: 'text-delta',
@@ -221,15 +235,16 @@ export function extractReasoningMiddleware({
221
235
  if (activeExtraction.isFirstReasoning) {
222
236
  controller.enqueue({
223
237
  type: 'reasoning-start',
224
- id: `reasoning-${activeExtraction.idCounter}`,
238
+ id: getReasoningId(),
225
239
  });
226
240
  }
227
241
 
228
242
  // reasoning part finished:
229
243
  controller.enqueue({
230
244
  type: 'reasoning-end',
231
- id: `reasoning-${activeExtraction.idCounter++}`,
245
+ id: getReasoningId(),
232
246
  });
247
+ activeExtraction.reasoningId = undefined;
233
248
  }
234
249
 
235
250
  activeExtraction.isReasoning = !activeExtraction.isReasoning;