diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index 5379dcd6d68..d2ec4b5b6ce 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -1068,12 +1068,7 @@ class LiteLLMAnthropicMessagesAdapter: unknown backend, because that provider declares this param and forwards it to a proxy that resolves the real target itself, where a derived cache key has no such guarantee. """ - if not model or not custom_llm_provider: - return False - supported_params: Final = litellm.get_supported_openai_params( - model=model, custom_llm_provider=custom_llm_provider - ) - return "reasoning_effort" in (supported_params or ()) + return LiteLLMAnthropicMessagesAdapter._target_declares("reasoning_effort", model, custom_llm_provider) def _translate_metadata_to_openai( self, @@ -1102,16 +1097,43 @@ class LiteLLMAnthropicMessagesAdapter: self, anthropic_message_request: AnthropicMessagesRequest, new_kwargs: ChatCompletionRequest, + custom_llm_provider: str | None = None, ) -> None: - """Translate Anthropic tool_choice to OpenAI format.""" + """Translate Anthropic tool_choice to OpenAI format. + + ``disable_parallel_tool_use`` becomes ``parallel_tool_calls=False`` only for a target that + declares the param, so a provider without it keeps answering instead of rejecting the + request the way it already does an OpenAI-shaped ``parallel_tool_calls``. + """ if "tool_choice" not in anthropic_message_request: return tool_choice: Final = anthropic_message_request["tool_choice"] if not tool_choice: return - new_kwargs["tool_choice"] = self.translate_anthropic_tool_choice_to_openai( - tool_choice=cast(AnthropicMessagesToolChoice, tool_choice) + anthropic_tool_choice: Final = cast(AnthropicMessagesToolChoice, tool_choice) + new_kwargs["tool_choice"] = self.translate_anthropic_tool_choice_to_openai(tool_choice=anthropic_tool_choice) + if anthropic_tool_choice.get("disable_parallel_tool_use") is True and self._target_declares_parallel_tool_calls( + anthropic_message_request["model"], custom_llm_provider + ): + new_kwargs["parallel_tool_calls"] = False # rebind-ok: out-param store like tool_choice above + + @staticmethod + def _target_declares_parallel_tool_calls(model: str | None, custom_llm_provider: str | None) -> bool: + """Whether the target declares ``parallel_tool_calls`` among its supported params. + + Without a resolved provider the flag stays behind, which is what this bridge sent before + it carried one at all, for the same reasons as ``_target_declares_reasoning_effort``. + """ + return LiteLLMAnthropicMessagesAdapter._target_declares("parallel_tool_calls", model, custom_llm_provider) + + @staticmethod + def _target_declares(param: str, model: str | None, custom_llm_provider: str | None) -> bool: + if not model or not custom_llm_provider: + return False + supported_params: Final = litellm.get_supported_openai_params( + model=model, custom_llm_provider=custom_llm_provider ) + return param in (supported_params or ()) def _translate_stop_sequences_to_openai( self, @@ -1334,6 +1356,7 @@ class LiteLLMAnthropicMessagesAdapter: self._translate_tool_choice_to_openai( anthropic_message_request=anthropic_message_request, new_kwargs=new_kwargs, + custom_llm_provider=custom_llm_provider, ) ## CONVERT TOOLS tool_name_mapping = self._translate_tools_to_openai( diff --git a/litellm/llms/anthropic/messages/responses_adapters/handler.py b/litellm/llms/anthropic/messages/responses_adapters/handler.py index 85f042f621c..7e37f9eefa7 100644 --- a/litellm/llms/anthropic/messages/responses_adapters/handler.py +++ b/litellm/llms/anthropic/messages/responses_adapters/handler.py @@ -173,12 +173,20 @@ def _forwarded_kwargs(extra_kwargs: Mapping[str, object] | None) -> Mapping[str, def _provider_returns_encrypted_reasoning(model: str, custom_llm_provider: object) -> bool: + return _responses_config_declares("include", model, custom_llm_provider) + + +def _provider_declares_parallel_tool_calls(model: str, custom_llm_provider: object) -> bool: + return _responses_config_declares("parallel_tool_calls", model, custom_llm_provider) + + +def _responses_config_declares(param: str, model: str, custom_llm_provider: object) -> bool: provider: Final = ( custom_llm_provider if isinstance(custom_llm_provider, str) else litellm.get_llm_provider(model=model)[1] ) provider_model: Final = local_model_name(model, provider) responses_config: Final = ProviderConfigManager.get_provider_responses_api_config(provider, provider_model) - return responses_config is not None and "include" in responses_config.get_supported_openai_params(provider_model) + return responses_config is not None and param in responses_config.get_supported_openai_params(provider_model) def _reads_prompt_cache_breakpoint(model: str, forwarded_kwargs: Mapping[str, object]) -> bool: @@ -333,6 +341,9 @@ def _build_responses_kwargs( ), auto_summary_level=auto_reasoning_summary(model, forwarded_kwargs.get("custom_llm_provider")), drop_params=drops_unsupported_params(forwarded_kwargs.get("drop_params")), + declares_parallel_tool_calls=_provider_declares_parallel_tool_calls( + model, forwarded_kwargs.get("custom_llm_provider") + ), ) # Normalize reasoning effort based on model capabilities diff --git a/litellm/llms/anthropic/messages/responses_adapters/transformation.py b/litellm/llms/anthropic/messages/responses_adapters/transformation.py index 110e9fc40ff..80ad0f0754b 100644 --- a/litellm/llms/anthropic/messages/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/messages/responses_adapters/transformation.py @@ -591,11 +591,15 @@ class LiteLLMAnthropicToResponsesAPIAdapter: auto_summary_level: str = "detailed", *, drop_params: bool = True, + declares_parallel_tool_calls: bool = True, ) -> dict[str, Any]: """ Translate a full Anthropic /v1/messages request dict to litellm.responses() / litellm.aresponses() kwargs. + ``declares_parallel_tool_calls`` says whether the provider's Responses API accepts + ``parallel_tool_calls``; ``tool_choice.disable_parallel_tool_use`` maps onto it only then. + ``include_encrypted_reasoning`` asks the provider for ``reasoning.encrypted_content`` on every call, so a reasoning model's items can be replayed intact next turn even when the client sent no ``thinking`` block; pass False for a provider whose @@ -660,9 +664,10 @@ class LiteLLMAnthropicToResponsesAPIAdapter: # tool_choice tool_choice: Final = anthropic_request.get("tool_choice") if tool_choice: - responses_kwargs["tool_choice"] = self.translate_tool_choice_to_responses_api( - cast(AnthropicMessagesToolChoice, tool_choice) - ) + anthropic_tool_choice: Final = cast(AnthropicMessagesToolChoice, tool_choice) + responses_kwargs["tool_choice"] = self.translate_tool_choice_to_responses_api(anthropic_tool_choice) + if anthropic_tool_choice.get("disable_parallel_tool_use") is True and declares_parallel_tool_calls: + responses_kwargs["parallel_tool_calls"] = False # thinking -> reasoning thinking: Final = anthropic_request.get("thinking") diff --git a/tests/integration/translation/messages/params/__init__.py b/tests/integration/translation/messages/params/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/translation/messages/params/test_messages_params_tool_choice.py b/tests/integration/translation/messages/params/test_messages_params_tool_choice.py new file mode 100644 index 00000000000..5a2781f373a --- /dev/null +++ b/tests/integration/translation/messages/params/test_messages_params_tool_choice.py @@ -0,0 +1,97 @@ +from dataclasses import replace +from typing import Final + +import pytest +from integration._support.client import Gateway +from integration._support.provider import SharedProvider +from integration.translation.case import TranslationTestCase +from integration.translation.messages.bases.azure import GPT_6_LUNA_TEST_CASE as AZURE_GPT_6_LUNA_TEST_CASE +from integration.translation.messages.bases.bedrock_converse import CLAUDE_SONNET_4_6_TEST_CASE +from integration.translation.messages.bases.openai import GPT_5_4_TEST_CASE as OPENAI_GPT_5_4_TEST_CASE +from integration.translation.runner import assert_translation +from pydantic import JsonValue + +LOOKUP_SCHEMA: Final[dict[str, JsonValue]] = { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], +} +ANTHROPIC_TOOLS: Final[list[JsonValue]] = [ + {"name": "lookup_weather", "description": "Look up the weather for a city.", "input_schema": LOOKUP_SCHEMA} +] +RESPONSES_TOOLS: Final[list[JsonValue]] = [ + { + "type": "function", + "name": "lookup_weather", + "strict": False, + "description": "Look up the weather for a city.", + "parameters": LOOKUP_SCHEMA, + } +] +BEDROCK_TOOL_CONFIG: Final[dict[str, JsonValue]] = { + "tools": [ + { + "toolSpec": { + "name": "lookup_weather", + "description": "Look up the weather for a city.", + "inputSchema": {"json": LOOKUP_SCHEMA}, + } + } + ], + "toolChoice": {"auto": {}}, +} +DISABLE_PARALLEL_TOOL_USE: Final[dict[str, JsonValue]] = {"type": "auto", "disable_parallel_tool_use": True} +SCENARIO: Final = "tool_choice_disable_parallel_tool_use" + +OPENAI_GPT_5_4_DISABLE_PARALLEL_TEST_CASE: Final = replace( + OPENAI_GPT_5_4_TEST_CASE, + scenario=SCENARIO, + litellm_request={ + **OPENAI_GPT_5_4_TEST_CASE.litellm_request, + "tools": ANTHROPIC_TOOLS, + "tool_choice": DISABLE_PARALLEL_TOOL_USE, + }, + expected_provider_request={ + **OPENAI_GPT_5_4_TEST_CASE.expected_provider_request, + "tools": RESPONSES_TOOLS, + "tool_choice": "auto", + "parallel_tool_calls": False, + }, +) + +AZURE_GPT_6_LUNA_DISABLE_PARALLEL_TEST_CASE: Final = replace( + AZURE_GPT_6_LUNA_TEST_CASE, + scenario=SCENARIO, + litellm_request={**AZURE_GPT_6_LUNA_TEST_CASE.litellm_request, "tool_choice": DISABLE_PARALLEL_TOOL_USE}, + expected_provider_request={ + **AZURE_GPT_6_LUNA_TEST_CASE.expected_provider_request, + "tool_choice": "auto", + "parallel_tool_calls": False, + }, +) + +CLAUDE_SONNET_4_6_DISABLE_PARALLEL_TEST_CASE: Final = replace( + CLAUDE_SONNET_4_6_TEST_CASE, + scenario=SCENARIO, + litellm_request={ + **CLAUDE_SONNET_4_6_TEST_CASE.litellm_request, + "tools": ANTHROPIC_TOOLS, + "tool_choice": DISABLE_PARALLEL_TOOL_USE, + }, + expected_provider_request={ + **CLAUDE_SONNET_4_6_TEST_CASE.expected_provider_request, + "toolConfig": BEDROCK_TOOL_CONFIG, + "additionalModelRequestFields": {"tool_choice": {"disable_parallel_tool_use": True}}, + }, +) + +CASES: Final = ( + OPENAI_GPT_5_4_DISABLE_PARALLEL_TEST_CASE, + AZURE_GPT_6_LUNA_DISABLE_PARALLEL_TEST_CASE, + CLAUDE_SONNET_4_6_DISABLE_PARALLEL_TEST_CASE, +) + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case.id) +def test_messages_params_tool_choice(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None: + assert_translation(case, gateway, provider) diff --git a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py index fb4f19a862c..c26f60f2d2b 100644 --- a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py @@ -5525,3 +5525,47 @@ async def test_chat_bridge_tool_result_text_block_without_text_reaches_the_model ], } ] + + +@pytest.mark.parametrize( + ("tool_choice", "model", "custom_llm_provider", "expected_parallel_tool_calls"), + [ + ({"type": "auto", "disable_parallel_tool_use": True}, "gpt-5.4", "openai", False), + ({"type": "any", "disable_parallel_tool_use": True}, "gpt-5.4", "openai", False), + ({"type": "auto", "disable_parallel_tool_use": True}, "openai/gpt-oss-120b", "groq", False), + ({"type": "auto", "disable_parallel_tool_use": False}, "gpt-5.4", "openai", None), + ({"type": "auto"}, "gpt-5.4", "openai", None), + ({"type": "auto", "disable_parallel_tool_use": True}, "command-a-03-2025", "cohere_chat", None), + ({"type": "auto", "disable_parallel_tool_use": True}, "gpt-5.4", None, None), + ], + ids=[ + "openai-auto-disabled", + "openai-any-disabled", + "groq-auto-disabled", + "openai-auto-enabled", + "openai-auto-omitted", + "cohere-declares-no-parallel-tool-calls-stays-behind", + "no-provider-stays-behind", + ], +) +def test_translate_anthropic_to_openai_disable_parallel_tool_use_sets_parallel_tool_calls( + tool_choice: dict[str, object], + model: str, + custom_llm_provider: str | None, + expected_parallel_tool_calls: bool | None, +) -> None: + from litellm.types.llms.anthropic import AnthropicMessagesRequest + + request: Final = AnthropicMessagesRequest( + model=model, + max_tokens=64, + messages=[{"role": "user", "content": "What is the weather in Paris?"}], + tools=[{"name": "lookup_weather", "input_schema": {"type": "object", "properties": {}}}], + tool_choice=tool_choice, + ) + + translated, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( + request, custom_llm_provider=custom_llm_provider + ) + + assert translated.get("parallel_tool_calls") == expected_parallel_tool_calls diff --git a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_handler.py b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_handler.py index c06c9d721ac..ed32a53b823 100644 --- a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_handler.py +++ b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_handler.py @@ -52,6 +52,30 @@ def test_build_responses_kwargs_never_asks_the_nested_call_to_replay_a_stream(): assert responses_kwargs["custom_llm_provider"] == "azure" +@pytest.mark.parametrize( + ("model", "custom_llm_provider", "expected_parallel_tool_calls"), + [ + ("openai/gpt-5.6-luna", "openai", False), + ("azure/gpt-5.6-luna", "azure", False), + ("perplexity/sonar", "perplexity", None), + ], + ids=["openai-declares", "azure-declares", "perplexity-declares-no-parallel-tool-calls-stays-behind"], +) +def test_build_responses_kwargs_maps_disable_parallel_tool_use_only_where_the_provider_declares_it( + model: str, custom_llm_provider: str, expected_parallel_tool_calls: bool | None +): + responses_kwargs = _build_responses_kwargs( + max_tokens=1024, + messages=MESSAGES, + model=model, + tools=[{"name": "do_thing", "input_schema": {"type": "object", "properties": {}}}], + tool_choice={"type": "auto", "disable_parallel_tool_use": True}, + extra_kwargs={"custom_llm_provider": custom_llm_provider}, + ) + assert responses_kwargs.get("parallel_tool_calls") == expected_parallel_tool_calls + assert responses_kwargs["tool_choice"] == "auto" + + def test_build_responses_kwargs_derives_prompt_cache_key_from_claude_code_session_id(): responses_kwargs = _build_responses_kwargs( max_tokens=1024, diff --git a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py index dbb8c925fa0..d547926a840 100644 --- a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py @@ -1180,6 +1180,30 @@ class TestTranslateRequestBroaderCoverage: kwargs = _ADAPTER.translate_request(req) assert kwargs["tool_choice"] == {"type": "function", "name": "do_thing"} + @pytest.mark.parametrize( + ("tool_choice", "expected_parallel_tool_calls"), + [ + ({"type": "auto", "disable_parallel_tool_use": True}, False), + ({"type": "any", "disable_parallel_tool_use": True}, False), + ({"type": "auto", "disable_parallel_tool_use": False}, None), + ({"type": "auto"}, None), + ], + ) + def test_disable_parallel_tool_use_sets_parallel_tool_calls( + self, tool_choice: dict[str, object], expected_parallel_tool_calls: bool | None + ): + req = _make_request(tools=[{"name": "do_thing"}], tool_choice=tool_choice) + kwargs = _ADAPTER.translate_request(req) + assert kwargs.get("parallel_tool_calls") == expected_parallel_tool_calls + + def test_disable_parallel_tool_use_stays_behind_when_provider_lacks_parallel_tool_calls(self): + req = _make_request( + tools=[{"name": "do_thing"}], tool_choice={"type": "auto", "disable_parallel_tool_use": True} + ) + kwargs = _ADAPTER.translate_request(req, declares_parallel_tool_calls=False) + assert "parallel_tool_calls" not in kwargs + assert kwargs["tool_choice"] == "auto" + def test_thinking_translated_to_reasoning(self): req = _make_request(thinking={"type": "enabled", "budget_tokens": 12000}) kwargs = _ADAPTER.translate_request(req)