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.
This commit is contained in:
Priyansh Nandwana 2026-09-27 14:15:48 +05:30
parent de54d8a72c
commit cc56e3c43b
5 changed files with 55 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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