The warning is telling you that the single-function parameter has been superseded by the array-based one. The first useful question is not how to rewrite it but which layer of your stack is complaining, because the answer decides whether the fix is in your code at all.
Who is actually emitting this
The HTTP API does not return that sentence in a response body. A request using the old parameters is accepted and answered; the deprecation is documented rather than enforced at the wire. So the warning is coming from software running on your machine, and there are three candidates:
- Your provider SDK. The Python and Node clients mark superseded parameters and fields, and the exact wording differs between them and between versions.
- A framework wrapping the SDK. Agent and chain libraries commonly carry their own deprecation shims with their own text, and they warn about their abstraction rather than the provider’s.
- Your own code. Someone added a shim months ago to catch exactly this and everyone forgot.
Find the emitter before editing anything. In Python, turn the warning into an exception so you get a stack trace pointing at the call site:
python -W error::DeprecationWarning your_script.py
# or, scoped, inside the program:
import warnings
warnings.simplefilter("error", DeprecationWarning)
In Node, run with --trace-deprecation, which prints the stack for each deprecation instead of the bare message. In both cases the frame you want is the topmost one inside your own package — that is the line to change. If every frame is inside a third-party library, the fix is a version bump of that library, not a rewrite of your call.
There is a fourth possibility worth ruling out early, because it sends people down the wrong path for hours: the warning may be about a field you are reading rather than a parameter you are sending. Code that inspects message.function_call on a response touches the deprecated field just as much as code that sets it, and some clients warn on attribute access. If your request already uses tools and the warning persists, look at the response-handling side. That asymmetry — request migrated, response parse not — is also the most common reason a tool-calling loop stops firing without any error at all.
The minimal rewrite
If it is your call site, the change is mechanical and small. Three things move on the request and two on the way back:
- functions=[{"name": "lookup", "description": d, "parameters": p}],
- function_call="auto",
+ tools=[{"type": "function",
+ "function": {"name": "lookup", "description": d, "parameters": p}}],
+ tool_choice="auto",
- call = resp.choices[0].message.function_call
- if call:
- result = run(call.name, json.loads(call.arguments))
- messages.append({"role": "function", "name": call.name,
- "content": result})
+ msg = resp.choices[0].message
+ messages.append(msg)
+ for call in msg.tool_calls or []:
+ result = run(call.function.name, json.loads(call.function.arguments))
+ messages.append({"role": "tool", "tool_call_id": call.id,
+ "content": result})
Note what the diff does not change. The JSON Schema in parameters is byte-identical; it simply moved one level deeper. The arguments value is still a JSON string that you parse yourself. And tool_choice takes the same three values the old parameter did, plus "required".
The one structural change is that tool_calls is a list. The for loop above is not stylistic: a single-call assumption is the defect that survives this rewrite most often, because the model usually emits one call and the bug only shows up on the request where it emits two.
Four errors from a half-finished rewrite
These are the errors that appear after the request has been updated and something else has not. Each one identifies precisely which half is stale.
“Invalid parameter: messages with role 'tool' must be a response to a preceeding message with 'tool_calls'.” — you are sending a tool reply, but the assistant message before it in your history does not carry the matching tool_calls array. Two usual causes: you appended a reconstructed assistant message with only the text content instead of appending the message object the API returned, or your history store trimmed the assistant turn and left the reply. Append the returned message verbatim, and treat the assistant-plus-replies group as one indivisible unit when trimming history. The misspelling in that message is real and is a useful search term.
“An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.” — the mirror image. The model requested several calls and you answered one. Either answer all of them, or set parallel_tool_calls to false so the situation cannot arise.
A tool result that the model ignores entirely. No error, just an answer that behaves as though the tool never ran. Check the role: a message with role: "function" alongside tools is a shape mismatch that some stacks pass through silently.
A branch on finish_reason == "function_call" that never fires. — the value is now "tool_calls". This is the quietest of the four because nothing raises; the code simply takes the wrong path and returns the assistant’s empty content as an answer. Grep for the string literal, not for the field.
A fifth, adjacent failure often arrives in the same upgrade and is worth recognising because it looks unrelated: Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead. That is a different rename on the same request object, and it hits when the tool migration is done at the same time as a model change.
A useful habit when any of these appear: print the last three messages you were about to send, not the exception. All four failures above are statements about the shape of the message list, and the list is the only artefact that shows which half of the rewrite is stale. A logging line that dumps the role, the presence of tool_calls and the tool_call_id for each of the final messages resolves every one of them in a single run, whereas the traceback points only at the request call, which is the same line in all four cases.
When you cannot fix it yet
Sometimes the emitter is a dependency you cannot upgrade today. It is reasonable to silence the warning for a release, provided you silence exactly it and leave a trail. In Python, filter on the message and the module rather than on the category:
import warnings
warnings.filterwarnings(
"ignore",
message=r".*function_call.*deprecated.*",
category=DeprecationWarning,
module=r"some_framework\..*",
) # TODO(ticket-482): remove after some_framework >= 2.4
A blanket simplefilter("ignore") is the wrong move, because the next deprecation on the same request object — and there will be one — then arrives with no warning at all, and you discover it as a removal instead. Set a date, or a version constraint, and make the suppression fail loudly when the dependency catches up.
The full shape change, including streaming accumulation and migrating stored transcripts that have no call ids, is in the function-to-tool calling migration. If several services in the same codebase are affected, the hardcoded-assumption audit finds them in one pass.
Top comments (0)