From cc56e3c43b37863e9a754b35068e0cb22748afec Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 27 Sep 2026 14:15:48 +0530 Subject: [PATCH] fix(websearch): leave a hook-qualified follow-up model on its own provider A hook that patches in its own model owns that choice, so re-qualifying it sent a cross-provider follow-up to the original request's provider instead. Only a bare patched model and the request's provider-stripped model take the prefix now, and the sibling agentic-loop path resolves the model the same way. --- .../chat_completion_agentic_loop.py | 5 ++-- litellm/litellm_core_utils/core_helpers.py | 13 ++++++++++ litellm/llms/custom_httpx/llm_http_handler.py | 4 +-- .../litellm_core_utils/test_core_helpers.py | 25 +++++++++++++++++++ .../custom_httpx/test_llm_http_handler.py | 15 +++++++++-- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/chat_completion_agentic_loop.py b/litellm/litellm_core_utils/chat_completion_agentic_loop.py index e0bd85a7937..e1c862383a8 100644 --- a/litellm/litellm_core_utils/chat_completion_agentic_loop.py +++ b/litellm/litellm_core_utils/chat_completion_agentic_loop.py @@ -13,6 +13,7 @@ from litellm.litellm_core_utils.agentic_loop_settings import ( DEFAULT_MAX_AGENTIC_LOOPS, validated_max_agentic_loops, ) +from litellm.litellm_core_utils.core_helpers import qualify_agentic_followup_model from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject from litellm.llms.base_llm.base_model_iterator import MockResponseIterator from litellm.types.integrations.custom_logger import ( @@ -170,9 +171,7 @@ async def _execute_chat_completion_agentic_plan( if patch.messages is None: raise ValueError("Agentic loop plan missing patched messages") - full_model_name = patch.model or model - if "/" not in full_model_name: - full_model_name = f"{custom_llm_provider}/{full_model_name}" + full_model_name: Final = qualify_agentic_followup_model(patch.model, model, custom_llm_provider) optional_params_for_followup: Final = {**optional_params, **patch.optional_params} if patch.tools is not None: diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 25fca539f17..fe58c15fc47 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -37,6 +37,19 @@ def qualify_provider_stripped_model(model: str, custom_llm_provider: str) -> str return f"{custom_llm_provider}/{model}" +def qualify_agentic_followup_model(patch_model: str | None, model: str, custom_llm_provider: str) -> str: + """Resolve the model an agentic follow-up re-dispatches. + + A hook that qualified its own patched model owns that choice, including a cross-provider + one, so only a bare patched model and the request's provider-stripped model get a prefix. + """ + if patch_model is None: + return qualify_provider_stripped_model(model, custom_llm_provider) + if "/" in patch_model: + return patch_model + return qualify_provider_stripped_model(patch_model, custom_llm_provider) + + def safe_divide_seconds(seconds: float, denominator: float, default: float | None = None) -> float | None: """ Safely divide seconds by denominator, handling zero division. diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 59d082e365f..cba17fb8015 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5454,9 +5454,9 @@ class BaseLLMHTTPHandler: if patch.messages is None: raise ValueError("Agentic loop plan missing patched messages") - from litellm.litellm_core_utils.core_helpers import qualify_provider_stripped_model + from litellm.litellm_core_utils.core_helpers import qualify_agentic_followup_model - full_model_name: Final = qualify_provider_stripped_model(patch.model or model, custom_llm_provider) + full_model_name: Final = qualify_agentic_followup_model(patch.model, model, custom_llm_provider) optional_params_for_followup: Final = dict(optional_params) optional_params_for_followup.update(patch.optional_params) diff --git a/tests/unit/litellm_core_utils/test_core_helpers.py b/tests/unit/litellm_core_utils/test_core_helpers.py index 9d382cb5ae1..08b70a245cb 100644 --- a/tests/unit/litellm_core_utils/test_core_helpers.py +++ b/tests/unit/litellm_core_utils/test_core_helpers.py @@ -16,6 +16,7 @@ from litellm.litellm_core_utils.core_helpers import ( get_provider_response_headers_from_hidden_params, map_finish_reason, normalize_drop_params, + qualify_agentic_followup_model, qualify_provider_stripped_model, reconstruct_model_name, redact_nested_match_and_regex_keys, @@ -591,3 +592,27 @@ class TestQualifyProviderStrippedModel: def test_no_provider_leaves_the_model_untouched(self): assert qualify_provider_stripped_model("gpt-4o", "") == "gpt-4o" + + +class TestQualifyAgenticFollowUpModel: + """A hook that qualified its own patched model owns that choice, so a cross-provider + follow-up must not be re-prefixed with the original request's provider.""" + + def test_a_cross_provider_patched_model_is_dispatched_as_the_hook_asked(self): + 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(self): + 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(self): + 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(self): + 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(self): + assert qualify_agentic_followup_model("openai/gpt-4o", "gpt-4o", "openai") == "openai/gpt-4o" diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index dd81f106d6a..95da0278258 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -4319,11 +4319,15 @@ class TestAgenticFollowUpKeepsTheProviderPrefix: request_patch=AgenticLoopRequestPatch(messages=[{"role": "user", "content": "hi"}]), ) - async def _run_followup(self, model: str, custom_llm_provider: str): + async def _run_followup(self, model: str, custom_llm_provider: str, patched_model: str | None = None): from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + plan = self._plan() + if patched_model is not None: + plan.request_patch.model = patched_model + return await BaseLLMHTTPHandler()._execute_chat_completion_agentic_plan( - plan=self._plan(), + plan=plan, model=model, messages=[{"role": "user", "content": "hi"}], optional_params={"mock_response": "ok from followup"}, @@ -4346,3 +4350,10 @@ class TestAgenticFollowUpKeepsTheProviderPrefix: response = await self._run_followup("gpt-4o-mini", "openai") assert response.choices[0].message.content == "ok from followup" + + @pytest.mark.asyncio + async def test_a_hook_that_patches_another_providers_model_reaches_that_provider(self): + response = await self._run_followup("gpt-4o", "openai", patched_model="anthropic/claude-sonnet-4-5") + + assert response.model == "claude-sonnet-4-5" + assert response._hidden_params["custom_llm_provider"] == "anthropic"