Skip to content

Commit 58bc42d

Browse files
shaperdancer
andauthored
feat (provider/openai): support custom tools (#12948)
## background adds support for openai custom tools in responses and fixes alias mapping failures that could cause runtime errors refs https://developers.openai.com/api/reference/resources/responses/methods/create/ ## summary - add custom tool support for responses with grammar formats - resolve aliased custom tool names end to end across tool choice, parsing, and streaming - map provider tool names back to sdk tool keys so tool calls return the user-facing key - support custom tool output content mapping instead of dropping to empty output - add repro examples for forced and unforced alias flows ## before this fix - forced alias tool choice could fail with api call errors because custom tools were resolved as function tool choice - unforced alias calls could fail with no such tool errors when provider name and sdk key differed ## after this fix - forced alias tool choice resolves to `{ type: 'custom', name: 'write_sql' }` and executes correctly - returned tool call and tool result names stay as sdk key `alias_name` - unforced runs no longer fail due to alias mismatch and may validly return no tool calls when model answers directly ## repro <details> <summary>repro-alias-forced</summary> ### before fix (`368bbdd468`) ```bash git checkout 368bbdd cd examples/ai-functions pnpm tsx src/generate-text/openai/repro-alias-forced.ts ``` result - `AI_APICallError: Tool choice 'function' not found in 'tools' parameter` - request body includes `tool_choice: { type: 'function', name: 'alias_name' }` ### after fix (`a3565b08c2`) ```bash git checkout a3565b0 cd examples/ai-functions pnpm tsx src/generate-text/openai/repro-alias-forced.ts ``` result - succeeds with tool execution - tool call and tool result use sdk key `alias_name` - `Steps: 2` </details> <details> <summary>repro-alias-unforced</summary> ### before fix (`368bbdd468`) ```bash git checkout 368bbdd cd examples/ai-functions pnpm tsx src/generate-text/openai/repro-alias-unforced.ts ``` observed behavior (multiple runs) - run 1: direct text response, `toolCalls: []` - run 2: `AI_NoSuchToolError` when model emits `write_sql` but available tool key is `alias_name` - run 3: direct text response, `toolCalls: []` ### after fix (`a3565b08c2`) ```bash git checkout a3565b0 cd examples/ai-functions pnpm tsx src/generate-text/openai/repro-alias-unforced.ts ``` observed behavior (multiple runs) - no alias mismatch errors - direct text responses with `toolCalls: []`, `toolResults: []`, `Steps: 2` </details> ## verification - `pnpm tsx src/generate-text/openai/responses-custom-tool.ts` - `pnpm tsx src/stream-text/openai/responses-custom-tool.ts` - `pnpm tsx src/generate-text/openai/responses-custom-tool-multi-turn.ts` - `pnpm tsx src/stream-text/openai/responses-custom-tool-multi-turn.ts` ## checklist - [x] tests have been added / updated (for bug fixes / features) - [x] documentation has been added / updated (for bug fixes / features) - [x] a _patch_ changeset for relevant packages has been added (run `pnpm changeset` in root) - [x] i have reviewed this pull request (self-review) ## related issues fixes #12614 --------- Co-authored-by: dancer <josh@afterima.ge>
1 parent 946ca0e commit 58bc42d

21 files changed

+1536
-22
lines changed

.changeset/rude-mails-love.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ai-sdk/openai': patch
3+
'@ai-sdk/provider-utils': patch
4+
---
5+
6+
feat(provider/openai): support custom tools with alias mapping

content/providers/01-ai-sdk-providers/03-openai.mdx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,84 @@ Your execute function must return:
958958
- **status** _'completed' | 'failed'_ - Whether the patch was applied successfully
959959
- **output** _string_ (optional) - Human-readable log text (e.g., results or error messages)
960960

961+
#### Custom Tool
962+
963+
The OpenAI Responses API supports
964+
[custom tools](https://developers.openai.com/api/docs/guides/function-calling/#custom-tools)
965+
through the `openai.tools.customTool` tool.
966+
Custom tools return a raw string instead of JSON, optionally constrained to a grammar
967+
(regex or Lark syntax). This makes them useful for generating structured text like
968+
SQL queries, code snippets, or any output that must match a specific pattern.
969+
970+
```ts
971+
import { openai } from '@ai-sdk/openai';
972+
import { generateText, stepCountIs } from 'ai';
973+
974+
const result = await generateText({
975+
model: openai.responses('gpt-5.2-codex'),
976+
tools: {
977+
write_sql: openai.tools.customTool({
978+
name: 'write_sql',
979+
description: 'Write a SQL SELECT query to answer the user question.',
980+
format: {
981+
type: 'grammar',
982+
syntax: 'regex',
983+
definition: 'SELECT .+',
984+
},
985+
execute: async input => {
986+
// input is a raw string matching the grammar, e.g. "SELECT * FROM users WHERE age > 25"
987+
const rows = await db.query(input);
988+
return JSON.stringify(rows);
989+
},
990+
}),
991+
},
992+
toolChoice: 'required',
993+
prompt: 'Write a SQL query to get all users older than 25.',
994+
stopWhen: stepCountIs(3),
995+
});
996+
```
997+
998+
Custom tools also work with `streamText`:
999+
1000+
```ts
1001+
import { openai } from '@ai-sdk/openai';
1002+
import { streamText } from 'ai';
1003+
1004+
const result = streamText({
1005+
model: openai.responses('gpt-5.2-codex'),
1006+
tools: {
1007+
write_sql: openai.tools.customTool({
1008+
name: 'write_sql',
1009+
description: 'Write a SQL SELECT query to answer the user question.',
1010+
format: {
1011+
type: 'grammar',
1012+
syntax: 'regex',
1013+
definition: 'SELECT .+',
1014+
},
1015+
}),
1016+
},
1017+
toolChoice: 'required',
1018+
prompt: 'Write a SQL query to get all users older than 25.',
1019+
});
1020+
1021+
for await (const chunk of result.fullStream) {
1022+
if (chunk.type === 'tool-call') {
1023+
console.log(`Tool: ${chunk.toolName}`);
1024+
console.log(`Input: ${chunk.input}`);
1025+
}
1026+
}
1027+
```
1028+
1029+
The custom tool can be configured with:
1030+
1031+
- **name** _string_ (required) - The name of the custom tool. Used to identify the tool in tool calls.
1032+
- **description** _string_ (optional) - A description of what the tool does, to help the model understand when to use it.
1033+
- **format** _object_ (optional) - The output format constraint. Omit for unconstrained text output.
1034+
- **type** _'grammar' | 'text'_ - The format type. Use `'grammar'` for constrained output or `'text'` for explicit unconstrained text.
1035+
- **syntax** _'regex' | 'lark'_ - (grammar only) The grammar syntax. Use `'regex'` for regular expression patterns or `'lark'` for [Lark parser grammar](https://lark-parser.readthedocs.io/).
1036+
- **definition** _string_ - (grammar only) The grammar definition string (a regex pattern or Lark grammar).
1037+
- **execute** _function_ (optional) - An async function that receives the raw string input and returns a string result. Enables multi-turn tool calling.
1038+
9611039
#### Image Inputs
9621040

9631041
The OpenAI Responses API supports Image inputs for appropriate models.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { generateText, stepCountIs } from 'ai';
2+
import { openai } from '@ai-sdk/openai';
3+
import { run } from '../../lib/run';
4+
5+
run(async () => {
6+
const result = await generateText({
7+
model: openai.responses('gpt-5.2-codex'),
8+
tools: {
9+
alias_name: openai.tools.customTool({
10+
name: 'write_sql',
11+
format: { type: 'grammar', syntax: 'regex', definition: 'SELECT .+' },
12+
execute: async input => 'ok:' + input,
13+
}),
14+
},
15+
toolChoice: { type: 'tool', toolName: 'alias_name' },
16+
prompt: 'write sql select 1',
17+
stopWhen: stepCountIs(2),
18+
});
19+
20+
const stepOneRequestBody = result.steps[0]?.request?.body as
21+
| { tool_choice?: unknown }
22+
| undefined;
23+
24+
console.log('Step 1 tool_choice:', stepOneRequestBody?.tool_choice);
25+
console.log('Tool calls:', result.toolCalls);
26+
console.log('Tool results:', result.toolResults);
27+
console.log('Steps:', result.steps.length);
28+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { generateText, stepCountIs } from 'ai';
2+
import { openai } from '@ai-sdk/openai';
3+
import { run } from '../../lib/run';
4+
5+
run(async () => {
6+
const result = await generateText({
7+
model: openai.responses('gpt-5.2-codex'),
8+
tools: {
9+
alias_name: openai.tools.customTool({
10+
name: 'write_sql',
11+
format: { type: 'grammar', syntax: 'regex', definition: 'SELECT .+' },
12+
execute: async input => 'ok:' + input,
13+
}),
14+
},
15+
prompt: 'write sql select users older than 25',
16+
stopWhen: stepCountIs(5),
17+
});
18+
19+
const stepOneRequestBody = result.steps[0]?.request?.body as
20+
| { tool_choice?: unknown }
21+
| undefined;
22+
const allToolCalls = result.steps.flatMap(step => step.toolCalls);
23+
const allToolResults = result.steps.flatMap(step => step.toolResults);
24+
25+
console.log('Step 1 tool_choice:', stepOneRequestBody?.tool_choice);
26+
console.log('Text:', result.text);
27+
console.log('Tool calls (final step):', result.toolCalls);
28+
console.log('Tool results (final step):', result.toolResults);
29+
console.log('Tool calls (all steps):', allToolCalls);
30+
console.log('Tool results (all steps):', allToolResults);
31+
console.log('Steps:', result.steps.length);
32+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { openai } from '@ai-sdk/openai';
2+
import { generateText, stepCountIs } from 'ai';
3+
import { run } from '../../lib/run';
4+
import { print } from '../../lib/print';
5+
6+
run(async () => {
7+
const result = await generateText({
8+
model: openai.responses('gpt-5.2-codex'),
9+
tools: {
10+
write_sql: openai.tools.customTool({
11+
name: 'write_sql',
12+
description: 'Write a SQL SELECT query to answer the user question.',
13+
format: {
14+
type: 'grammar',
15+
syntax: 'regex',
16+
definition: 'SELECT .+',
17+
},
18+
execute: async input => {
19+
console.log(`Executing SQL: ${input}`);
20+
return `3 rows returned: Alice (30), Bob (28), Charlie (35)`;
21+
},
22+
}),
23+
},
24+
prompt: 'How many users are older than 25? Use SQL to find out.',
25+
stopWhen: stepCountIs(3),
26+
});
27+
28+
const allToolCalls = result.steps.flatMap(step => step.toolCalls);
29+
const allToolResults = result.steps.flatMap(step => step.toolResults);
30+
31+
print('Text:', result.text);
32+
print('Steps:', result.steps.length);
33+
print('Tool calls (final step):', result.toolCalls);
34+
print('Tool results (final step):', result.toolResults);
35+
print('Tool calls (all steps):', allToolCalls);
36+
print('Tool results (all steps):', allToolResults);
37+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { openai } from '@ai-sdk/openai';
2+
import { generateText } from 'ai';
3+
import { run } from '../../lib/run';
4+
import { print } from '../../lib/print';
5+
6+
run(async () => {
7+
const result = await generateText({
8+
model: openai.responses('gpt-5.2-codex'),
9+
tools: {
10+
write_sql: openai.tools.customTool({
11+
name: 'write_sql',
12+
description: 'Write a SQL SELECT query to answer the user question.',
13+
format: {
14+
type: 'grammar',
15+
syntax: 'regex',
16+
definition: 'SELECT .+',
17+
},
18+
}),
19+
},
20+
toolChoice: 'required',
21+
prompt: 'Write a SQL query to get all users older than 25.',
22+
});
23+
24+
print('Tool calls:', result.toolCalls);
25+
print('Finish reason:', result.finishReason);
26+
print('Usage:', result.usage);
27+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { openai } from '@ai-sdk/openai';
2+
import { streamText, stepCountIs } from 'ai';
3+
import { run } from '../../lib/run';
4+
5+
run(async () => {
6+
const result = streamText({
7+
model: openai.responses('gpt-5.2-codex'),
8+
tools: {
9+
write_sql: openai.tools.customTool({
10+
name: 'write_sql',
11+
description: 'Write a SQL SELECT query to answer the user question.',
12+
format: {
13+
type: 'grammar',
14+
syntax: 'regex',
15+
definition: 'SELECT .+',
16+
},
17+
execute: async input => {
18+
console.log(`Executing SQL: ${input}`);
19+
return `3 rows returned: Alice (30), Bob (28), Charlie (35)`;
20+
},
21+
}),
22+
},
23+
prompt: 'How many users are older than 25? Use SQL to find out.',
24+
stopWhen: stepCountIs(3),
25+
});
26+
27+
for await (const chunk of result.fullStream) {
28+
switch (chunk.type) {
29+
case 'tool-call': {
30+
console.log(`Tool call: ${chunk.toolName}`);
31+
console.log(` Input: ${JSON.stringify(chunk.input)}`);
32+
break;
33+
}
34+
case 'tool-result': {
35+
console.log(`Tool result: ${JSON.stringify(chunk.output)}`);
36+
break;
37+
}
38+
case 'text-delta': {
39+
process.stdout.write(chunk.text);
40+
break;
41+
}
42+
}
43+
}
44+
45+
console.log();
46+
console.log('Finish reason:', await result.finishReason);
47+
console.log('Steps:', (await result.steps).length);
48+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { openai } from '@ai-sdk/openai';
2+
import { streamText } from 'ai';
3+
import { run } from '../../lib/run';
4+
5+
run(async () => {
6+
const result = streamText({
7+
model: openai.responses('gpt-5.2-codex'),
8+
tools: {
9+
write_sql: openai.tools.customTool({
10+
name: 'write_sql',
11+
description: 'Write a SQL SELECT query to answer the user question.',
12+
format: {
13+
type: 'grammar',
14+
syntax: 'regex',
15+
definition: 'SELECT .+',
16+
},
17+
}),
18+
},
19+
toolChoice: 'required',
20+
prompt: 'Write a SQL query to get all users older than 25.',
21+
});
22+
23+
for await (const chunk of result.fullStream) {
24+
switch (chunk.type) {
25+
case 'tool-call': {
26+
console.log(
27+
`\x1b[32m\x1b[1mTool call:\x1b[22m ${chunk.toolName}\x1b[0m`,
28+
);
29+
console.log(` Input: ${JSON.stringify(chunk.input)}`);
30+
break;
31+
}
32+
33+
case 'error':
34+
console.error('Error:', chunk.error);
35+
break;
36+
}
37+
}
38+
39+
console.log();
40+
console.log('Finish reason:', await result.finishReason);
41+
console.log('Usage:', await result.usage);
42+
});

packages/openai/src/openai-tools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { applyPatch } from './tool/apply-patch';
22
import { codeInterpreter } from './tool/code-interpreter';
3+
import { customTool } from './tool/custom';
34
import { fileSearch } from './tool/file-search';
45
import { imageGeneration } from './tool/image-generation';
56
import { localShell } from './tool/local-shell';
@@ -18,6 +19,17 @@ export const openaiTools = {
1819
*/
1920
applyPatch,
2021

22+
/**
23+
* Custom tools let callers constrain model output to a grammar (regex or
24+
* Lark syntax). The model returns a `custom_tool_call` output item whose
25+
* `input` field is a string matching the specified grammar.
26+
*
27+
* @param name - The name of the custom tool.
28+
* @param description - An optional description of the tool.
29+
* @param format - The output format constraint (grammar type, syntax, and definition).
30+
*/
31+
customTool,
32+
2133
/**
2234
* The Code Interpreter tool allows models to write and run Python code in a
2335
* sandboxed environment to solve complex problems in domains like data analysis,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{"type":"response.created","response":{"id":"resp_custom_tool_test_001","object":"response","created_at":1741257730,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.2-codex","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":"low","summary":null},"store":true,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"required","tools":[{"type":"custom","name":"write_sql","description":"Write a SQL SELECT query to answer the user question.","format":{"type":"grammar","syntax":"regex","definition":"SELECT .+"}}],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}}}
2+
{"type":"response.in_progress","response":{"id":"resp_custom_tool_test_001","object":"response","created_at":1741257730,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.2-codex","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":"low","summary":null},"store":true,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"required","tools":[{"type":"custom","name":"write_sql","description":"Write a SQL SELECT query to answer the user question.","format":{"type":"grammar","syntax":"regex","definition":"SELECT .+"}}],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}}}
3+
{"type":"response.output_item.added","output_index":0,"item":{"type":"custom_tool_call","id":"ct_abc123def456","call_id":"call_custom_sql_001","name":"write_sql","input":""}}
4+
{"type":"response.custom_tool_call_input.delta","item_id":"ct_abc123def456","output_index":0,"delta":"SELECT * "}
5+
{"type":"response.custom_tool_call_input.delta","item_id":"ct_abc123def456","output_index":0,"delta":"FROM users "}
6+
{"type":"response.custom_tool_call_input.delta","item_id":"ct_abc123def456","output_index":0,"delta":"WHERE age > 25"}
7+
{"type":"response.output_item.done","output_index":0,"item":{"type":"custom_tool_call","id":"ct_abc123def456","call_id":"call_custom_sql_001","name":"write_sql","input":"SELECT * FROM users WHERE age > 25","status":"completed"}}
8+
{"type":"response.completed","response":{"id":"resp_custom_tool_test_001","object":"response","created_at":1741257730,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.2-codex","output":[{"type":"custom_tool_call","id":"ct_abc123def456","call_id":"call_custom_sql_001","name":"write_sql","input":"SELECT * FROM users WHERE age > 25","status":"completed"}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":"low","summary":null},"store":true,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"required","tools":[{"type":"custom","name":"write_sql","description":"Write a SQL SELECT query to answer the user question.","format":{"type":"grammar","syntax":"regex","definition":"SELECT .+"}}],"top_p":1,"truncation":"disabled","usage":{"input_tokens":50,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":70},"user":null,"metadata":{}}}

0 commit comments

Comments
 (0)