fix(router): preserve fusion tool choice semantics

This commit is contained in:
moe-berri 2026-09-03 17:06:29 -07:00
parent a01c230990
commit 1d21c6c2b8
2 changed files with 83 additions and 10 deletions

View file

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

View file

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