Skip to content

Commit 60f5ee9

Browse files
committed
fix: resolve max_tokens / max_completion_tokens to one field, tighter value wins
A chat request can carry both output-token fields — most often because a deployment configures one as a ceiling in litellm_params while the client sends the other. What reached the provider then depended on dict iteration order, and was wrong two different ways. SILENT CEILING OVERRIDE. The provider maps that collapse the two fields assign the same output key from two branches, so the one iterated last won. `max_completion_tokens` is second in the `get_optional_params` signature, so a deployment ceiling silently replaced the caller's request. Measured on the pre-change tree with max_tokens=50 + max_completion_tokens=64000: anthropic -> {'max_tokens': 64000} bedrock -> {'maxTokens': 64000} openai o3 -> {'max_completion_tokens': 64000} openai gpt-5 -> {'max_completion_tokens': 64000} `openai_like` is worse: `replace_max_completion_tokens_with_max_tokens` overwrites unconditionally, so the ceiling always won. BOTH FIELDS ON THE WIRE. Providers that forward both unchanged send both. Azure 2025+ api_versions reject that outright: AzureException BadRequestError - Setting 'max_tokens' and 'max_completion_tokens' at the same time is not supported. Neither failure is provider-specific in nature, so neither belongs in a provider transform. `get_optional_params` now resolves the two fields once, before any mapping, in `litellm_core_utils/max_tokens_params.py`: 1. Tighter wins. A ceiling can't be raised by a client, and a client's tighter request can't be widened to the ceiling. 2. One field out, chosen by `BaseConfig.get_preferred_max_tokens_param`. Default None (either field accepted, caller's choice stands). `AzureOpenAIConfig` derives it from the api_version — 2025+ and the v1 API need `max_completion_tokens`, older versions predate that field and need `max_tokens`, so the rename runs in both directions. The o-series and gpt-5 configs declare `max_completion_tokens`, which is what makes a request carrying both resolve there instead of last-wins. 3. Never invent a value. A max_tokens of 0 / None / "50" / True does not become a max_completion_tokens that truncates every response. It is still stripped from a field the provider is known to reject, since an unusable value is not a ceiling worth preserving. A preference for a field the provider does not accept is ignored rather than forced, so resolution can never leave a request with no ceiling at all. Also adds `use_max_completion_tokens`, a per-request / per-deployment directive that overrides the provider detection: True sends `max_completion_tokens`, False sends `max_tokens`, None (default) keeps today's detection. It is the authoritative control for deployments whose requirement can't be inferred from provider + api_version. Plumbed as a litellm-only param, so it is never forwarded to a provider. Verified live against a litellm proxy with a capture server standing in for the provider, client sending max_tokens=50, before/after on the same config: deployment before after azure 2025 + max_completion_tokens MCT 16384 + MT 50 MCT 50 ceiling (Azure 400s) azure 2025 + that ceiling and MCT 16384 MCT 50 additional_drop_params: [max_tokens] (caller's 50 gone) anthropic + max_completion_tokens MT 64000 MT 50 ceiling (caller's 50 gone) azure 2025 + use_max_completion_ n/a (ignored) MT 50 tokens: false 60 tests. The tighter-wins and Azure-rename tests fail on the previous head. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Also regenerates ui/litellm-dashboard/src/lib/http/schema.d.ts, which the Check UI API Types Sync workflow verifies against the live proxy spec: adding the param to GenericLiteLLMParams changes that spec. The same regeneration picks up a pre-existing /v1/router/models drift already on h2o-main (a route added without regenerating), so that hunk is here too — without it the check stays red on any PR touching litellm/types/**.
1 parent 275646b commit 60f5ee9

13 files changed

Lines changed: 875 additions & 1 deletion

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Canonicalize the two OpenAI output-token fields onto a single field.
2+
3+
WHY THIS EXISTS
4+
---------------
5+
The OpenAI chat schema carries two output-token fields: the original
6+
``max_tokens`` and its replacement ``max_completion_tokens``. A request can
7+
arrive with both — most commonly because a proxy deployment configures one of
8+
them as a ceiling in ``litellm_params`` while the client sends the other. When
9+
that happens today, what reaches the provider depends on the order the
10+
provider's ``map_openai_params`` happens to iterate, and the result is wrong in
11+
two different ways:
12+
13+
* Providers that collapse both onto one field do so with LAST-WINS
14+
semantics, because both branches assign the same output key. ``anthropic``
15+
(``max_tokens``) and ``bedrock`` converse (``maxTokens``) both do this, and
16+
``max_completion_tokens`` is iterated second, so a client asking for 50
17+
output tokens against a deployment configured with a 64000 ceiling gets
18+
64000 — the caller's limit is silently discarded. ``openai_like`` is worse:
19+
it replaces unconditionally, so the ceiling always wins.
20+
21+
* Providers that forward both fields unchanged send both to the API.
22+
Azure 2025+ API versions reject that outright:
23+
24+
AzureException BadRequestError - Setting 'max_tokens' and
25+
'max_completion_tokens' at the same time is not supported.
26+
27+
Neither failure mode is provider-specific in nature, so neither belongs in a
28+
provider transform. This module resolves both fields to one, once, before the
29+
provider mapping runs.
30+
31+
THE RULES
32+
---------
33+
1. TIGHTER WINS. When both fields carry a usable value, the smaller one is
34+
kept. A deployment ceiling can therefore never be RAISED by a client, and a
35+
client's tighter request can never be widened to the ceiling.
36+
37+
2. ONE FIELD OUT. When a preferred field is known, only that field survives.
38+
The preference comes from the provider config
39+
(``BaseConfig.get_preferred_max_tokens_param``), or from an explicit
40+
per-request/per-deployment ``use_max_completion_tokens`` directive, which
41+
overrides the provider's own detection.
42+
43+
3. NEVER INVENT A VALUE. If neither field holds a positive integer, nothing is
44+
changed. A ``max_tokens`` of ``0``, ``None``, a string, or a bool must not
45+
become a ``max_completion_tokens`` that truncates every response.
46+
"""
47+
48+
from typing import Any, Dict, Optional, Sequence
49+
50+
MAX_TOKENS_PARAM = "max_tokens"
51+
MAX_COMPLETION_TOKENS_PARAM = "max_completion_tokens"
52+
53+
MAX_TOKENS_PARAMS = (MAX_TOKENS_PARAM, MAX_COMPLETION_TOKENS_PARAM)
54+
55+
56+
def preferred_param_for_directive(
57+
use_max_completion_tokens: Optional[bool],
58+
) -> Optional[str]:
59+
"""Translate the ``use_max_completion_tokens`` directive to a field name.
60+
61+
``None`` means "no directive given" — fall back to provider detection.
62+
"""
63+
if use_max_completion_tokens is None:
64+
return None
65+
return (
66+
MAX_COMPLETION_TOKENS_PARAM
67+
if use_max_completion_tokens
68+
else MAX_TOKENS_PARAM
69+
)
70+
71+
72+
def _positive_int(value: Any) -> Optional[int]:
73+
"""Return ``value`` as a positive int, or None if it isn't one.
74+
75+
``bool`` is rejected explicitly because it is an ``int`` subclass.
76+
"""
77+
if isinstance(value, bool) or not isinstance(value, int):
78+
return None
79+
return value if value > 0 else None
80+
81+
82+
def resolve_max_tokens_params(
83+
non_default_params: Dict[str, Any],
84+
supported_params: Sequence[str],
85+
preferred_param: Optional[str] = None,
86+
) -> Optional[str]:
87+
"""Collapse ``max_tokens`` / ``max_completion_tokens`` in place.
88+
89+
Args:
90+
non_default_params: mutated in place; the params about to be mapped.
91+
supported_params: the params this model/provider accepts. A preference
92+
for a field the provider does not accept is ignored rather than
93+
forced, so the resolution can never leave a request with no
94+
output-token ceiling at all.
95+
preferred_param: the field to keep, or None to let the caller's own
96+
field stand when only one was sent.
97+
98+
Returns:
99+
The field that survived, or None when nothing was changed.
100+
"""
101+
present = [p for p in MAX_TOKENS_PARAMS if p in non_default_params]
102+
if not present:
103+
return None
104+
105+
values = [
106+
v
107+
for v in (_positive_int(non_default_params[p]) for p in present)
108+
if v is not None
109+
]
110+
if not values:
111+
# Rule 3 — nothing usable to move or tighten. Still strip a field the
112+
# provider is known to reject: an unusable value (0, a string) is not a
113+
# ceiling worth preserving, and leaving it on a field the provider
114+
# rejects turns a garbage request into a guaranteed 400.
115+
if preferred_param is not None and preferred_param in supported_params:
116+
for param in MAX_TOKENS_PARAMS:
117+
if param != preferred_param:
118+
non_default_params.pop(param, None)
119+
return None
120+
resolved = min(values)
121+
122+
target: Optional[str] = None
123+
if preferred_param is not None and preferred_param in supported_params:
124+
target = preferred_param
125+
elif len(present) == 1:
126+
# One field, no preference: the caller's choice already is canonical.
127+
return None
128+
else:
129+
# Both fields with no provider preference. Collapsing is still
130+
# required — leaving both is what makes the last-wins provider maps
131+
# pick the looser value. Prefer `max_tokens`: every provider
132+
# understands it, and the reasoning-model configs that want
133+
# `max_completion_tokens` rename it themselves downstream.
134+
target = (
135+
MAX_TOKENS_PARAM
136+
if MAX_TOKENS_PARAM in supported_params
137+
else MAX_COMPLETION_TOKENS_PARAM
138+
)
139+
140+
for param in MAX_TOKENS_PARAMS:
141+
if param != target:
142+
non_default_params.pop(param, None)
143+
non_default_params[target] = resolved
144+
return target

litellm/llms/azure/chat/gpt_5_transformation.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Support for Azure OpenAI gpt-5 model family."""
22

3-
from typing import List
3+
from typing import List, Optional
44

55
import litellm
66
from litellm.exceptions import UnsupportedParamsError
@@ -56,6 +56,17 @@ def is_model_gpt_5_model(cls, model: str) -> bool:
5656
_normalized = model.split("/")[-1] # strip provider prefix, e.g. "azure/"
5757
return ("gpt-5" in model and not _normalized.startswith("gpt-5-chat")) or "gpt5_series" in model
5858

59+
def get_preferred_max_tokens_param(
60+
self, model: str, api_version: Optional[str] = None
61+
) -> Optional[str]:
62+
"""Always ``max_completion_tokens``, independent of api_version.
63+
64+
The MRO would otherwise reach ``AzureOpenAIConfig``'s api_version rule
65+
first, which would answer ``max_tokens`` for a pre-2025 version — wrong
66+
for a gpt-5 deployment, whose ``map_openai_params`` renames it anyway.
67+
"""
68+
return "max_completion_tokens"
69+
5970
def get_supported_openai_params(self, model: str) -> List[str]:
6071
"""Get supported parameters for Azure OpenAI GPT-5 models.
6172

litellm/llms/azure/chat/gpt_transformation.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from litellm.llms.base_llm.chat.transformation import BaseLLMException
1010
from litellm.types.llms.azure import (
1111
API_VERSION_MONTH_SUPPORTED_RESPONSE_FORMAT,
12+
API_VERSION_YEAR_REQUIRING_MAX_COMPLETION_TOKENS,
1213
API_VERSION_YEAR_SUPPORTED_RESPONSE_FORMAT,
1314
)
1415
from litellm.types.utils import ModelResponse
@@ -148,6 +149,34 @@ def _is_response_format_supported_api_version(self, api_version_year: str, api_v
148149
else:
149150
return api_month >= supported_month
150151

152+
def get_preferred_max_tokens_param(
153+
self, model: str, api_version: Optional[str] = None
154+
) -> Optional[str]:
155+
"""Azure's output-token field depends on the api_version.
156+
157+
2025+ versions (and the v1 API) reject ``max_tokens`` for ALL chat
158+
models, not just the o-series, so the caller's value has to travel on
159+
``max_completion_tokens``. Older versions predate that field, so it has
160+
to travel on ``max_tokens``.
161+
162+
Returns None when the api_version isn't a recognizable shape, which
163+
leaves the caller's own field untouched.
164+
"""
165+
if api_version is None:
166+
return None
167+
168+
from litellm.llms.azure.common_utils import BaseAzureLLM
169+
170+
if BaseAzureLLM._is_azure_v1_api_version(api_version):
171+
return "max_completion_tokens"
172+
173+
api_version_year = api_version.split("-")[0]
174+
if len(api_version_year) != 4 or not api_version_year.isdigit():
175+
return None
176+
if int(api_version_year) >= API_VERSION_YEAR_REQUIRING_MAX_COMPLETION_TOKENS:
177+
return "max_completion_tokens"
178+
return "max_tokens"
179+
151180
def map_openai_params(
152181
self,
153182
non_default_params: dict,

litellm/llms/base_llm/chat/transformation.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,22 @@ def max_retry_on_unprocessable_entity_error(self) -> int:
192192
def get_supported_openai_params(self, model: str) -> list:
193193
pass
194194

195+
def get_preferred_max_tokens_param(
196+
self, model: str, api_version: Optional[str] = None
197+
) -> Optional[str]:
198+
"""Which output-token field this provider wants on the wire.
199+
200+
The OpenAI schema has two — ``max_tokens`` and its replacement
201+
``max_completion_tokens`` — and a request can arrive carrying both.
202+
Return the one this provider accepts so that only that one is sent;
203+
return None (the default) when the provider handles either and the
204+
caller's choice should stand.
205+
206+
See ``litellm.litellm_core_utils.max_tokens_params`` for the resolution
207+
this feeds.
208+
"""
209+
return None
210+
195211
def _add_response_format_to_tools(
196212
self,
197213
optional_params: dict,

litellm/llms/openai/chat/gpt_5_transformation.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,16 @@ def get_supported_openai_params(self, model: str) -> list:
188188

189189
return [param for param in base_gpt_series_params if param not in non_supported_params]
190190

191+
def get_preferred_max_tokens_param(
192+
self, model: str, api_version: Optional[str] = None
193+
) -> Optional[str]:
194+
"""gpt-5 models take ``max_completion_tokens`` only.
195+
196+
Same reason as the o-series: the rename below writes the key that the
197+
generic mapping overwrites when both fields are present.
198+
"""
199+
return "max_completion_tokens"
200+
191201
def map_openai_params(
192202
self,
193203
non_default_params: dict,

litellm/llms/openai/chat/o_series_transformation.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ def get_supported_openai_params(self, model: str) -> list:
8787

8888
return [param for param in all_openai_params if param not in non_supported_params]
8989

90+
def get_preferred_max_tokens_param(
91+
self, model: str, api_version: Optional[str] = None
92+
) -> Optional[str]:
93+
"""O-series models take ``max_completion_tokens`` only.
94+
95+
``map_openai_params`` below already moves ``max_tokens`` across, but it
96+
writes the key that the generic mapping then overwrites when BOTH
97+
fields are present. Declaring the preference is what makes a request
98+
carrying both resolve to one field holding the tighter value.
99+
"""
100+
return "max_completion_tokens"
101+
90102
def map_openai_params(
91103
self,
92104
non_default_params: dict,

litellm/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5254,6 +5254,10 @@ def completion( # type: ignore
52545254
"service_tier": service_tier,
52555255
"allowed_openai_params": kwargs.get("allowed_openai_params"),
52565256
"base_model": base_model,
5257+
# Selects WHICH output-token field is sent (max_tokens vs
5258+
# max_completion_tokens), overriding the provider's own detection.
5259+
# Settable per-request or per-deployment via litellm_params.
5260+
"use_max_completion_tokens": kwargs.get("use_max_completion_tokens"),
52575261
}
52585262
optional_params = get_optional_params(**optional_param_args, **non_default_params)
52595263
processed_non_default_params = pre_process_non_default_params(

litellm/types/llms/azure.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,17 @@
11
API_VERSION_YEAR_SUPPORTED_RESPONSE_FORMAT = 2024
22
API_VERSION_MONTH_SUPPORTED_RESPONSE_FORMAT = 8
3+
4+
# First api_version year in which Azure chat completions reject `max_tokens`
5+
# in favour of `max_completion_tokens`:
6+
#
7+
# Unsupported parameter: 'max_tokens' is not supported with this model.
8+
# Use 'max_completion_tokens' instead.
9+
#
10+
# Observed on 2025-04-01-preview against a gpt-4o deployment, i.e. for a plain
11+
# chat model and not only for the o-series. The boundary is set at the year
12+
# rather than a specific preview date because Azure rolled the change out
13+
# across the 2025 preview versions and the v1 (`preview` / `latest` / `v1`)
14+
# API, and because `max_completion_tokens` is the field Azure documents as
15+
# current for every 2025 version — so erring on this side of the boundary
16+
# sends the field Azure asks for.
17+
API_VERSION_YEAR_REQUIRING_MAX_COMPLETION_TOKENS = 2025

litellm/types/router.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
245245
default=False,
246246
description="Use stored xAI OAuth credentials when no xAI API key is configured.",
247247
)
248+
# Which output-token field this deployment accepts. True -> send
249+
# `max_completion_tokens`, False -> send `max_tokens`, None (default) ->
250+
# let the provider config decide (for Azure, from the api_version).
251+
# Overrides that detection, so it is the authoritative control when a
252+
# deployment's requirement can't be inferred from provider + api_version.
253+
use_max_completion_tokens: Optional[bool] = None
248254
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
249255
merge_reasoning_content_in_choices: Optional[bool] = False
250256
model_info: Optional[Dict] = None

litellm/types/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3210,6 +3210,7 @@ def strip_custom_pricing_fields(cls, model_info: Dict[str, Any]) -> Dict[str, An
32103210
"_litellm_tpm_reserved_model",
32113211
"_litellm_tpm_reserved_scopes",
32123212
"_litellm_tpm_reservation_released",
3213+
"use_max_completion_tokens",
32133214
]
32143215
+ list(StandardCallbackDynamicParams.__annotations__.keys())
32153216
+ list(CustomPricingLiteLLMParams.model_fields.keys())

0 commit comments

Comments
 (0)