diff --git a/litellm/main.py b/litellm/main.py index 1c1f36d1d3f..eaaca52a6b8 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1076,7 +1076,6 @@ from litellm.responses.bridge_check import ( # noqa: E402 # re-export at the o ) _ANTHROPIC_ONLY_TOOL_KEYS: Final = frozenset({"input_examples", "eager_input_streaming"}) -_CHAT_HISTORY_ADAPTER: Final = TypeAdapter(Sequence[object]) def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool: @@ -1090,13 +1089,21 @@ def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool: return False -def _route_drops_tool_search_uses(messages: Sequence[object], provider_config: BaseConfig) -> bool: +def _history_without_unanswered_tool_search_calls( + messages: Sequence[object], provider_config: BaseConfig +) -> tuple[object, ...] | None: from litellm.responses.litellm_completion_transformation.tool_search import ( chat_config_builds_anthropic_request, - history_holds_tool_search_use, + chat_messages_without_tool_search_calls, + unanswered_tool_search_call_ids, ) - return not chat_config_builds_anthropic_request(provider_config) and history_holds_tool_search_use(messages) + if chat_config_builds_anthropic_request(provider_config): + return None + unanswered_search_call_ids: Final = unanswered_tool_search_call_ids(messages) + if not unanswered_search_call_ids: + return None + return chat_messages_without_tool_search_calls(messages, unanswered_search_call_ids) def _without_anthropic_only_tool_keys(tool: dict[str, object]) -> dict[str, object]: @@ -5566,16 +5573,13 @@ def completion( ) if provider_config is not None: - messages = provider_config.translate_developer_role_to_system_role(messages=messages) - chat_history: Final = _CHAT_HISTORY_ADAPTER.validate_python(messages) - if _route_drops_tool_search_uses(chat_history, provider_config): - from litellm.responses.litellm_completion_transformation.tool_search import ( - chat_messages_without_tool_search_uses, - ) - - messages = list( # rebind-ok: completion() rewrites messages step by step for the provider - chat_messages_without_tool_search_uses(chat_history) - ) + 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 + ) + 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) + ) if ( supports_system_message is not None diff --git a/litellm/responses/litellm_completion_transformation/tool_search.py b/litellm/responses/litellm_completion_transformation/tool_search.py index beffe371ddf..799bbca8081 100644 --- a/litellm/responses/litellm_completion_transformation/tool_search.py +++ b/litellm/responses/litellm_completion_transformation/tool_search.py @@ -20,15 +20,15 @@ __all__ = [ "anthropic_tool_search_chat_tool", "build_tool_search_call", "chat_config_builds_anthropic_request", - "chat_messages_without_tool_search_uses", + "chat_messages_without_tool_search_calls", "has_server_tool_search", - "history_holds_tool_search_use", "holds_tool_search_use", "is_anthropic_tool_search_chat_tool", "is_anthropic_tool_search_use", "is_server_tool_search_tool", "provider_builds_anthropic_request", "tool_search_call_ids", + "unanswered_tool_search_call_ids", "without_tool_search_calls", "without_tool_search_uses", ] @@ -177,10 +177,6 @@ def without_tool_search_uses(message: Message) -> Message | None: return message.model_copy(update={"tool_calls": list(kept) or None}) -def history_holds_tool_search_use(messages: Iterable[object]) -> bool: - return any(_is_assistant_message(message) and holds_tool_search_use(message) for message in messages) - - def _is_assistant_message(message: object) -> bool: match message: case {"role": "assistant"}: @@ -191,33 +187,48 @@ def _is_assistant_message(message: object) -> bool: return _session_message_fields(message).role == "assistant" -def _assistant_mapping_without_tool_search_uses(message: Mapping[str, object]) -> Mapping[str, object] | None: - kept_calls: Final = without_tool_search_calls(message) - if not kept_calls and not message.get("content"): +def _tool_result_ids(messages: Iterable[object]) -> frozenset[str]: + return frozenset( + tool_call_id + for message in messages + if (tool_call_id := _session_message_fields(message).tool_call_id) is not None + ) + + +def unanswered_tool_search_call_ids(messages: Sequence[object]) -> frozenset[str]: + search_call_ids: Final = tool_search_call_ids(message for message in messages if _is_assistant_message(message)) + if not search_call_ids: + return search_call_ids + return search_call_ids - _tool_result_ids(messages) + + +def _assistant_mapping_with_tool_calls( + message: Mapping[str, object], tool_calls: tuple[object, ...] +) -> Mapping[str, object] | None: + if not tool_calls and not message.get("content"): return None without_calls: Final = {key: value for key, value in message.items() if key != "tool_calls"} - return {**without_calls, "tool_calls": list(kept_calls)} if kept_calls else without_calls + return {**without_calls, "tool_calls": list(tool_calls)} if tool_calls else without_calls -def _without_tool_search_use(message: object, search_call_ids: Set[str]) -> object | None: - if answers_tool_search(message, search_call_ids): - return None - if not holds_tool_search_use(message): +def _without_tool_search_calls(message: object, call_ids: Set[str]) -> object | None: + tool_calls: Final = _assistant_tool_calls(message) + kept_calls: Final = tuple(tool_call for tool_call in tool_calls if _tool_search_call_id(tool_call) not in call_ids) + if len(kept_calls) == len(tool_calls): return message match message: case Message(): - return without_tool_search_uses(message) + if not kept_calls and not message.content: + return None + return message.model_copy(update={"tool_calls": list(kept_calls) or None}) case Mapping(): - return _assistant_mapping_without_tool_search_uses(_MESSAGE_FIELDS_ADAPTER.validate_python(message)) + return _assistant_mapping_with_tool_calls(_MESSAGE_FIELDS_ADAPTER.validate_python(message), kept_calls) case _: return message -def chat_messages_without_tool_search_uses(messages: Sequence[object]) -> tuple[object, ...]: - search_call_ids: Final = tool_search_call_ids(messages) - return tuple( - kept for message in messages if (kept := _without_tool_search_use(message, search_call_ids)) is not None - ) +def chat_messages_without_tool_search_calls(messages: Sequence[object], call_ids: Set[str]) -> tuple[object, ...]: + return tuple(kept for message in messages if (kept := _without_tool_search_calls(message, call_ids)) is not None) def _parsed_arguments(arguments_json: str) -> Mapping[str, object] | None: diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 8bef1cbe20b..67962857705 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -427,6 +427,7 @@ _LOADED_FUNCTION_CALL: Final = { } _USER_TURN: Final = {"role": "user", "content": "What is the weather in Paris?"} _WEATHER_RESULT: Final = {"role": "tool", "tool_call_id": "toolu_01weather", "content": "18C"} +_SEARCH_RESULT: Final = {"role": "tool", "tool_call_id": "srvtoolu_01search", "content": "searched"} @pytest.mark.parametrize( @@ -449,16 +450,26 @@ _WEATHER_RESULT: Final = {"role": "tool", "tool_call_id": "toolu_01weather", "co id="sdk-message-object", ), pytest.param( - [ - {"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL]}, - {"role": "tool", "tool_call_id": "srvtoolu_01search", "content": "searched"}, - ], + [{"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL]}], [], - id="answered-search-alone", + id="unanswered-search-alone", + ), + pytest.param( + [ + {"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL, _LOADED_FUNCTION_CALL]}, + _SEARCH_RESULT, + _WEATHER_RESULT, + ], + [ + {"role": "assistant", "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL, _LOADED_FUNCTION_CALL]}, + _SEARCH_RESULT, + _WEATHER_RESULT, + ], + id="answered-search-kept", ), ], ) -def test_completion_drops_claude_tool_search_calls_from_history_sent_to_openai( +def test_completion_drops_unanswered_claude_tool_search_calls_from_history_sent_to_openai( respx_mock: respx.MockRouter, openai_api_response, claude_turn, sent_after_user ): api_base: Final = "http://localhost:12346/v1" @@ -480,7 +491,7 @@ def test_completion_drops_claude_tool_search_calls_from_history_sent_to_openai( ] == [_USER_TURN, *sent_after_user, {"role": "user", "content": "And tomorrow?"}] -def test_completion_drops_claude_tool_search_calls_before_the_responses_bridge_sends_history_to_openai( +def test_completion_drops_unanswered_claude_tool_search_calls_before_the_responses_bridge_sends_history_to_openai( respx_mock: respx.MockRouter, ): route: Final = respx_mock.post("https://api.openai.com/v1/responses").respond( @@ -510,7 +521,6 @@ def test_completion_drops_claude_tool_search_calls_before_the_responses_bridge_s messages=[ _USER_TURN, {"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL, _LOADED_FUNCTION_CALL]}, - {"role": "tool", "tool_call_id": "srvtoolu_01search", "content": "searched"}, _WEATHER_RESULT, ], tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}],