Skip to content

Mapping Max Tokens Parameters Between APIs

9 min read · updated August 11, 2026

Of all the fields in a chat request, this is the one most likely to be copied across unchanged, and the one where copying it across unchanged does the most damage. The name has been deprecated on one provider, nested on another, and made mandatory on a third — and the value buys a different amount of visible output on each.

The name changed, twice

Start with the identifiers, because there are more of them than you would expect for one concept.

  • OpenAI Chat Completions — historically max_tokens. The published specification now carries the deprecation note “Deprecated in favor of max_completion_tokens” on that field, and max_completion_tokens is the current name. Both appear in the request schema, which is why so much code is still sending the old one.
  • OpenAI Responses — a third spelling again: max_output_tokens.
  • Anthropic Messagesmax_tokens, and it is required. A request without it is rejected.
  • Gemini generationConfig.maxOutputTokens, optional, and nested one level down inside the generation configuration object.

Four names for one concept, one of them deprecated, one of them nested, one of them mandatory. An adapter that maps this field by name alone will be sending a deprecated parameter to one provider and omitting a required one on another.

Required, optional, and what omission does

The required-versus-optional split is the asymmetry that matters when you write the translation, because it is the direction that cannot be faked.

Translating to Anthropic from an API where the field is optional, you may have nothing to translate: the source request simply did not set it. You cannot omit it on the target, so the adapter has to invent a number. Whatever it invents becomes an invisible policy decision baked into your gateway — too low and long answers are cut off on a provider the user did not expect it from, too high and a runaway generation costs more than the request that produced it.

Translating from Anthropic, the field is always present, so there is always something to carry across. That direction is clean. But note what the presence means: because Anthropic requires it, the value in an Anthropic request is frequently a large ceiling somebody set once rather than a considered budget for this call. Carrying that number verbatim to an API where omission would have produced a sensible model-specific default can make outputs longer and more expensive than they were before the migration, with no code change anywhere that looks responsible for it.

The number does not mean the same thing

This is the part that is not a rename at all, and it is the reason a verbatim copy misbehaves even when both sides accept the field.

On a model that reasons before answering, the tokens spent reasoning are output tokens. They are generated, they are billed, and they count against the ceiling. That is why OpenAI renamed the field: the older max_tokens read as “how long may the answer be”, and max_completion_tokens reads as “how many tokens may the completion consume in total”, which is the honest description. Anthropic’s max_tokens behaves the same way where thinking is enabled: it caps thinking plus visible text together.

The observable consequence is a response with a truncation signal and no visible content at all. The entire budget went to reasoning and the answer never started. A ceiling of 1,024 that comfortably fitted a two-paragraph answer on a non-reasoning model can return an empty string on a reasoning one, and the request still bills for a thousand output tokens. If your adapter carries the number across unchanged and the target model reasons and the source did not, this is the failure you get, and it looks like the model refusing to answer.

Two related confusions are worth keeping separate. This ceiling is not the context window, which bounds input plus output together and is a property of the model rather than of your request — the distinction is worked through on context window versus max tokens. And it is not a stop sequence: one is a hard budget, the other is a content trigger, and they produce different terminal signals, which is the subject of the stop sequence mapping.

Four ways to say the same truncation

When the ceiling is hit, each API says so differently, and the shapes are different enough that a single handler needs a real branch rather than a string comparison.

OpenAI Chat Completions   choices[0].finish_reason == "length"
Anthropic Messages        stop_reason == "max_tokens"
Gemini                    candidates[0].finishReason == "MAX_TOKENS"
OpenAI Responses          status == "incomplete"
                          incomplete_details.reason == "max_output_tokens"

Three of these live on the completion object and are a single string compare. The fourth splits the signal across two fields, so a normaliser that only reads the terminal-reason field on the Responses shape sees a response whose status is incomplete and no reason at all. Casing differs too — Gemini’s enumeration is uppercase, the others are lowercase — which is exactly the kind of difference that survives a code review and fails in production.

What the adapter should actually do

Three rules keep this field honest across a mapping layer.

First, hold the concept, not the number. If your internal representation records “the caller did not specify a ceiling” as distinct from “the caller specified 4096”, you can honour omission where the target allows it and apply a per-model default only where the target demands one. If your representation normalises omission to a number on the way in, that information is gone before the target is chosen.

Second, make the default per-model, not global. The right ceiling for a model that reasons is not the right ceiling for one that does not, and the whole point of a mapping layer is that the caller does not know which one they got.

Third, normalise the truncation signal at the boundary and log it. Every provider above reports truncation, and every one of them reports it somewhere different and in a different case, which means the only way to know how often it happens across a fleet of models is to convert all four signals into one of your own at the point where you already have to branch on the provider anyway. A single flag on your own response object — truncated or not — costs nothing and turns a class of silent quality regressions into a countable event. You cannot tune the default without that number, and no provider will give it to you in a form that is the same across two of them.