TL;DR — Newer OpenAI models reject max_tokens and want max_completion_tokens. It is a rename, not a behaviour change you can ignore: the new name exists because reasoning models spend tokens thinking before they emit a single visible character, and the cap now covers both. Swap the parameter for those models — but if you call more than one model family, do not sprinkle if model.startswith(...) through your code; normalise the parameter in one place.
The error
HTTP 400
{
"message": "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.",
"type": "invalid_request_error",
"param": "max_tokens",
"code": "unsupported_parameter"
}
It shows up the moment you point existing code at a newer model, which is what makes it feel like a regression: nothing in your code changed, only the model string did.
Why the parameter was renamed
Reasoning models generate internal reasoning tokens before the visible answer. Those tokens are billed and they occupy the completion budget, but you never see them. max_tokens used to mean "the length of the reply"; on a reasoning model that framing is wrong, because a large share of the budget can be consumed before the reply starts.
max_completion_tokens names the thing accurately: a cap on all tokens generated — reasoning plus visible output.
The practical consequence catches people out:
A limit that was generous for a chat model can be entirely consumed by reasoning, returning an empty or truncated answer with a
lengthfinish reason and a bill attached.
If you migrate by renaming the parameter and keeping the same number, budget too tight is the failure you should expect next.
The fix
One model family — rename it:
- max_tokens: 1024
+ max_completion_tokens: 4096 # leave headroom for reasoning tokens
A mixed fleet — normalise once, at the edge:
REASONING_STYLE = {"o1", "o3", "o4"} # keep this list in config, not in code
def build_payload(model: str, budget: int, **kw) -> dict:
key = ("max_completion_tokens"
if any(model.startswith(p) for p in REASONING_STYLE)
else "max_tokens")
return {"model": model, key: budget, **kw}
The point is not the exact predicate — model naming keeps moving, and any hardcoded list goes stale. The point is that there is exactly one place to edit when it does. Scattering the branch across every call site is how a one-line rename turns into a week of whack-a-mole.
Related parameter rejections on the same models
The same family rejects several other long-standing parameters, and the error shape is identical (unsupported_parameter / unsupported_value):
| Parameter | What happens |
|---|---|
temperature, top_p |
Rejected or restricted to the default — sampling is not yours to tune on these models |
frequency_penalty, presence_penalty |
Commonly unsupported |
max_tokens |
Rejected — use max_completion_tokens |
So a migration usually surfaces as a sequence of 400s rather than one. Strip the unsupported parameters rather than discovering them one deploy at a time.
Prevention
- Do not hardcode a model id next to a parameter set. Keep the pair in config so a model swap is a config change, not a code change.
- Log
finish_reason. Alengthfinish on a reasoning model means the budget was eaten, which looks like a quality problem and is actually a configuration problem. - Fail loudly at startup with one cheap call per configured model, so a parameter mismatch surfaces at boot and not at 3am.
- If you route across providers and model families, that normalisation belongs in the routing layer rather than in every service — what an LLM router should absorb for you.
Sourced from OpenAI's own error response; the exact string is quoted above so you can match on it.