Error code: 400 - {'error': {'message': "Invalid parameter: 'tool_calls' cannot be used when 'functions' are present
This is request validation, rejected before a model ran. Your key is fine, your quota is fine — billing and throttling problems come back as 429, and model overload as 503 — and no tokens were generated or billed. The server read the payload, found two mutually exclusive descriptions of how tool calling should work in the same request, and refused to guess which one you meant. The useful part is not what the message says; it is that the two conflicting fields almost never come from the same piece of your code.
One conversation cannot be encoded two ways at once
Chat Completions has carried two generations of the same feature. The older pair
is functions with function_call. The current pair is tools with
tool_choice, where the assistant’s reply carries tool_calls and each result
comes back in its own message referencing the call it answers.
These are not two spellings of one thing. They are two wire formats, and they
determine both how the model’s next reply is encoded and how the turns already in
your messages array are interpreted. A request that declares functions is
asking for a reply in the older shape; a message history containing tool_calls
is asserting that earlier turns are in the newer shape. Honoring both would mean
reading history under one contract and answering under another, on a request
where nothing states which turns belong to which.
So the API does the thing that costs you two minutes instead of a week: it rejects the request rather than picking a winner silently. The rule is one tool protocol per request, and the history counts as part of the request.
The field you did not write is the one breaking it
The cheapest way to burn an hour here is to search your own code for functions,
find nothing, and conclude the error is wrong. It usually is not, because the two
halves of the conflict live in different layers:
tool_callscomes from the conversation you replayed. It is on an assistant message you appended after a previous response, or loaded from a database, or restored from a session file. You did not type it — you stored it.functionscomes from whatever assembles the request. A framework, an agent library, an internal wrapper, a compatibility gateway sitting behind yourbase_url, or a code path someone wrote before the migration and that still runs for one model.
Because the conflict is split between “state you saved” and “parameters something
adds”, no single file contains both. Stop reading code and read bytes. Capture
the serialized request body at the transport layer — the SDK’s raw-response and
logging facilities, or the proxy in front of it — and look at the top-level keys.
Whichever of functions and tools you did not expect to see names the layer you
need to go fix.
A gateway is the case people miss most often. A shim that accepts modern requests
and forwards them to a backend that only speaks the older shape has to translate,
and a translation that adds functions while passing your messages through
untouched produces exactly this rejection. If your base_url points anywhere
other than the official endpoint, that is the first suspect.
Is retrying useful?
No. This is a statement about the bytes you sent, and identical bytes are rejected identically, forever.
There is a mechanical confirmation available to you. The official Python and TypeScript SDKs retry automatically twice by default, with a short exponential backoff, and the list of what they retry is specific: connection errors, 408, 409, 429, and 5xx. 400 is not on that list. Nothing retried this for you, which is why it came back instantly — and an error with no delay in front of it is, by itself, evidence that you are not looking at a throttling or capacity problem.
The same is true on a streaming call. HTTP-level error responses apply before the stream starts, so this one arrives as a normal HTTP error and never reaches your event loop. There is no partial output to reconcile and nothing to resume.
Do capture one thing before moving on: failed responses carry an x-request-id
header, exposed by catching the SDK’s status error and reading request_id in
Python or requestID in Node. Without it, a support conversation is a
description of a feeling.
Which layer owns which field
Four observations, each of which either indicts a layer or clears it.
Does it fail on the very first turn, before any tool has been called? Then
your history contains no tool_calls yet, so something in the request builder is
emitting the older parameter — and probably emitting tools as well. The
conversation is innocent.
Does it only fail after the model has made a tool call? Then the builder is
consistent and the stored assistant turn is the newer shape. The functions
parameter is being added by something that did not notice.
Send the same parameters with an empty message history. If it succeeds, the history is the source. If it still fails, the parameters are. This is the fastest single test on the page and it takes one run.
Point the same client at the official endpoint instead of your gateway. If the request passes there and fails through the gateway, the translation layer is merging the two shapes and no change to your application will fix it.
Fix by the layer the test selected
- The request builder adds
functions— removefunctionsandfunction_calland declare your tools in the current shape. Migrating forward is the right direction: the older pair is the deprecated side of this fork, and staying on it means the structural differences — a list of calls per assistant turn rather than at most one — stay unavailable to you. - Stored history carries the older shape and the builder the newer one — convert history at load time, and convert both halves. An assistant turn and the messages that answer it are one unit: rewriting the call without rewriting the result leaves a reference to a call that no longer exists.
- A framework or wrapper is responsible — the traceback and the captured body tell you which. Upgrade it, or configure which interface it emits, and re-run the empty-history test to confirm the field stopped appearing.
- The backend genuinely only speaks the older interface — then commit to it everywhere, including the conversations you have already stored. The API does not object to the older shape used consistently. It objects to the mixture.
- Two code paths for two models — normalize at the boundary where the request is built, not at each call site, so a request cannot be assembled half in one shape and half in the other.
What not to do: delete tool_calls to make it validate
This is the fix that suggests itself, because tool_calls sits in data you own
and looks removable. It is the most expensive option on the page.
The assistant’s tool_calls entry is the only thing the result messages point
back to. Strip it and one of two things happens. Either the request fails a
different validation — a result answering a call nobody made — and you have
traded a clear error for an obscure one. Or it validates, the model sees a tool
result with no record of having requested anything, and it re-calls the tool.
Now your side effects run twice, and nothing in the logs says why.
If a stored conversation must change shape, transform it. Do not amputate it.
How to confirm it is fixed
Re-send the exact request that failed, history included and otherwise unmodified. It has to succeed with no edits: if it only works once you also trim the messages, you removed the symptom rather than the conflict.
Then run a complete round trip, because this failure surfaces one turn after the layer that causes it. Send a prompt that triggers a tool call, append the assistant message exactly as returned, append the result, and send the follow-up. That second request is the one that previously died, and it is the one that proves the migration covered both halves.
Finally, make it permanent with an assertion rather than a memory. In whatever
code serializes the request, check that exactly one of functions and tools is
present and fail loudly otherwise. The whole failure mode is two layers each
believing they own the tool interface; an assertion at the single point where the
body is built is the only place that can see both.
Related errors
Still inside the Python SDK, ChatCompletionMessageToolCallParam “cannot instantiate typing.Union” is the other failure that looks like an API problem and is not — there the request is never even built, and the reframe is the whole fix.
On the Anthropic side the same class of mistake has two well-known shapes: a broken tool-call pair poisoning every later turn is what happens when history and parameters disagree and the API accepts it anyway, and tool names must be unique is the other pre-flight validation of a tool payload that never reaches a model.
If you are not yet sure whether your failure is validation, capacity, or transport, the AI coding error triage tool separates them by the signals used above — chiefly whether the error arrived instantly or after a retry delay.