From b9d54d15bc8e95be2050284a1a9ed501e0ced31f Mon Sep 17 00:00:00 2001 From: ujangm Date: Wed, 14 Oct 2026 20:01:52 +0700 Subject: [PATCH] fix(anthropic): default max_tokens from the deployment's max_output_tokens A chat-completions request without max_tokens gets its default from get_config(), which looks the model up by its provider-stripped name. A proxy deployment such as `anthropic/` is registered under the prefixed name, so the lookup misses and the request goes upstream capped at DEFAULT_ANTHROPIC_CHAT_MAX_TOKENS (4096) even though model_info sets max_output_tokens. transform_request now uses litellm_params["model_info"]["max_output_tokens"] when the request has no max_tokens and no class-level default is set. An explicit request value and AnthropicConfig(max_tokens=...) still win; deployments without the field keep the existing lookup and 4096 fallback. --- litellm/llms/anthropic/chat/transformation.py | 11 +++ .../test_anthropic_chat_transformation.py | 70 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index c43e5f9b05b..44cc5151edb 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2101,6 +2101,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if len(tools) > 1: optional_params["tools"] = tools + ## Deployment max output (proxy model_info) beats get_config(), which only sees the provider-stripped + ## model name and so falls back to 4096 for custom deployments; a request or class default still wins + if "max_tokens" not in optional_params and litellm.AnthropicConfig.max_tokens is None: + match litellm_params: + case {"model_info": {"max_output_tokens": int(deployment_max_tokens)}} if ( + deployment_max_tokens > 0 and not isinstance(deployment_max_tokens, bool) + ): + optional_params["max_tokens"] = deployment_max_tokens # rebind-ok: out-param store + case object(): + pass # no deployment limit: get_config() below fills the default + ## Load Config config: Final = litellm.AnthropicConfig.get_config(model=model) for k, v in config.items(): diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index b682d8661f3..25ce191c166 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -2646,6 +2646,76 @@ def test_transform_request_respects_user_max_tokens(): assert result["max_tokens"] == 1000 +@pytest.mark.parametrize( + ("optional_params", "litellm_params", "expected"), + [ + # custom deployment: the configured output limit replaces the 4096 fallback + ({}, {"model_info": {"max_output_tokens": 131072}}, 131072), + # an explicit request value still wins over the deployment limit + ({"max_tokens": 1000}, {"model_info": {"max_output_tokens": 131072}}, 1000), + # no deployment limit: unknown models keep the 4096 fallback + ({}, {}, 4096), + ({}, {"model_info": {"max_output_tokens": None}}, 4096), + ], +) +def test_transform_request_default_max_tokens_from_deployment( + optional_params: dict[str, object], litellm_params: dict[str, object], expected: int +) -> None: + result = AnthropicConfig().transform_request( + model="glm-5.3-flash", + messages=[{"role": "user", "content": "Hello"}], + optional_params=optional_params, + litellm_params=litellm_params, + headers={}, + ) + + assert result["max_tokens"] == expected, f"model_info={litellm_params!r} request={optional_params!r}" + + +async def test_router_sends_deployment_max_output_tokens_when_request_omits_max_tokens( + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, +) -> None: + """A proxy deployment `anthropic/` with model_info.max_output_tokens must not go upstream capped at 4096.""" + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + sent: Final[list[dict]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + sent.append(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "glm-5.3-flash", + "content": [{"type": "text", "text": "OK"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + ) + + respx_mock.post("https://anthropic-compatible.test/v1/messages").mock(side_effect=respond) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "claude-glm-5.3-flash", + "litellm_params": { + "model": "anthropic/glm-5.3-flash", + "api_base": "https://anthropic-compatible.test", + "api_key": "test-key", + }, + "model_info": {"max_output_tokens": 131072}, + } + ] + ) + + await router.acompletion(model="claude-glm-5.3-flash", messages=[{"role": "user", "content": "hi"}]) + await router.acompletion(model="claude-glm-5.3-flash", messages=[{"role": "user", "content": "hi"}], max_tokens=512) + + assert [body["max_tokens"] for body in sent] == [131072, 512], "first call omits max_tokens, second sends 512" + + def test_calculate_usage_completion_tokens_details_always_populated(): """ Test that completion_tokens_details is always populated in Usage object,