What "Function Calling" Meant Before "Tool Calling", and What Changed
10 min read · updated August 11, 2026
The 2023 rename from functions to tools looks like a field rename and is not one. Four parts of the exchange changed together, and updating the request alone produces a call that succeeds once and then fails on the message you send back.
Four shapes change, not one
In OpenAI’s Chat Completions API, the older interface declared callable functions in a top-level functions array and steered selection with a function_call parameter. The current interface declares them in tools and steers with tool_choice. That is the visible half. The other three changes are:
- The response field.
choices[0].message.function_call, a single object, becamechoices[0].message.tool_calls, an array. - The finish reason.
finish_reasonreportstool_callswhere it once reportedfunction_call. - The reply message. The result you send back was a message with
role: "function"and aname. It is now a message withrole: "tool"and atool_call_id.
A codebase that changes the first and forgets the fourth will make a request that works, receive tool calls it parses correctly, and then get a 400 on the follow-up. The error text is worth memorising because it is the single most common landing point for a half-finished migration: Invalid parameter: messages with role 'tool' must be a response to a preceeding message with 'tool_calls'. The misspelling of “preceding” is in the real message, so searching for the correct spelling finds less than searching for what you were actually sent.
The same call, before and after
One weather function, declared and answered, in both shapes. The old form:
# old
resp = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "Weather in Utrecht?"}],
functions=[{
"name": "get_weather",
"description": "Current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}],
function_call="auto",
)
call = resp.choices[0].message.function_call # one object, or None
args = json.loads(call.arguments) # arguments is a JSON *string*
messages.append({"role": "assistant", "function_call": {...}})
messages.append({"role": "function", "name": call.name, "content": result})The current form:
# current
resp = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "Weather in Utrecht?"}],
tools=[{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
},
},
}],
tool_choice="auto",
)
msg = resp.choices[0].message
messages.append(msg) # keep it verbatim, ids and all
for call in msg.tool_calls or []: # an array, possibly many
args = json.loads(call.function.arguments) # still a JSON string
messages.append({
"role": "tool",
"tool_call_id": call.id, # the new mandatory link
"content": json.dumps(run(call.function.name, args)),
})Three details in that diff are easy to miss. The function declaration is now nested one level deeper under a function key with a sibling type. The arguments are still a JSON string, not an object, in both shapes — that never changed and it is still the source of most parse failures. And the assistant message carrying tool calls has content of null, which breaks any logging or history code that assumes every assistant message has text.
tool_choice also gained a value with no predecessor. The old parameter accepted "none", "auto" or a named function object. The new one adds "required", meaning the model must call some tool but may pick which. If you were emulating that by forcing a named function and then discarding the choice, you can delete that code.
The call id is the new hard part
The old shape had no identifier. There was one call in flight, the reply followed it, and the pairing was positional. The new shape allows several calls in one assistant turn, so each carries an id — a string like call_abc123 — and every reply must name exactly one of them in tool_call_id. Both directions are enforced: a tool message without a preceding assistant message bearing that id fails with the error above, and an assistant message whose tool calls are not all answered fails on the next request.
This has a consequence nobody plans for. Stored transcripts in the old format have no ids to migrate. If you persist conversation history and replay it — for evaluation, for a support tool, for resuming a session — every old turn needs a synthesised id, and it must be synthesised identically on the assistant message and its reply. The rewrite is small but it has to be a real data migration, not a read-time patch, or the two halves diverge:
def upgrade_turn(old_assistant, old_function_reply, i):
cid = f"call_legacy_{i}"
return (
{"role": "assistant", "content": None, "tool_calls": [{
"id": cid, "type": "function",
"function": {
"name": old_assistant["function_call"]["name"],
"arguments": old_assistant["function_call"]["arguments"],
},
}]},
{"role": "tool", "tool_call_id": cid,
"content": old_function_reply["content"]},
)The other consequence is parallelism. The array can hold more than one call, and by default the model may emit several at once. If your executor assumes one, it silently drops the rest — and then the next request fails because those calls were never answered. Setting parallel_tool_calls to false restores one-at-a-time behaviour, which is the correct short-term move if your tools have side effects that are not safe to interleave. The library covers the shape in the parallel tool call format.
Streaming deltas accumulate differently
Under the old shape a stream delivered delta.function_call with the name arriving once and arguments arriving as a sequence of string fragments you concatenated. Under the new shape the delta carries delta.tool_calls, an array whose entries have an index field, and the fragments must be accumulated per index because two calls can stream interleaved:
acc = {}
for chunk in stream:
for tc in chunk.choices[0].delta.tool_calls or []:
slot = acc.setdefault(tc.index, {"id": None, "name": "", "args": ""})
if tc.id: slot["id"] = tc.id
if tc.function.name: slot["name"] += tc.function.name
if tc.function.arguments: slot["args"] += tc.function.argumentsConcatenating without keying on index produces a single malformed JSON string that is the arguments of two calls spliced together — which fails in json.loads at a character offset that tells you nothing about the cause. The general treatment of chunk shapes is in the streaming chunk format page.
The Responses API is a third shape
If your migration is also moving from Chat Completions to OpenAI’s Responses API, the tool shape changes again and it is not the shape you just built. Tools there are declared flat — a type of "function" with name and parameters as siblings, with no nested function object. Calls come back as output items of type function_call carrying a call_id, and results go back as items of type function_call_output naming that call_id. So the field is call_id, not tool_call_id, and the container is an output item list, not a message with a tool_calls property.
Do both moves in one step and you will be debugging two unfamiliar shapes at once. Do the functions-to-tools rename first, get it green, then move APIs.
Finishing the migration
- Grep for the four old identifiers together:
functions=,function_call,role.*functionandfinish_reason.*function_call. Any file matching one and not the others is a partial migration. - Change the request, the response parse, and the reply message in the same commit. They are one shape and splitting them across commits guarantees a broken intermediate state.
- Add a test that exercises two tool calls in one assistant turn. A single-call test passes on code that drops the second.
- Migrate stored transcripts with synthesised ids, in a script, once — not at read time.
- Set
parallel_tool_callsto false if your tools are not safe to interleave, and record why, so the next person does not remove it.
If what you actually have in front of you is the deprecation warning rather than a planned migration, the minimal fix for that warning is the shorter path. See also OpenAI’s deprecations page, which is where the removal dates for the old parameters are published.