diff --git a/litellm/llms/anthropic/pass_through/messages/transformation.py b/litellm/llms/anthropic/pass_through/messages/transformation.py index 30d45f3cec1..7cc819eea5c 100644 --- a/litellm/llms/anthropic/pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/pass_through/messages/transformation.py @@ -678,6 +678,21 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if transformed_context_management is not None: anthropic_messages_optional_request_params["context_management"] = transformed_context_management + # Downgrade a forced tool_choice for models flagged supports_forced_tool_use: false + # (e.g. claude-opus-5-5), matching the chat path's AnthropicConfig.map_openai_params; + # otherwise /v1/messages 400s where /chat/completions downgrades to auto (or raises + # the clean UnsupportedParamsError without drop_params). + tool_choice_param: Final = anthropic_messages_optional_request_params.get("tool_choice") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # the request params arrive as a bare dict + if isinstance(tool_choice_param, dict) and tool_choice_param.get("type") in ("any", "tool"): # pyright: ignore[reportUnknownMemberType] # the request params arrive as a bare dict + gated_tool_choice: Final = AnthropicModelInfo._apply_forced_tool_choice( # pyright: ignore[reportPrivateUsage] # the same gate the chat path applies from AnthropicConfig + model=model, + tool_choice=tool_choice_param, # pyright: ignore[reportArgumentType] # the request params arrive as a bare dict + drop_params=litellm_params.drop_params is True, + ) + anthropic_messages_optional_request_params["tool_choice"] = ( # rebind-ok: write the gated value back + gated_tool_choice + ) + ####### get required params for all anthropic messages requests ###### # Lazy %s: the f-string previously stringified the entire messages # payload on every request regardless of log level (a full scan of the diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_transformation.py b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_transformation.py index c63bfbdd301..68c99e05f11 100644 --- a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_experimental_pass_through_messages_transformation.py @@ -300,3 +300,93 @@ async def test_a_json_provider_gets_safeguards_and_the_beta_together_unless_drop assert sent.headers["anthropic-beta"] == forwarded_beta assert "safeguard_results" not in response assert response["content"][0]["text"] == "hello" + + +def _tool_choice_request( + model: str, + tool_choice: Mapping[str, object], + litellm_params: GenericLiteLLMParams | None = None, +) -> Mapping[str, object]: + return AnthropicMessagesConfig().transform_anthropic_messages_request( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # transform returns an untyped provider dict + model=model, + messages=[{"role": "user", "content": "What's the weather in Paris?"}], + anthropic_messages_optional_request_params={ + "max_tokens": 64, + "tools": [ + { + "name": "get_weather", + "description": "Get the weather for a city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + } + ], + "tool_choice": tool_choice, + }, + litellm_params=litellm_params or GenericLiteLLMParams(), + headers={}, + ) + + +def test_forced_tool_choice_downgraded_to_auto_with_drop_params( + local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +): + """Regression test for #46074: /v1/messages forwarded a forced tool_choice verbatim to + models flagged supports_forced_tool_use: false (Opus/Sonnet 5.5) and 400ed, while the + chat path downgraded it. The pass-through now applies the same gate.""" + monkeypatch.setattr(litellm, "drop_params", False) + + result: Final = _tool_choice_request( + model="claude-opus-5-5", + tool_choice={"type": "tool", "name": "get_weather"}, + litellm_params=GenericLiteLLMParams(drop_params=True), + ) + + assert result["tool_choice"] == {"type": "auto"} + + +def test_forced_tool_choice_downgrade_keeps_disable_parallel_tool_use( + local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "drop_params", False) + + result: Final = _tool_choice_request( + model="claude-opus-5-5", + tool_choice={"type": "any", "disable_parallel_tool_use": True}, + litellm_params=GenericLiteLLMParams(drop_params=True), + ) + + assert result["tool_choice"] == {"type": "auto", "disable_parallel_tool_use": True} + + +def test_forced_tool_choice_raises_clean_error_without_drop_params( + local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "drop_params", False) + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="forced tool use"): + _tool_choice_request( + model="claude-opus-5-5", + tool_choice={"type": "tool", "name": "get_weather"}, + ) + + +def test_forced_tool_choice_forwarded_on_models_that_support_it( + local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "drop_params", False) + tool_choice: Final[Mapping[str, object]] = {"type": "tool", "name": "get_weather"} + + result: Final = _tool_choice_request(model="claude-sonnet-4-6", tool_choice=tool_choice) + + assert result["tool_choice"] == tool_choice + + +def test_non_forced_tool_choice_forwarded_on_opus_5_5(local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "drop_params", False) + + result: Final = _tool_choice_request(model="claude-opus-5-5", tool_choice={"type": "auto"}) + + assert result["tool_choice"] == {"type": "auto"}