From 1d21c6c2b8bba405f6aa9732e79376f1fc73bd50 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 3 Sep 2026 17:06:29 -0700 Subject: [PATCH] fix(router): preserve fusion tool choice semantics --- litellm/fusion_router.py | 23 +++++--- tests/test_litellm/test_fusion_router.py | 70 +++++++++++++++++++++++- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/litellm/fusion_router.py b/litellm/fusion_router.py index 4390f339bce..a2cb359e28f 100644 --- a/litellm/fusion_router.py +++ b/litellm/fusion_router.py @@ -982,8 +982,9 @@ class FusionRouter: kwargs: Final = _outer_kwargs(request_kwargs) kwargs.pop("litellm_metadata", None) kwargs["metadata"] = _fusion_call_metadata(request_kwargs, FUSION_INITIAL_CALL_ORIGIN) + client_tools: Final = _client_tools(request_kwargs.get("tools")) kwargs["tools"] = [ # mutable-ok: local provider payload - *_client_tools(request_kwargs.get("tools")), + *client_tools, _fusion_tool(), ] # mutable-ok: local provider payload if self.config.invocation == "required": @@ -993,6 +994,13 @@ class FusionRouter: "name": FUSION_TOOL_NAME }, # mutable-ok: local provider payload } # mutable-ok: local provider payload + elif kwargs.get("tool_choice") == "none": + # The caller's tool policy applies to executable client tools, not + # to Fusion's private reasoning step. Let the outer model deliberate + # while keeping client tools unavailable until the final call, where + # the original `tool_choice="none"` is preserved. + kwargs["tools"] = [_fusion_tool()] # mutable-ok: local provider payload + kwargs["tool_choice"] = "auto" elif kwargs.get("tool_choice") is None: kwargs["tool_choice"] = "auto" response: Final = await self._completion( @@ -1198,14 +1206,11 @@ class FusionRouter: final_kwargs: Final = _outer_kwargs(request_kwargs) final_kwargs.pop("litellm_logging_obj", None) final_kwargs.pop("litellm_call_id", None) - # `required` has already been satisfied by the private Fusion call. Do - # not force the continuation into another tool call (or an impossible - # tool call when the caller supplied no client tools). - if request_kwargs.get("tool_choice") == "required": - if _client_tools(request_kwargs.get("tools")): - final_kwargs["tool_choice"] = "auto" - else: - final_kwargs.pop("tool_choice", None) + # A private Fusion call does not satisfy the caller's requirement for + # an executable tool call. Preserve `required` when client tools exist; + # only remove the impossible no-tools combination. + if request_kwargs.get("tool_choice") == "required" and not _client_tools(request_kwargs.get("tools")): + final_kwargs.pop("tool_choice", None) final_metadata: Final = _fusion_call_metadata(request_kwargs, FUSION_CONTINUATION_CALL_ORIGIN) final_kwargs.pop("litellm_metadata", None) final_kwargs["metadata"] = final_metadata diff --git a/tests/test_litellm/test_fusion_router.py b/tests/test_litellm/test_fusion_router.py index 0480b89436b..20402ec5633 100644 --- a/tests/test_litellm/test_fusion_router.py +++ b/tests/test_litellm/test_fusion_router.py @@ -168,6 +168,74 @@ async def test_outer_client_tool_call_is_returned_without_running_panel_or_secon assert [call["model"] for call in completion.calls] == ["outer"] +@pytest.mark.asyncio +async def test_tool_choice_none_allows_private_deliberation_but_never_client_tools() -> None: + completion = RecordingCompletion( + { + "outer": [_fusion_call(), _response("Final answer without a tool call")], + "panel-a": [_response("Panel A")], + "panel-b": [_response("Panel B")], + "analyst": [_response(_analysis())], + } + ) + client_tool = { + "type": "function", + "function": {"name": "send_email", "parameters": {"type": "object"}}, + } + + response = await _router(completion).acompletion( + messages=[{"role": "user", "content": "Research this but do not send anything"}], + stream=False, + request_kwargs={"tools": [client_tool], "tool_choice": "none"}, + ) + + assert isinstance(response, ModelResponse) + assert response.choices[0].message.content == "Final answer without a tool call" + assert [call["model"] for call in completion.calls] == [ + "outer", + "panel-a", + "panel-b", + "analyst", + "outer", + ] + initial = completion.calls[0] + assert [tool["function"]["name"] for tool in initial["tools"]] == [FUSION_TOOL_NAME] + assert initial["tool_choice"] == "auto" + final = completion.calls[-1] + assert final["tools"] == [client_tool] + assert final["tool_choice"] == "none" + + +@pytest.mark.asyncio +async def test_named_client_tool_choice_is_preserved_and_bypasses_private_deliberation() -> None: + client_call = { + "id": "email-1", + "type": "function", + "function": {"name": "send_email", "arguments": '{"to":"user@example.com"}'}, + } + completion = RecordingCompletion({"outer": [_response(None, [client_call])]}) + client_tool = { + "type": "function", + "function": {"name": "send_email", "parameters": {"type": "object"}}, + } + named_choice = {"type": "function", "function": {"name": "send_email"}} + + response = await _router(completion).acompletion( + messages=[{"role": "user", "content": "Send the update"}], + stream=False, + request_kwargs={"tools": [client_tool], "tool_choice": named_choice}, + ) + + assert isinstance(response, ModelResponse) + assert response.choices[0].message.tool_calls[0].function.name == "send_email" + assert [call["model"] for call in completion.calls] == ["outer"] + assert completion.calls[0]["tool_choice"] == named_choice + assert [tool["function"]["name"] for tool in completion.calls[0]["tools"]] == [ + "send_email", + FUSION_TOOL_NAME, + ] + + @pytest.mark.asyncio async def test_mixed_fusion_and_client_tool_calls_return_only_executable_client_calls() -> None: client_call = { @@ -321,7 +389,7 @@ async def test_forced_fusion_runs_parallel_panel_then_analyst_then_outer() -> No assert analyst["metadata"]["user_api_key_budget_reservation"] is reservation final = completion.calls[4] assert final["tools"] == [client_tool] - assert final["tool_choice"] == "auto" + assert final["tool_choice"] == "required" continuation = final["messages"] assert continuation[0] == messages[0] assert continuation[1]["role"] == "developer"