Skip to content

Commit af2f027

Browse files
pseudotensorclaude
andcommitted
fix: harden the max_tokens resolution — four regressions found by red-teaming
Adversarially probing #26 against the pre-change tree (275646b) turned up four behaviour regressions and one dead branch. Every row below is measured, not reasoned. 1. A NUMERIC CEILING WAS SILENTLY DELETED. `_positive_int` rejected anything that was not an `int`, and the "strip an unusable value from a rejected field" clause then removed it. So on Azure 2025: max_tokens=50.0 before {'max_tokens': 50.0} after {} <- unbounded max_tokens='50' before {'max_tokens': '50'} after {} <- unbounded max_tokens=0 before {'max_tokens': 0} after {} max_tokens=True before {'max_tokens': True} after {} Floats genuinely reach litellm — `AnthropicConfig.map_openai_params` coerces one with `max(1, int(round(value)))`, and `anthropic max_tokens=50.0` really does go out as 50. Turning a caller's 50.0 into no ceiling at all is worse than the defect #26 fixed. Fixed two ways. Numeric values are now usable and coerced the same way Anthropic already coerces them (NaN and inf rejected). And rule 3 no longer strips anything: when no usable value can be derived, NOTHING is changed and the provider rejects the request as loudly as it always did. Garbage in, error out — that clause bought nothing (Azure rejects `max_tokens: 0` on its own merits either way) and cost a silent unbounding. 2. IT RESURRECTED A DROPPED PARAM. A field in `additional_drop_params` was still eligible as a target, so `drop: [max_completion_tokens]` plus a client `max_tokens` went out AS `max_completion_tokens` — defeating an explicit operator instruction: drop=[max_completion_tokens], MT=50 before {'max_tokens': 50} after {'max_completion_tokens': 50} The drop list is now consulted for every target choice, including the directive's. If both fields are dropped or unsupported, nothing happens. 3. A NON-STRING api_version RAISED A NEW EXCEPTION TYPE. The v1-api check does a set membership test, so a list or dict `api_version` raised `unhashable type` from param mapping where the pre-change tree raised `AttributeError` from its own `.split()`. Now anything that is not a `str` yields "no preference" — this lookup runs on every Azure chat request and must not invent a failure mode. The remaining int/bytes/list crashes are litellm's own pre-existing `api_version.split("-")` and are now byte-for-byte identical to pre-change. 4. THE PREFERENCE RESOLVED AGAINST A DIFFERENT api_version THAN THE REQUEST USED. The fallback to `litellm.api_version` / `AZURE_API_VERSION` / `AZURE_DEFAULT_API_VERSION` triggered on `is None`, while the azure branch below it uses `or`-falsiness. An `api_version=""` therefore resolved the preference against nothing while the request went out on the 2025 default — `max_tokens` to a version that rejects it. Now matches the same falsiness. DEAD BRANCH REMOVED. The `extra_body` handling could never fire: `extra_body` is not a mappable chat param, so it never appears in `non_default_params`. Verified — with `extra_body={'max_completion_tokens': 64000}` neither the strip nor the min() saw it. Code that claims a guarantee it does not provide is worse than no code, so it is gone and the limitation is documented instead: a caller who reaches for `extra_body` is explicitly bypassing param mapping, and the h2o cap hook is where that channel is policed. ALSO VERIFIED, NO CHANGE NEEDED. `azure_text` (the legacy /completions endpoint, which has no `max_completion_tokens` at all) resolves to a different provider config and is untouched — the same class of hazard that made the earlier deployment-hook approach break /v1/messages. Now covered by a test. Live re-verification through a litellm proxy against a capture server, client sending max_tokens=50: azure 2025, mt=50 -> max_completion_tokens: 50 azure 2025, mt=50.0 -> max_completion_tokens: 50 azure 2025, drop=[MCT] -> max_tokens: 50 azure 2025, directive=false -> max_tokens: 50 anthropic ceiling 64000 -> max_tokens: 50 86 tests, up from 60. The float, drop-list, api_version-type and leave-garbage-alone cases all fail on #26's head. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 60f5ee9 commit af2f027

4 files changed

Lines changed: 252 additions & 57 deletions

File tree

litellm/litellm_core_utils/max_tokens_params.py

Lines changed: 110 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
``max_tokens`` and its replacement ``max_completion_tokens``. A request can
77
arrive with both — most commonly because a proxy deployment configures one of
88
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:
9+
that happens, what reaches the provider depends on the order the provider's
10+
``map_openai_params`` happens to iterate, and the result is wrong in two
11+
different ways:
1212
1313
* Providers that collapse both onto one field do so with LAST-WINS
1414
semantics, because both branches assign the same output key. ``anthropic``
@@ -30,22 +30,43 @@
3030
3131
THE RULES
3232
---------
33-
1. TIGHTER WINS. When both fields carry a usable value, the smaller one is
33+
1. TIGHTER WINS. When more than one usable value is present, the smallest is
3434
kept. A deployment ceiling can therefore never be RAISED by a client, and a
3535
client's tighter request can never be widened to the ceiling.
3636
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
37+
2. ONE FIELD OUT. When a target field is known, only that field survives. The
38+
target comes from the provider config
39+
(``BaseConfig.get_preferred_max_tokens_param``) or from an explicit
4040
per-request/per-deployment ``use_max_completion_tokens`` directive, which
4141
overrides the provider's own detection.
4242
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.
43+
3. NEVER SILENTLY DROP A CEILING. If no usable value can be derived — a
44+
``max_tokens`` of ``0``, ``"50"``, ``True``, ``None`` — NOTHING is changed.
45+
The request goes on exactly as it would have without this module, and the
46+
provider rejects it as loudly as it always did. Rewriting it here would
47+
turn a garbage-in/error-out request into an unbounded one, which is a worse
48+
failure than the one being fixed. Numeric values ARE usable and are coerced
49+
to a positive int, matching what ``AnthropicConfig.map_openai_params``
50+
already does with a float ``max_tokens``.
51+
52+
4. NEVER RESURRECT A DROPPED PARAM. A field listed in
53+
``additional_drop_params`` is off limits as a target. An operator who
54+
dropped a param meant it, and moving a value onto that field would defeat
55+
the drop.
56+
57+
OUT OF SCOPE: ``extra_body``
58+
----------------------------
59+
A copy of either field inside ``extra_body`` is NOT considered here, and
60+
deliberately so — ``extra_body`` is not a mappable chat param, so it never
61+
reaches ``non_default_params`` and cannot be read at this layer. It is merged
62+
into the request body further downstream, which means a caller who puts an
63+
output-token field there can still override what this resolves. That is the
64+
pre-existing behaviour of that channel (a caller reaching for ``extra_body`` is
65+
explicitly bypassing param mapping), and the h2o ``max_tokens`` cap hook is
66+
where ``extra_body`` is policed.
4667
"""
4768

48-
from typing import Any, Dict, Optional, Sequence
69+
from typing import Any, Dict, List, Optional, Sequence
4970

5071
MAX_TOKENS_PARAM = "max_tokens"
5172
MAX_COMPLETION_TOKENS_PARAM = "max_completion_tokens"
@@ -59,30 +80,64 @@ def preferred_param_for_directive(
5980
"""Translate the ``use_max_completion_tokens`` directive to a field name.
6081
6182
``None`` means "no directive given" — fall back to provider detection.
83+
Only the exact booleans are honoured; anything else (a stray ``"false"``
84+
string from a YAML config, say) is treated as "not given" rather than
85+
coerced by truthiness into the opposite of what it reads like.
6286
"""
63-
if use_max_completion_tokens is None:
87+
if use_max_completion_tokens is True:
88+
return MAX_COMPLETION_TOKENS_PARAM
89+
if use_max_completion_tokens is False:
90+
return MAX_TOKENS_PARAM
91+
return None
92+
93+
94+
def _usable_int(value: Any) -> Optional[int]:
95+
"""Return ``value`` as a positive int, or None if it isn't a usable limit.
96+
97+
``bool`` is rejected explicitly because it is an ``int`` subclass. Floats
98+
are accepted and rounded, because a float ``max_tokens`` really does reach
99+
litellm — ``AnthropicConfig.map_openai_params`` coerces one with
100+
``max(1, int(round(value)))``, and this mirrors that so the two agree.
101+
Strings are NOT coerced: a value litellm cannot interpret must be left for
102+
the provider to reject (rule 3).
103+
"""
104+
if isinstance(value, bool) or not isinstance(value, (int, float)):
64105
return None
106+
if isinstance(value, float):
107+
if value != value or value in (float("inf"), float("-inf")): # NaN / inf
108+
return None
109+
if value <= 0:
110+
return None
111+
return max(1, int(round(value)))
112+
return value if value > 0 else None
113+
114+
115+
def _is_dropped(param: str, additional_drop_params: Optional[Sequence[str]]) -> bool:
116+
"""Mirror of ``litellm.utils._should_drop_param`` for these two fields."""
65117
return (
66-
MAX_COMPLETION_TOKENS_PARAM
67-
if use_max_completion_tokens
68-
else MAX_TOKENS_PARAM
118+
additional_drop_params is not None
119+
and isinstance(additional_drop_params, (list, tuple))
120+
and param in additional_drop_params
69121
)
70122

71123

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
124+
def _eligible(
125+
param: str,
126+
supported_params: Sequence[str],
127+
additional_drop_params: Optional[Sequence[str]],
128+
) -> bool:
129+
"""A field can only be a target if the provider accepts it (rule 2) and the
130+
operator has not dropped it (rule 4)."""
131+
return param in supported_params and not _is_dropped(
132+
param, additional_drop_params
133+
)
80134

81135

82136
def resolve_max_tokens_params(
83137
non_default_params: Dict[str, Any],
84138
supported_params: Sequence[str],
85139
preferred_param: Optional[str] = None,
140+
additional_drop_params: Optional[Sequence[str]] = None,
86141
) -> Optional[str]:
87142
"""Collapse ``max_tokens`` / ``max_completion_tokens`` in place.
88143
@@ -94,6 +149,8 @@ def resolve_max_tokens_params(
94149
output-token ceiling at all.
95150
preferred_param: the field to keep, or None to let the caller's own
96151
field stand when only one was sent.
152+
additional_drop_params: this deployment's drop list; a field in it is
153+
never used as a target.
97154
98155
Returns:
99156
The field that survived, or None when nothing was changed.
@@ -104,41 +161,50 @@ def resolve_max_tokens_params(
104161

105162
values = [
106163
v
107-
for v in (_positive_int(non_default_params[p]) for p in present)
164+
for v in (_usable_int(non_default_params[p]) for p in present)
108165
if v is not None
109166
]
110167
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)
168+
# Rule 3 — nothing usable. Leave the request exactly as it was so the
169+
# provider rejects it as loudly as it would have without us.
119170
return None
120171
resolved = min(values)
121172

122173
target: Optional[str] = None
123-
if preferred_param is not None and preferred_param in supported_params:
174+
if preferred_param is not None and _eligible(
175+
preferred_param, supported_params, additional_drop_params
176+
):
124177
target = preferred_param
125178
elif len(present) == 1:
126-
# One field, no preference: the caller's choice already is canonical.
179+
# One field, no provider preference: the caller's choice already is
180+
# canonical.
127181
return None
128182
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-
)
183+
# Both fields, no provider preference. Collapsing is still required:
184+
# leaving both is what makes the last-wins provider maps pick the looser
185+
# value. Prefer `max_tokens` — every provider understands it, and the
186+
# reasoning-model configs that want `max_completion_tokens` rename it
187+
# themselves downstream.
188+
for candidate in MAX_TOKENS_PARAMS:
189+
if _eligible(candidate, supported_params, additional_drop_params):
190+
target = candidate
191+
break
192+
if target is None:
193+
# Both fields are unsupported or dropped: there is nothing to move
194+
# the value onto, and inventing one would defeat the drop.
195+
return None
139196

140197
for param in MAX_TOKENS_PARAMS:
141198
if param != target:
142199
non_default_params.pop(param, None)
143200
non_default_params[target] = resolved
144201
return target
202+
203+
204+
__all__ = [
205+
"MAX_COMPLETION_TOKENS_PARAM",
206+
"MAX_TOKENS_PARAM",
207+
"MAX_TOKENS_PARAMS",
208+
"preferred_param_for_directive",
209+
"resolve_max_tokens_params",
210+
]

litellm/llms/azure/chat/gpt_transformation.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,12 @@ def get_preferred_max_tokens_param(
160160
to travel on ``max_tokens``.
161161
162162
Returns None when the api_version isn't a recognizable shape, which
163-
leaves the caller's own field untouched.
163+
leaves the caller's own field untouched. A non-str api_version (an int
164+
year, bytes, a list) is treated as unrecognizable rather than allowed to
165+
raise: this runs on every Azure chat request, so it must not turn a
166+
misconfigured api_version into a traceback from param mapping.
164167
"""
165-
if api_version is None:
168+
if not isinstance(api_version, str):
166169
return None
167170

168171
from litellm.llms.azure.common_utils import BaseAzureLLM

litellm/utils.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3883,10 +3883,11 @@ def _check_valid_arg(supported_params: List[str]):
38833883
# limit to the deployment ceiling. See
38843884
# litellm.litellm_core_utils.max_tokens_params for the rules.
38853885
_max_tokens_api_version = api_version
3886-
if custom_llm_provider == "azure" and _max_tokens_api_version is None:
3887-
# Mirror the fallback chain the azure branch below applies, so the
3888-
# api_version-dependent preference is resolved against the version the
3889-
# request will actually be sent with.
3886+
if custom_llm_provider == "azure" and not _max_tokens_api_version:
3887+
# Mirror the fallback chain the azure branch below applies — including
3888+
# its `or`-based falsiness, so an empty-string api_version resolves the
3889+
# preference against the same version the request is actually sent with
3890+
# rather than against nothing.
38903891
_max_tokens_api_version = (
38913892
litellm.api_version
38923893
or get_secret("AZURE_API_VERSION")
@@ -3903,6 +3904,7 @@ def _check_valid_arg(supported_params: List[str]):
39033904
non_default_params=non_default_params,
39043905
supported_params=supported_params,
39053906
preferred_param=_preferred_max_tokens_param,
3907+
additional_drop_params=additional_drop_params,
39063908
)
39073909

39083910
_check_valid_arg(

0 commit comments

Comments
 (0)