Function calling
The full tool round trip (request, tool_calls, the tool-result turn, the final answer) plus how tools are billed.
tools and tool_choice are honored, and so is the whole loop: a model that can ask for
a tool call, and a caller that can send the result back. Half a loop would be an endpoint
you could only use once.
The round trip
import type OpenAI from "openai";
const tools: OpenAI.ChatCompletionTool[] = [
{
type: "function",
function: {
name: "get_weather",
description: "Current weather for a city.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
},
];
const messages: OpenAI.ChatCompletionMessageParam[] = [
{ role: "user", content: "What's the weather in Lyon?" },
];
// 1. Ask, offering the tool.
const first = await client.chat.completions.create({
model: "nova-lite",
messages,
tools,
});
const message = first.choices[0]!.message;
if (first.choices[0]!.finish_reason === "tool_calls") {
// 2. Replay the assistant turn verbatim: it carries the tool_calls.
messages.push(message);
for (const call of message.tool_calls ?? []) {
// `arguments` is a JSON *string*, as on OpenAI's wire.
const args = JSON.parse(call.function.arguments) as { city: string };
const result = await getWeather(args.city);
// 3. One tool message per call, echoing its id.
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(result),
});
}
// 4. Ask again, now with the results in the conversation.
const second = await client.chat.completions.create({
model: "nova-lite",
messages,
tools,
});
console.log(second.choices[0]?.message.content);
}What comes back
finish_reason: "tool_calls", andchoices[0].message.tool_calls[] = { id, type: "function", function: { name, arguments } }.argumentsis a JSON string, not an object. That's OpenAI's wire form, soJSON.parseit.contentisnullwhen the model produced no text alongside the call. That is what OpenAI returns, and what you replay back verbatim.
What you send back
The assistant turn that requested the call is replayed with its tool_calls, and each
result is a tool message. Both are validated, and a malformed conversation is a 400
rather than a 502 from the provider:
| Rule | |
|---|---|
tool_calls | Only on an assistant message. |
tool_call_id | Required on a tool message, forbidden everywhere else. |
content | Required, except on an assistant message that carries tool_calls. |
Every tool_call_id | Must match an id an earlier assistant message declared. |
A tool declared without parameters is forwarded as the schema for "takes no arguments",
which is what was asked.
tool_choice
| Value | |
|---|---|
'auto' | The model decides. |
'required' | The model must call a tool. |
{ type: 'function', function: { name } } | That tool. |
'none' | A 400. There is no way to expose the tools while forbidding their use, and both approximations are undetectable: dropping the tools changes the answer and the input tokens billed, ignoring the field lets the model call a tool you forbade. Send the request without tools instead. |
tool_choice without tools is a 400 too.
Streaming: arguments arrive in fragments
With stream: true, a tool call is delivered as delta.tool_calls fragments, in
OpenAI's own shape:
- the first fragment of a call carries
index,id,typeand the function name, witharguments: ""; - every later fragment carries only a piece of the argument JSON, under the same
index.
Concatenating the pieces of one index yields exactly the string the unary body would
have carried. So accumulate per index and parse once at the end, because a fragment on
its own is not valid JSON.
const pending = new Map<number, { name: string; args: string }>();
for await (const chunk of stream) {
for (const fragment of chunk.choices[0]?.delta?.tool_calls ?? []) {
const entry = pending.get(fragment.index) ?? { name: "", args: "" };
if (fragment.function?.name) entry.name = fragment.function.name;
entry.args += fragment.function?.arguments ?? "";
pending.set(fragment.index, entry);
}
}
for (const [, { name, args }] of pending) {
console.log(name, JSON.parse(args)); // parse only once it's complete
}Tools cost input tokens
This is the part integrators are surprised by: tool schemas and tool results are prompt content. They are counted in the pessimistic estimate that sizes the wallet reservation, and they are in the tokens you are billed for.
- Every declared tool's
name,descriptionand the serialized bytes of itsparametersschema. - The tool name in a
{ type: 'function' }tool_choice. - Each
toolmessage's content andtool_call_id, and every replayedtool_callsentry'sid,nameand serializedarguments.
A 16 KB schema is thousands of input tokens on every call of the loop, and a two-step
round trip pays for the schema twice. So an over-large schema shows up as a 402 on a
wallet that would have covered the conversation, before it shows up on an invoice. Trim
descriptions, and don't send tools the model can't use in this turn.
Tool-call arguments are generated output and are billed as such, so a tool-only answer is not a free one.
Bounds
All of these are a 400. See the full bounds table.
| Bound | |
|---|---|
tools | at most 128 |
function.name | at most 64 characters, on OpenAI's own [A-Za-z0-9_-] grammar |
function.description | at most 4,096 characters |
function.parameters | a JSON object, at most 16,384 serialized characters, at most 10 levels deep |
tool_calls | at most 32 per message; each arguments at most 32,768 characters and must parse to an object |
tool_call_id | at most 128 characters |
function.strict, and any other undeclared sub-field of a tool, is a 400 naming it.
What is stored
Nothing about the content. Tool calls are output and tool results are input; the usage event records token counts, the model and the cost, never a message, a tool call or a tool result.