Fleeexdocs

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

tool-loop.ts
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", and choices[0].message.tool_calls[] = { id, type: "function", function: { name, arguments } }.
  • arguments is a JSON string, not an object. That's OpenAI's wire form, so JSON.parse it.
  • content is null when 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_callsOnly on an assistant message.
tool_call_idRequired on a tool message, forbidden everywhere else.
contentRequired, except on an assistant message that carries tool_calls.
Every tool_call_idMust 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, type and the function name, with arguments: "";
  • 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, description and the serialized bytes of its parameters schema.
  • The tool name in a { type: 'function' } tool_choice.
  • Each tool message's content and tool_call_id, and every replayed tool_calls entry's id, name and serialized arguments.

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
toolsat most 128
function.nameat most 64 characters, on OpenAI's own [A-Za-z0-9_-] grammar
function.descriptionat most 4,096 characters
function.parametersa JSON object, at most 16,384 serialized characters, at most 10 levels deep
tool_callsat most 32 per message; each arguments at most 32,768 characters and must parse to an object
tool_call_idat 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.