From 3c9ce458fd5ad27c95851b4e4c4462b5f7f19035 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 1 Sep 2026 18:46:03 +0000 Subject: [PATCH] feat(anthropic): gate forced tool_choice for Fable 5.1 behind supports_forced_tool_use Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/anthropic/chat/transformation.py | 4 +- litellm/llms/anthropic/common_utils.py | 34 ++++++ ...odel_prices_and_context_window_backup.json | 8 ++ model_prices_and_context_window.json | 8 ++ .../test_anthropic_chat_transformation.py | 105 ++++++++++++++++++ .../test_claude_fable_5_config.py | 1 + 6 files changed, 159 insertions(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index e1387a9068c..91f1fb5bd6f 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1466,7 +1466,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) if _tool_choice is not None: - optional_params["tool_choice"] = _tool_choice + optional_params["tool_choice"] = AnthropicConfig._apply_forced_tool_choice( + model=model, tool_choice=_tool_choice, drop_params=drop_params + ) elif param == "stream" and value is True: optional_params["stream"] = value elif param == "stop" and (isinstance(value, str) or isinstance(value, list)): diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 9871001bf66..0ca0e07d4d0 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -28,10 +28,15 @@ from litellm.types.llms.anthropic import ( ANTHROPIC_OAUTH_TOKEN_PREFIX, AllAnthropicToolsValues, AnthropicMcpServerTool, + AnthropicMessagesToolChoice, ) from litellm.types.llms.openai import AllMessageValues from litellm.types.proxy.model_listing import ModelInfoResponse +DROP_FORCED_TOOL_CHOICE_WARNING: Final = ( + "Downgrading forced tool_choice to 'auto' for model=%s (drop_params=True): this model rejects tool_choice type " + "'any'/'tool' with a 400 because thinking is always on and a forced call would skip it." +) DROP_DISABLED_THINKING_WARNING: Final = ( "Dropping `thinking={'type': 'disabled'}` for model=%s: thinking is always on for this model and cannot be " "disabled (the alternative is a provider 400). The model will still think adaptively, its response can contain " @@ -320,6 +325,35 @@ class AnthropicModelInfo(BaseLLMModelInfo): status_code=400, ) + @staticmethod + def _apply_forced_tool_choice( + model: str, + tool_choice: AnthropicMessagesToolChoice, + drop_params: bool, + ) -> AnthropicMessagesToolChoice: + """Forward ``tool_choice`` unless the model map flags the model with + ``supports_forced_tool_use: false`` (Fable 5.1 / Mythos 5.1 400 on + ``any``/``tool``), in which case downgrade to ``auto`` (with + drop_params) or raise a clean client-side 400.""" + if tool_choice["type"] not in ("any", "tool"): + return tool_choice + if AnthropicModelInfo._get_model_capability(model, "supports_forced_tool_use") is not False: + return tool_choice + if not (litellm.drop_params or drop_params): + raise litellm.utils.UnsupportedParamsError( + message=( + f"{model} does not support forced tool use (tool_choice='required' or a named tool). " + "Use tool_choice='auto' and tell the model in the prompt when to call the tool, or set " + "`litellm.drop_params = True` to downgrade to 'auto' automatically." + ), + status_code=400, + ) + litellm.verbose_logger.warning(DROP_FORCED_TOOL_CHOICE_WARNING, model) + disable_parallel: Final = tool_choice.get("disable_parallel_tool_use") + if disable_parallel is None: + return AnthropicMessagesToolChoice(type="auto") + return AnthropicMessagesToolChoice(type="auto", disable_parallel_tool_use=disable_parallel) + @staticmethod def _strip_version_suffix(model: str) -> str: at: Final = model.rfind("@") diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index cd39d080da7..65c2c5dd5d2 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1472,6 +1472,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -1546,6 +1547,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -1620,6 +1622,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -1694,6 +1697,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -3248,6 +3252,7 @@ "thinking_always_on": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -13231,6 +13236,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -42204,6 +42210,7 @@ "thinking_always_on": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -42273,6 +42280,7 @@ "thinking_always_on": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cd39d080da7..65c2c5dd5d2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1472,6 +1472,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -1546,6 +1547,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -1620,6 +1622,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -1694,6 +1697,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -3248,6 +3252,7 @@ "thinking_always_on": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -13231,6 +13236,7 @@ "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -42204,6 +42210,7 @@ "thinking_always_on": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -42273,6 +42280,7 @@ "thinking_always_on": true, "supports_assistant_prefill": false, "supports_computer_use": true, + "supports_forced_tool_use": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 435cee55de3..b56e6c41f23 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -6176,6 +6176,7 @@ def test_is_anthropic_usage_object_rejects_responses_api_usage(): [ # always-on-thinking models reject thinking.type=disabled with a 400 ("claude-fable-5", True), + ("claude-fable-5-1", True), ("claude-mythos-5", True), # unmapped future family member -> claude-always-on-thinking fallback rule ("claude-fable-6-1", True), @@ -6207,3 +6208,107 @@ def test_disabled_thinking_omitted_only_for_always_on_models( assert "thinking" not in request else: assert request["thinking"] == {"type": "disabled"} + + +@pytest.mark.parametrize( + "tool_choice", + ["required", {"type": "required"}, {"type": "function", "function": {"name": "get_weather"}}], +) +def test_forced_tool_choice_raises_clean_error_on_fable_5_1_without_drop_params( + local_model_cost_map, tool_choice, monkeypatch +): + """Fable 5.1 400s on tool_choice type any/tool (thinking is always on and a + forced call would skip it); without drop_params the caller gets a clean + client-side 400 that explains the workaround, not a provider error.""" + monkeypatch.setattr(litellm, "drop_params", False) + config = AnthropicConfig() + + with pytest.raises(litellm.utils.UnsupportedParamsError, match="forced tool use"): + config.map_openai_params( + non_default_params={"tool_choice": tool_choice}, + optional_params={}, + model="claude-fable-5-1", + drop_params=False, + ) + + +@pytest.mark.parametrize( + "tool_choice", + ["required", {"type": "required"}, {"type": "function", "function": {"name": "get_weather"}}], +) +def test_forced_tool_choice_downgraded_to_auto_on_fable_5_1_with_drop_params( + local_model_cost_map, tool_choice +): + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": tool_choice}, + optional_params={}, + model="claude-fable-5-1", + drop_params=True, + ) + + assert result["tool_choice"] == {"type": "auto"} + + +def test_forced_tool_choice_downgrade_keeps_parallel_tool_calls_flag(local_model_cost_map): + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": "required", "parallel_tool_calls": False}, + optional_params={}, + model="claude-fable-5-1", + drop_params=True, + ) + + assert result["tool_choice"] == {"type": "auto", "disable_parallel_tool_use": True} + + +@pytest.mark.parametrize("tool_choice, expected_type", [("auto", "auto"), ("none", "none")]) +def test_unforced_tool_choice_forwarded_on_fable_5_1( + local_model_cost_map, tool_choice, expected_type, monkeypatch +): + monkeypatch.setattr(litellm, "drop_params", False) + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": tool_choice}, + optional_params={}, + model="claude-fable-5-1", + drop_params=False, + ) + + assert result["tool_choice"]["type"] == expected_type + + +@pytest.mark.parametrize("model", ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]) +def test_forced_tool_choice_forwarded_on_models_that_support_it( + local_model_cost_map, model, monkeypatch +): + monkeypatch.setattr(litellm, "drop_params", False) + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": "required"}, + optional_params={}, + model=model, + drop_params=True, + ) + + assert result["tool_choice"] == {"type": "any"} + + +def test_forced_tool_choice_gating_driven_by_model_map_flag(local_model_cost_map, monkeypatch): + """The gate must read ``supports_forced_tool_use`` from the model map, not + the model name: a flagged entry gates a model whose name says nothing.""" + monkeypatch.setitem(litellm.model_cost, "claude-zeta-9", {"supports_forced_tool_use": False}) + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"tool_choice": "required"}, + optional_params={}, + model="claude-zeta-9", + drop_params=True, + ) + + assert result["tool_choice"] == {"type": "auto"} diff --git a/tests/test_litellm/test_claude_fable_5_config.py b/tests/test_litellm/test_claude_fable_5_config.py index 90ded46e2a4..3ecf94602d9 100644 --- a/tests/test_litellm/test_claude_fable_5_config.py +++ b/tests/test_litellm/test_claude_fable_5_config.py @@ -251,6 +251,7 @@ def test_fable_5_1_model_pricing_and_capabilities(): assert "output_cost_per_token_above_200k_tokens" not in info assert info["supports_assistant_prefill"] is False + assert info["supports_forced_tool_use"] is False assert info["supports_function_calling"] is True assert info["supports_prompt_caching"] is True assert info["supports_reasoning"] is True