@ai-sdk/openai 3.0.97 → 3.0.99

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.
@@ -1277,6 +1277,63 @@ The custom tool can be configured with:
1277
1277
  - **definition** _string_ - (grammar only) The grammar definition string (a regex pattern or Lark grammar).
1278
1278
  - **execute** _function_ (optional) - An async function that receives the raw string input and returns a string result. Enables multi-turn tool calling.
1279
1279
 
1280
+ #### Restricting Callable Tools
1281
+
1282
+ The `allowedTools` provider option restricts which of the tools you declared the model is allowed to
1283
+ call, while still sending the full `tools` list to OpenAI. Because the tools list stays byte-identical
1284
+ across requests, the prompt cache is preserved — unlike `activeTools`, which removes tools from the
1285
+ request and invalidates the cache whenever the allow-list changes.
1286
+
1287
+ `allowedTools` is only supported by the Responses API, and it overrides the request-level `toolChoice`.
1288
+
1289
+ ```ts
1290
+ import { openai } from '@ai-sdk/openai';
1291
+ import { generateText } from 'ai';
1292
+
1293
+ const result = await generateText({
1294
+ model: openai.responses('gpt-5.5'),
1295
+ tools: {
1296
+ weather: weatherTool,
1297
+ cityAttractions: cityAttractionsTool,
1298
+ search: openai.tools.webSearch(),
1299
+ },
1300
+ providerOptions: {
1301
+ openai: {
1302
+ allowedTools: { toolNames: ['weather', 'search'], mode: 'auto' },
1303
+ },
1304
+ },
1305
+ prompt: 'What is the weather in San Francisco?',
1306
+ });
1307
+ ```
1308
+
1309
+ - **toolNames** _string[]_ - The tools the model may call, named as you declared them in `tools`. For
1310
+ provider-defined tools you may also use the canonical OpenAI name (for example `web_search`).
1311
+ - **mode** _'auto' | 'required'_ (optional) - `'auto'` (default) lets the model pick one of the allowed
1312
+ tools or answer with a message; `'required'` forces it to call at least one of them.
1313
+
1314
+ Both function tools and provider-defined tools can be allow-listed, including web search, file search,
1315
+ image generation, code interpreter, apply patch, shell, custom tools, and MCP servers.
1316
+
1317
+ If a name matches both a tool you declared and the canonical OpenAI name of a different tool, the tool
1318
+ you declared under that name wins, and a warning reports the ambiguity. If several tools share the same
1319
+ canonical name — two MCP servers, for example — that name is ambiguous, so it is dropped with a warning;
1320
+ name those tools as you declared them instead.
1321
+
1322
+ <Note>
1323
+ Three kinds of tools cannot be allow-listed, because OpenAI's `allowed_tools`
1324
+ only sees the eagerly-loaded tool list: the [tool search tool](#tool-search),
1325
+ tools marked with `deferLoading`, and tools grouped into a namespace. Naming
1326
+ one of these in `allowedTools` removes it from the allow-list and emits a
1327
+ warning; if that leaves no tools at all, the request fails with an error
1328
+ instead of silently sending no restriction.
1329
+ </Note>
1330
+
1331
+ <Note>
1332
+ An MCP server is allow-listed as a whole: the entry carries the server label,
1333
+ so every tool on that server is callable. To restrict which tools on a server
1334
+ the model may call, use the MCP tool's own `allowedTools` argument.
1335
+ </Note>
1336
+
1280
1337
  #### Image Inputs
1281
1338
 
1282
1339
  The OpenAI Responses API supports Image inputs for appropriate models.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/openai",
3
- "version": "3.0.97",
3
+ "version": "3.0.99",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  UnsupportedFunctionalityError,
3
3
  type LanguageModelV3Prompt,
4
+ type LanguageModelV3ToolResultOutput,
5
+ type LanguageModelV3ToolResultPart,
4
6
  type LanguageModelV3ToolApprovalResponsePart,
5
7
  type SharedV3ProviderOptions,
6
8
  type SharedV3Warning,
@@ -33,11 +35,206 @@ import type {
33
35
  OpenAIResponsesInput,
34
36
  OpenAIResponsesReasoning,
35
37
  } from './openai-responses-api';
38
+ import {
39
+ getParallelToolCallMetadata,
40
+ type ParallelToolCallMetadata,
41
+ } from './expand-parallel-tool-call';
36
42
 
37
43
  function serializeToolCallArguments(input: unknown): string {
38
44
  return JSON.stringify(input === undefined ? {} : input);
39
45
  }
40
46
 
47
+ async function convertFunctionToolResultOutput({
48
+ output,
49
+ providerOptionsName,
50
+ warnings,
51
+ }: {
52
+ output: LanguageModelV3ToolResultOutput;
53
+ providerOptionsName: string;
54
+ warnings: Array<SharedV3Warning>;
55
+ }): Promise<OpenAIResponsesFunctionCallOutput['output']> {
56
+ switch (output.type) {
57
+ case 'text':
58
+ case 'error-text':
59
+ return output.value;
60
+ case 'execution-denied':
61
+ return output.reason ?? 'Tool call execution denied.';
62
+ case 'json':
63
+ case 'error-json':
64
+ return JSON.stringify(output.value);
65
+ case 'content':
66
+ return output.value
67
+ .map(item => {
68
+ const promptCacheBreakpoint = getPromptCacheBreakpoint(
69
+ item.providerOptions,
70
+ providerOptionsName,
71
+ );
72
+ switch (item.type) {
73
+ case 'text': {
74
+ return {
75
+ type: 'input_text' as const,
76
+ text: item.text,
77
+ ...(promptCacheBreakpoint != null && {
78
+ prompt_cache_breakpoint: promptCacheBreakpoint,
79
+ }),
80
+ };
81
+ }
82
+
83
+ case 'image-data': {
84
+ return {
85
+ type: 'input_image' as const,
86
+ image_url: `data:${item.mediaType};base64,${item.data}`,
87
+ detail:
88
+ item.providerOptions?.[providerOptionsName]?.imageDetail,
89
+ ...(promptCacheBreakpoint != null && {
90
+ prompt_cache_breakpoint: promptCacheBreakpoint,
91
+ }),
92
+ };
93
+ }
94
+
95
+ case 'image-url': {
96
+ return {
97
+ type: 'input_image' as const,
98
+ image_url: item.url,
99
+ detail:
100
+ item.providerOptions?.[providerOptionsName]?.imageDetail,
101
+ ...(promptCacheBreakpoint != null && {
102
+ prompt_cache_breakpoint: promptCacheBreakpoint,
103
+ }),
104
+ };
105
+ }
106
+
107
+ case 'file-data': {
108
+ return {
109
+ type: 'input_file' as const,
110
+ filename: item.filename ?? 'data',
111
+ file_data: `data:${item.mediaType};base64,${item.data}`,
112
+ ...(promptCacheBreakpoint != null && {
113
+ prompt_cache_breakpoint: promptCacheBreakpoint,
114
+ }),
115
+ };
116
+ }
117
+
118
+ case 'file-url': {
119
+ return {
120
+ type: 'input_file' as const,
121
+ file_url: item.url,
122
+ ...(promptCacheBreakpoint != null && {
123
+ prompt_cache_breakpoint: promptCacheBreakpoint,
124
+ }),
125
+ };
126
+ }
127
+
128
+ default: {
129
+ warnings.push({
130
+ type: 'other',
131
+ message: `unsupported tool content part type: ${item.type}`,
132
+ });
133
+ return undefined;
134
+ }
135
+ }
136
+ })
137
+ .filter(isNonNullable);
138
+ }
139
+ }
140
+
141
+ type ParallelToolResultGroup = {
142
+ metadata: ParallelToolCallMetadata;
143
+ results: Array<LanguageModelV3ToolResultPart>;
144
+ };
145
+
146
+ function hasSameParallelToolCall(
147
+ first: ParallelToolCallMetadata,
148
+ second: ParallelToolCallMetadata,
149
+ ): boolean {
150
+ return (
151
+ first.itemId === second.itemId &&
152
+ first.toolCallId === second.toolCallId &&
153
+ first.toolName === second.toolName &&
154
+ first.input === second.input &&
155
+ first.count === second.count
156
+ );
157
+ }
158
+
159
+ function collectCompleteParallelToolResultGroups({
160
+ prompt,
161
+ providerOptionsName,
162
+ }: {
163
+ prompt: LanguageModelV3Prompt;
164
+ providerOptionsName: string;
165
+ }): Map<string, ParallelToolResultGroup> {
166
+ const pendingGroups = new Map<
167
+ string,
168
+ {
169
+ metadata: ParallelToolCallMetadata;
170
+ results: Map<number, LanguageModelV3ToolResultPart>;
171
+ invalid: boolean;
172
+ }
173
+ >();
174
+
175
+ for (const message of prompt) {
176
+ if (message.role !== 'tool') {
177
+ continue;
178
+ }
179
+
180
+ for (const part of message.content) {
181
+ if (part.type !== 'tool-result') {
182
+ continue;
183
+ }
184
+
185
+ const metadata = getParallelToolCallMetadata({
186
+ providerOptions: part.providerOptions,
187
+ providerOptionsName,
188
+ });
189
+
190
+ if (metadata == null) {
191
+ continue;
192
+ }
193
+
194
+ const existing = pendingGroups.get(metadata.toolCallId);
195
+ if (existing == null) {
196
+ pendingGroups.set(metadata.toolCallId, {
197
+ metadata,
198
+ results: new Map([[metadata.index, part]]),
199
+ invalid: false,
200
+ });
201
+ continue;
202
+ }
203
+
204
+ if (
205
+ !hasSameParallelToolCall(existing.metadata, metadata) ||
206
+ existing.results.has(metadata.index)
207
+ ) {
208
+ existing.invalid = true;
209
+ continue;
210
+ }
211
+
212
+ existing.results.set(metadata.index, part);
213
+ }
214
+ }
215
+
216
+ const completeGroups = new Map<string, ParallelToolResultGroup>();
217
+
218
+ for (const [toolCallId, group] of pendingGroups) {
219
+ if (group.invalid || group.results.size !== group.metadata.count) {
220
+ continue;
221
+ }
222
+
223
+ const results = Array.from({ length: group.metadata.count }, (_, index) =>
224
+ group.results.get(index),
225
+ );
226
+
227
+ if (results.every(isNonNullable)) {
228
+ completeGroups.set(toolCallId, {
229
+ metadata: group.metadata,
230
+ results,
231
+ });
232
+ }
233
+ }
234
+
235
+ return completeGroups;
236
+ }
237
+
41
238
  type OpenAIPromptCacheBreakpoint = { mode: 'explicit' };
42
239
 
43
240
  function getPromptCacheBreakpoint(
@@ -93,6 +290,15 @@ export async function convertToOpenAIResponsesInput({
93
290
  let input: OpenAIResponsesInput = [];
94
291
  const warnings: Array<SharedV3Warning> = [];
95
292
  const processedApprovalIds = new Set<string>();
293
+ const parallelToolResultGroups =
294
+ hasConversation || hasPreviousResponseId
295
+ ? collectCompleteParallelToolResultGroups({
296
+ prompt,
297
+ providerOptionsName,
298
+ })
299
+ : new Map<string, ParallelToolResultGroup>();
300
+ const emittedParallelToolCalls = new Set<string>();
301
+ const emittedParallelToolResults = new Set<string>();
96
302
 
97
303
  for (const { role, content, providerOptions } of prompt) {
98
304
  switch (role) {
@@ -279,6 +485,49 @@ export async function convertToOpenAIResponsesInput({
279
485
  break;
280
486
  }
281
487
  case 'tool-call': {
488
+ const parallelToolCallMetadata = getParallelToolCallMetadata({
489
+ providerOptions: part.providerOptions,
490
+ providerOptionsName,
491
+ });
492
+ const parallelToolResultGroup =
493
+ parallelToolCallMetadata == null
494
+ ? undefined
495
+ : parallelToolResultGroups.get(
496
+ parallelToolCallMetadata.toolCallId,
497
+ );
498
+
499
+ if (
500
+ parallelToolCallMetadata != null &&
501
+ parallelToolResultGroup != null &&
502
+ hasSameParallelToolCall(
503
+ parallelToolResultGroup.metadata,
504
+ parallelToolCallMetadata,
505
+ )
506
+ ) {
507
+ if (
508
+ !emittedParallelToolCalls.has(
509
+ parallelToolResultGroup.metadata.toolCallId,
510
+ )
511
+ ) {
512
+ emittedParallelToolCalls.add(
513
+ parallelToolResultGroup.metadata.toolCallId,
514
+ );
515
+
516
+ // Conversations already contain the original wrapper item.
517
+ // previousResponseId chains require plain client function
518
+ // calls to be reconstructed in full.
519
+ if (!hasConversation) {
520
+ input.push({
521
+ type: 'function_call',
522
+ call_id: parallelToolResultGroup.metadata.toolCallId,
523
+ name: parallelToolResultGroup.metadata.toolName,
524
+ arguments: parallelToolResultGroup.metadata.input,
525
+ });
526
+ }
527
+ }
528
+ break;
529
+ }
530
+
282
531
  const id = (part.providerOptions?.[providerOptionsName]?.itemId ??
283
532
  (
284
533
  part as {
@@ -709,6 +958,61 @@ export async function convertToOpenAIResponsesInput({
709
958
  continue;
710
959
  }
711
960
 
961
+ const parallelToolCallMetadata = getParallelToolCallMetadata({
962
+ providerOptions: part.providerOptions,
963
+ providerOptionsName,
964
+ });
965
+ const parallelToolResultGroup =
966
+ parallelToolCallMetadata == null
967
+ ? undefined
968
+ : parallelToolResultGroups.get(
969
+ parallelToolCallMetadata.toolCallId,
970
+ );
971
+
972
+ if (
973
+ parallelToolCallMetadata != null &&
974
+ parallelToolResultGroup != null &&
975
+ hasSameParallelToolCall(
976
+ parallelToolResultGroup.metadata,
977
+ parallelToolCallMetadata,
978
+ )
979
+ ) {
980
+ if (
981
+ !emittedParallelToolResults.has(
982
+ parallelToolResultGroup.metadata.toolCallId,
983
+ )
984
+ ) {
985
+ emittedParallelToolResults.add(
986
+ parallelToolResultGroup.metadata.toolCallId,
987
+ );
988
+
989
+ const toolOutputs = await Promise.all(
990
+ parallelToolResultGroup.results.map(async result =>
991
+ convertFunctionToolResultOutput({
992
+ output: result.output,
993
+ providerOptionsName,
994
+ warnings,
995
+ }),
996
+ ),
997
+ );
998
+
999
+ input.push({
1000
+ type: 'function_call_output',
1001
+ call_id: parallelToolResultGroup.metadata.toolCallId,
1002
+ // The internal wrapper returns one output containing the child
1003
+ // results in the same order as the original tool_uses array.
1004
+ output: toolOutputs
1005
+ .map(output =>
1006
+ typeof output === 'string'
1007
+ ? output
1008
+ : JSON.stringify(output),
1009
+ )
1010
+ .join('\n'),
1011
+ });
1012
+ }
1013
+ continue;
1014
+ }
1015
+
712
1016
  const output = part.output;
713
1017
 
714
1018
  // Skip execution-denied with approvalId - already handled via tool-approval-response
@@ -897,96 +1201,11 @@ export async function convertToOpenAIResponsesInput({
897
1201
  continue;
898
1202
  }
899
1203
 
900
- let contentValue: OpenAIResponsesFunctionCallOutput['output'];
901
- switch (output.type) {
902
- case 'text':
903
- case 'error-text':
904
- contentValue = output.value;
905
- break;
906
- case 'execution-denied':
907
- contentValue = output.reason ?? 'Tool call execution denied.';
908
- break;
909
- case 'json':
910
- case 'error-json':
911
- contentValue = JSON.stringify(output.value);
912
- break;
913
- case 'content':
914
- contentValue = output.value
915
- .map(item => {
916
- const promptCacheBreakpoint = getPromptCacheBreakpoint(
917
- item.providerOptions,
918
- providerOptionsName,
919
- );
920
- switch (item.type) {
921
- case 'text': {
922
- return {
923
- type: 'input_text' as const,
924
- text: item.text,
925
- ...(promptCacheBreakpoint != null && {
926
- prompt_cache_breakpoint: promptCacheBreakpoint,
927
- }),
928
- };
929
- }
930
-
931
- case 'image-data': {
932
- return {
933
- type: 'input_image' as const,
934
- image_url: `data:${item.mediaType};base64,${item.data}`,
935
- detail:
936
- item.providerOptions?.[providerOptionsName]
937
- ?.imageDetail,
938
- ...(promptCacheBreakpoint != null && {
939
- prompt_cache_breakpoint: promptCacheBreakpoint,
940
- }),
941
- };
942
- }
943
-
944
- case 'image-url': {
945
- return {
946
- type: 'input_image' as const,
947
- image_url: item.url,
948
- detail:
949
- item.providerOptions?.[providerOptionsName]
950
- ?.imageDetail,
951
- ...(promptCacheBreakpoint != null && {
952
- prompt_cache_breakpoint: promptCacheBreakpoint,
953
- }),
954
- };
955
- }
956
-
957
- case 'file-data': {
958
- return {
959
- type: 'input_file' as const,
960
- filename: item.filename ?? 'data',
961
- file_data: `data:${item.mediaType};base64,${item.data}`,
962
- ...(promptCacheBreakpoint != null && {
963
- prompt_cache_breakpoint: promptCacheBreakpoint,
964
- }),
965
- };
966
- }
967
-
968
- case 'file-url': {
969
- return {
970
- type: 'input_file' as const,
971
- file_url: item.url,
972
- ...(promptCacheBreakpoint != null && {
973
- prompt_cache_breakpoint: promptCacheBreakpoint,
974
- }),
975
- };
976
- }
977
-
978
- default: {
979
- warnings.push({
980
- type: 'other',
981
- message: `unsupported tool content part type: ${item.type}`,
982
- });
983
- return undefined;
984
- }
985
- }
986
- })
987
- .filter(isNonNullable);
988
- break;
989
- }
1204
+ const contentValue = await convertFunctionToolResultOutput({
1205
+ output,
1206
+ providerOptionsName,
1207
+ warnings,
1208
+ });
990
1209
 
991
1210
  input.push({
992
1211
  type: 'function_call_output',
@@ -0,0 +1,142 @@
1
+ import {
2
+ isJSONObject,
3
+ type LanguageModelV3FunctionTool,
4
+ type LanguageModelV3ToolCall,
5
+ type SharedV3ProviderOptions,
6
+ } from '@ai-sdk/provider';
7
+ import { safeParseJSON } from '@ai-sdk/provider-utils';
8
+
9
+ const parallelToolName = 'parallel';
10
+ const recipientNamePrefix = 'functions.';
11
+
12
+ /**
13
+ * Preserves the original wrapper identity so child results can be sent back as
14
+ * one function output when Responses API server-side state is used.
15
+ */
16
+ export type ParallelToolCallMetadata = {
17
+ itemId: string;
18
+ toolCallId: string;
19
+ toolName: string;
20
+ input: string;
21
+ index: number;
22
+ count: number;
23
+ };
24
+
25
+ export function getParallelToolCallMetadata({
26
+ providerOptions,
27
+ providerOptionsName,
28
+ }: {
29
+ providerOptions: SharedV3ProviderOptions | undefined;
30
+ providerOptionsName: string;
31
+ }): ParallelToolCallMetadata | undefined {
32
+ const metadata = providerOptions?.[providerOptionsName]?.parallelToolCall;
33
+
34
+ if (
35
+ !isJSONObject(metadata) ||
36
+ typeof metadata.itemId !== 'string' ||
37
+ typeof metadata.toolCallId !== 'string' ||
38
+ typeof metadata.toolName !== 'string' ||
39
+ typeof metadata.input !== 'string' ||
40
+ typeof metadata.index !== 'number' ||
41
+ !Number.isInteger(metadata.index) ||
42
+ typeof metadata.count !== 'number' ||
43
+ !Number.isInteger(metadata.count) ||
44
+ metadata.index < 0 ||
45
+ metadata.count <= metadata.index
46
+ ) {
47
+ return undefined;
48
+ }
49
+
50
+ return metadata as ParallelToolCallMetadata;
51
+ }
52
+
53
+ export function isUndeclaredParallelToolCall({
54
+ toolName,
55
+ tools,
56
+ }: {
57
+ toolName: string;
58
+ tools: Array<LanguageModelV3FunctionTool>;
59
+ }): boolean {
60
+ return (
61
+ toolName === parallelToolName &&
62
+ !tools.some(tool => tool.name === parallelToolName)
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Expands the internal parallel tool wrapper that OpenAI models can emit as a
68
+ * regular function call. The wrapper is only recognized when every nested
69
+ * recipient is a declared client-side function tool.
70
+ */
71
+ export async function expandParallelToolCall({
72
+ toolCall,
73
+ tools,
74
+ providerOptionsName,
75
+ itemId,
76
+ }: {
77
+ toolCall: Pick<LanguageModelV3ToolCall, 'toolCallId' | 'toolName' | 'input'>;
78
+ tools: Array<LanguageModelV3FunctionTool>;
79
+ providerOptionsName: string;
80
+ itemId: string;
81
+ }): Promise<Array<LanguageModelV3ToolCall> | undefined> {
82
+ if (!isUndeclaredParallelToolCall({ toolName: toolCall.toolName, tools })) {
83
+ return undefined;
84
+ }
85
+
86
+ const parsedInput = await safeParseJSON({ text: toolCall.input });
87
+
88
+ if (!parsedInput.success || !isJSONObject(parsedInput.value)) {
89
+ return undefined;
90
+ }
91
+
92
+ const toolUses = parsedInput.value.tool_uses;
93
+ if (!Array.isArray(toolUses) || toolUses.length === 0) {
94
+ return undefined;
95
+ }
96
+
97
+ const availableToolNames = new Set(tools.map(tool => tool.name));
98
+ const expandedToolCalls: Array<LanguageModelV3ToolCall> = [];
99
+
100
+ for (const [index, toolUse] of toolUses.entries()) {
101
+ if (!isJSONObject(toolUse)) {
102
+ return undefined;
103
+ }
104
+
105
+ const recipientName = toolUse.recipient_name;
106
+ const parameters = toolUse.parameters;
107
+
108
+ if (
109
+ typeof recipientName !== 'string' ||
110
+ !recipientName.startsWith(recipientNamePrefix) ||
111
+ !isJSONObject(parameters)
112
+ ) {
113
+ return undefined;
114
+ }
115
+
116
+ const toolName = recipientName.slice(recipientNamePrefix.length);
117
+ if (toolName.length === 0 || !availableToolNames.has(toolName)) {
118
+ return undefined;
119
+ }
120
+
121
+ expandedToolCalls.push({
122
+ type: 'tool-call',
123
+ toolCallId: `${toolCall.toolCallId}_${index}`,
124
+ toolName,
125
+ input: JSON.stringify(parameters),
126
+ providerMetadata: {
127
+ [providerOptionsName]: {
128
+ parallelToolCall: {
129
+ itemId,
130
+ toolCallId: toolCall.toolCallId,
131
+ toolName: toolCall.toolName,
132
+ input: toolCall.input,
133
+ index,
134
+ count: toolUses.length,
135
+ } satisfies ParallelToolCallMetadata,
136
+ },
137
+ },
138
+ });
139
+ }
140
+
141
+ return expandedToolCalls;
142
+ }
@@ -337,6 +337,21 @@ export type OpenAIResponsesFunctionTool = {
337
337
  defer_loading?: boolean;
338
338
  };
339
339
 
340
+ /**
341
+ * Entry in `tool_choice.allowed_tools.tools`. OpenAI identifies most built-in
342
+ * tools by type alone; only `function`, `custom` and `mcp` carry an identifier.
343
+ */
344
+ export type OpenAIResponsesAllowedTool =
345
+ | { type: 'function'; name: string }
346
+ | { type: 'custom'; name: string }
347
+ | { type: 'mcp'; server_label: string }
348
+ | {
349
+ type: Exclude<
350
+ OpenAIResponsesTool['type'],
351
+ 'function' | 'custom' | 'mcp' | 'namespace' | 'tool_search'
352
+ >;
353
+ };
354
+
340
355
  export type OpenAIResponsesTool =
341
356
  | OpenAIResponsesFunctionTool
342
357
  | {