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>
This commit is contained in:
mateo 2026-09-01 18:46:03 +00:00
parent 0610331aa1
commit 3c9ce458fd
6 changed files with 159 additions and 1 deletions

View file

@ -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)):

View file

@ -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("@")

View file

@ -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,

View file

@ -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,

View file

@ -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"}

View file

@ -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