diff --git a/litellm/main.py b/litellm/main.py index eaaca52a6b8..b474c95db1a 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1090,15 +1090,15 @@ def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool: def _history_without_unanswered_tool_search_calls( - messages: Sequence[object], provider_config: BaseConfig + messages: Sequence[object], custom_llm_provider: str | None, provider_config: BaseConfig ) -> tuple[object, ...] | None: from litellm.responses.litellm_completion_transformation.tool_search import ( - chat_config_builds_anthropic_request, chat_messages_without_tool_search_calls, + chat_route_rejects_unanswered_tool_calls, unanswered_tool_search_call_ids, ) - if chat_config_builds_anthropic_request(provider_config): + if not chat_route_rejects_unanswered_tool_calls(custom_llm_provider, provider_config): return None unanswered_search_call_ids: Final = unanswered_tool_search_call_ids(messages) if not unanswered_search_call_ids: @@ -5575,7 +5575,7 @@ def completion( if provider_config is not None: translated_messages: Final = provider_config.translate_developer_role_to_system_role(messages=messages) history_for_route: Final = _history_without_unanswered_tool_search_calls( - translated_messages, provider_config + translated_messages, custom_llm_provider, provider_config ) messages = ( # rebind-ok: completion() rewrites messages step by step for the provider translated_messages if history_for_route is None else list(history_for_route) diff --git a/litellm/responses/litellm_completion_transformation/tool_search.py b/litellm/responses/litellm_completion_transformation/tool_search.py index 799bbca8081..86a5fc34714 100644 --- a/litellm/responses/litellm_completion_transformation/tool_search.py +++ b/litellm/responses/litellm_completion_transformation/tool_search.py @@ -21,6 +21,7 @@ __all__ = [ "build_tool_search_call", "chat_config_builds_anthropic_request", "chat_messages_without_tool_search_calls", + "chat_route_rejects_unanswered_tool_calls", "has_server_tool_search", "holds_tool_search_use", "is_anthropic_tool_search_chat_tool", @@ -89,6 +90,17 @@ def chat_config_builds_anthropic_request(chat_config: object) -> bool: return isinstance(chat_config, AnthropicConfig) and not isinstance(chat_config, OpenAIGPTConfig) +_PROVIDERS_REJECTING_UNANSWERED_TOOL_CALLS: Final = frozenset({LlmProviders.OPENAI.value, LlmProviders.AZURE.value}) + + +def chat_route_rejects_unanswered_tool_calls(custom_llm_provider: str | None, chat_config: object) -> bool: + from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig + + return custom_llm_provider in _PROVIDERS_REJECTING_UNANSWERED_TOOL_CALLS or isinstance( + chat_config, AmazonConverseConfig + ) + + class CompletionRoute(LiteLLMBaseModel): model_config = ConfigDict(frozen=True) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 67962857705..407b2b65ee4 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -533,6 +533,94 @@ def test_completion_drops_unanswered_claude_tool_search_calls_before_the_respons ] +def _openai_called_names(body: Mapping[str, object]) -> tuple[str, ...]: + messages: Final = body["messages"] + assert isinstance(messages, list) + return tuple(call["function"]["name"] for message in messages for call in message.get("tool_calls") or ()) + + +def _converse_called_names(body: Mapping[str, object]) -> tuple[str, ...]: + messages: Final = body["messages"] + assert isinstance(messages, list) + blocks: Final = tuple(block for message in messages for block in message["content"]) + return tuple(block["toolUse"]["name"] for block in blocks if "toolUse" in block) + + +def _gemini_called_names(body: Mapping[str, object]) -> tuple[str, ...]: + contents: Final = body["contents"] + assert isinstance(contents, list) + parts: Final = tuple(part for content in contents for part in content["parts"]) + return tuple(part["function_call"]["name"] for part in parts if "function_call" in part) + + +_OPENAI_CHAT_REPLY: Final = { + "id": "chatcmpl-turn2", + "object": "chat.completion", + "created": 1, + "model": "gpt-4o", + "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "18C"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} + + +@pytest.mark.parametrize( + ("model", "route_kwargs", "url_pattern", "reply", "called_names", "sent_names"), + [ + pytest.param( + "azure/gpt-4o", + {"api_base": "https://turn2.openai.azure.com", "api_key": "fake", "api_version": "2024-10-21"}, + r"https://turn2\.openai\.azure\.com/openai/deployments/gpt-4o/chat/completions.*", + _OPENAI_CHAT_REPLY, + _openai_called_names, + ("get_weather",), + id="azure-openai-strips", + ), + pytest.param( + "bedrock/us.anthropic.claude-opus-5-5", + {"aws_access_key_id": "fake", "aws_secret_access_key": "fake", "aws_region_name": "us-east-1"}, + r"https://bedrock-runtime\.us-east-1\.amazonaws\.com/model/.*/converse", + { + "output": {"message": {"role": "assistant", "content": [{"text": "18C"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}, + }, + _converse_called_names, + ("get_weather",), + id="bedrock-converse-strips", + ), + pytest.param( + "gemini/gemini-3.8-flash", + {"api_key": "fake"}, + r"https://generativelanguage\.googleapis\.com/.*:generateContent.*", + { + "candidates": [{"content": {"role": "model", "parts": [{"text": "18C"}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}, + }, + _gemini_called_names, + ("tool_search_tool_regex", "get_weather"), + id="gemini-keeps", + ), + ], +) +def test_completion_strips_an_unanswered_claude_tool_search_call_only_for_routes_that_reject_it( + respx_mock: respx.MockRouter, model, route_kwargs, url_pattern, reply, called_names, sent_names +): + route: Final = respx_mock.post(url__regex=url_pattern).respond(json=reply) + + litellm.completion( + model=model, + messages=[ + _USER_TURN, + {"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL, _LOADED_FUNCTION_CALL]}, + _WEATHER_RESULT, + ], + tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}], + **route_kwargs, + ) + + assert called_names(json.loads(route.calls.last.request.content)) == sent_names + + def test_completion_keeps_a_function_call_whose_id_only_looks_like_a_claude_server_tool( respx_mock: respx.MockRouter, openai_api_response ):