From 9665a15bf6fe8b78422c97ed34f5a18f4cd993be Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:06:18 -0700 Subject: [PATCH] fix(websearch): leave a follow-up model alone when its prefix already routes to the request provider --- litellm/litellm_core_utils/core_helpers.py | 11 +++++ .../test_websearch_responses.py | 11 ++--- .../test_chat_completion_agentic_loop.py | 43 +++++++++++++++++++ .../litellm_core_utils/test_core_helpers.py | 29 +++++++------ 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 99209dcab45..62016b37edb 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -37,9 +37,20 @@ def qualify_provider_stripped_model(model: str, custom_llm_provider: str) -> str """Put the provider prefix back on a provider-stripped model.""" if not custom_llm_provider or model.startswith(f"{custom_llm_provider}/"): return model + if _already_routes_to(model, custom_llm_provider): + return model return f"{custom_llm_provider}/{model}" +def _already_routes_to(model: str, custom_llm_provider: str) -> bool: + from litellm.litellm_core_utils.get_llm_provider_logic import inferred_provider + + leading_segment: Final = model.split("/", 1)[0] + if leading_segment == model or not names_a_provider(leading_segment): + return False + return inferred_provider(model) == custom_llm_provider + + def names_a_provider(candidate: str) -> bool: """Whether a model name's leading segment is a provider rather than part of the model.""" import litellm diff --git a/tests/unit/integrations/websearch_interception/test_websearch_responses.py b/tests/unit/integrations/websearch_interception/test_websearch_responses.py index af6038d0455..feb3b0dd987 100644 --- a/tests/unit/integrations/websearch_interception/test_websearch_responses.py +++ b/tests/unit/integrations/websearch_interception/test_websearch_responses.py @@ -7,6 +7,7 @@ builds a Responses-format follow-up request. """ from types import SimpleNamespace +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -257,14 +258,14 @@ async def test_build_responses_plan_produces_responses_input(): @pytest.mark.asyncio -async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model(monkeypatch): +async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model(monkeypatch: pytest.MonkeyPatch) -> None: """#38829 on the Responses surface: a provider-stripped model whose remainder still holds a slash was left unqualified, so the follow-up could not resolve a provider.""" - logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.BEDROCK]) + logger: Final = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.BEDROCK]) monkeypatch.setattr(proxy_server, "llm_router", None) monkeypatch.setattr(litellm, "asearch", AsyncMock(return_value=SearchResponse(object="search", results=[]))) - tools_dict = { + tools_dict: Final = { "tool_calls": [ { "id": "fc_1", @@ -280,7 +281,7 @@ async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model(monke "response_format": "responses", } - plan = await logger.async_build_responses_agentic_loop_plan( + plan: Final = await logger.async_build_responses_agentic_loop_plan( tools=tools_dict, model="mantle/anthropic.claude-sonnet-5", messages=[{"role": "user", "content": "What's the latest AI news?"}], @@ -294,7 +295,7 @@ async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model(monke }, ) - patch_obj = plan.request_patch + patch_obj: Final = plan.request_patch assert patch_obj is not None assert patch_obj.model == "bedrock/mantle/anthropic.claude-sonnet-5" litellm.asearch.assert_awaited_once() diff --git a/tests/unit/litellm_core_utils/test_chat_completion_agentic_loop.py b/tests/unit/litellm_core_utils/test_chat_completion_agentic_loop.py index 3cbc3591009..6167e91c37f 100644 --- a/tests/unit/litellm_core_utils/test_chat_completion_agentic_loop.py +++ b/tests/unit/litellm_core_utils/test_chat_completion_agentic_loop.py @@ -364,6 +364,49 @@ async def test_dispatcher_runs_followup_with_incremented_depth_and_patched_messa assert logger.cleanup_calls == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model,custom_llm_provider,routed_model", + [ + ("cohere/command-a-03-2025", "cohere_chat", "command-a-03-2025"), + ("anthropic/claude-2", "anthropic_text", "claude-2"), + ], +) +async def test_sdk_followup_keeps_a_model_whose_prefix_already_routes_to_its_provider( + model: str, custom_llm_provider: str, routed_model: str +) -> None: + plan: Final = AgenticLoopPlan( + run_agentic_loop=True, + request_patch=AgenticLoopRequestPatch(messages=_patched_messages()), + ) + gate: Final = _GateOnlyLogger(plan=plan, tool_calls={"tool_calls": [{"id": "call_abc"}]}) + logging_obj: Final = Logging( + model=model, + messages=[{"role": "user", "content": "what is 6*7?"}], + stream=False, + call_type="acompletion", + start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), + litellm_call_id="call-prefix", + function_id="fn-prefix", + dynamic_success_callbacks=[gate], + ) + + followup: Final = await maybe_run_chat_completion_agentic_loop( + response=_tool_call_model_response(), + model=model, + messages=[{"role": "user", "content": "what is 6*7?"}], + optional_params={}, + kwargs={"mock_response": "done"}, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + stream=False, + ) + + assert isinstance(followup, ModelResponse) + assert followup.model == routed_model + assert followup._hidden_params["custom_llm_provider"] == custom_llm_provider + + @pytest.mark.asyncio async def test_dispatcher_followup_does_not_repeat_a_request_param_found_in_request_kwargs( restore_callbacks, diff --git a/tests/unit/litellm_core_utils/test_core_helpers.py b/tests/unit/litellm_core_utils/test_core_helpers.py index e0ccfb21df4..cc047a73285 100644 --- a/tests/unit/litellm_core_utils/test_core_helpers.py +++ b/tests/unit/litellm_core_utils/test_core_helpers.py @@ -647,7 +647,7 @@ class TestProviderResponseHeadersInHiddenParams: ("claude-sonnet-4-5", "anthropic", "anthropic/claude-sonnet-4-5"), ], ) -def test_the_provider_prefix_is_restored(model, provider, expected): +def test_the_provider_prefix_is_restored(model: str, provider: str, expected: str) -> None: assert qualify_provider_stripped_model(model, provider) == expected @@ -656,60 +656,63 @@ def test_the_provider_prefix_is_restored(model, provider, expected): [ ("bedrock/mantle/anthropic.claude-sonnet-5", "bedrock"), ("openai/gpt-4o", "openai"), + ("cohere/command-a-03-2025", "cohere_chat"), + ("ai21/jamba-1.5-large", "ai21_chat"), + ("anthropic/claude-2", "anthropic_text"), ], ) -def test_an_already_qualified_model_is_left_alone(model, provider): +def test_an_already_qualified_model_is_left_alone(model: str, provider: str) -> None: assert qualify_provider_stripped_model(model, provider) == model -def test_a_provider_that_only_shares_a_prefix_is_still_qualified(): +def test_a_provider_that_only_shares_a_prefix_is_still_qualified() -> None: assert qualify_provider_stripped_model("openai_like/foo", "openai") == "openai/openai_like/foo" -def test_no_provider_leaves_the_model_untouched(): +def test_no_provider_leaves_the_model_untouched() -> None: assert qualify_provider_stripped_model("gpt-4o", "") == "gpt-4o" -def test_a_cross_provider_patched_model_is_dispatched_as_the_hook_asked(): +def test_a_cross_provider_patched_model_is_dispatched_as_the_hook_asked() -> None: assert qualify_agentic_followup_model("anthropic/claude-sonnet-4-5", "gpt-4o", "openai") == ( "anthropic/claude-sonnet-4-5" ) -def test_a_bare_patched_model_takes_the_request_provider(): +def test_a_bare_patched_model_takes_the_request_provider() -> None: assert qualify_agentic_followup_model("gpt-4o-mini", "gpt-4o", "openai") == "openai/gpt-4o-mini" -def test_a_sub_path_request_model_keeps_its_provider(): +def test_a_sub_path_request_model_keeps_its_provider() -> None: assert qualify_agentic_followup_model(None, "mantle/anthropic.claude-sonnet-5", "bedrock") == ( "bedrock/mantle/anthropic.claude-sonnet-5" ) -def test_an_unpatched_ordinary_model_takes_the_request_provider(): +def test_an_unpatched_ordinary_model_takes_the_request_provider() -> None: assert qualify_agentic_followup_model(None, "gpt-4o", "openai") == "openai/gpt-4o" -def test_a_patched_model_already_holding_the_request_provider_is_left_alone(): +def test_a_patched_model_already_holding_the_request_provider_is_left_alone() -> None: assert qualify_agentic_followup_model("openai/gpt-4o", "gpt-4o", "openai") == "openai/gpt-4o" @pytest.mark.parametrize("patched", ["", None]) -def test_a_patch_without_a_model_falls_back_to_the_request_model(patched): +def test_a_patch_without_a_model_falls_back_to_the_request_model(patched: str | None) -> None: assert qualify_agentic_followup_model(patched, "gpt-4o", "openai") == "openai/gpt-4o" -def test_a_patched_sub_path_model_still_takes_the_request_provider(): +def test_a_patched_sub_path_model_still_takes_the_request_provider() -> None: assert qualify_agentic_followup_model("mantle/anthropic.claude-sonnet-5", "gpt-4o", "bedrock") == ( "bedrock/mantle/anthropic.claude-sonnet-5" ) -def test_a_patched_model_on_a_json_configured_provider_reaches_that_provider(): +def test_a_patched_model_on_a_json_configured_provider_reaches_that_provider() -> None: assert qualify_agentic_followup_model("aihubmix/gpt-4o", "gpt-4o", "openai") == "aihubmix/gpt-4o" -def test_a_patch_echoing_the_request_model_keeps_the_request_provider(): +def test_a_patch_echoing_the_request_model_keeps_the_request_provider() -> None: assert qualify_agentic_followup_model("openai/gpt-4o", "openai/gpt-4o", "openrouter") == ( "openrouter/openai/gpt-4o" )