From 6463d8e6325620e95b1c2b581c17927a2259ec19 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 30 Aug 2026 12:17:38 +0530 Subject: [PATCH 1/5] fix(websearch): keep the provider prefix on the agentic follow-up model The agentic follow-up rebuilt its model string with "a slash means it is already qualified". Handlers are handed the provider-stripped model, and for a provider that routes through a sub-path the remainder still holds a slash, so bedrock/mantle/anthropic.claude-sonnet-5 arrived as mantle/anthropic.claude-sonnet-5 and stayed that way. The follow-up acompletion could not resolve a provider from it and raised "LLM Provider NOT provided". Prefix unless the model is already qualified with that same provider, in one shared helper used by both the handler and the websearch patch builder, which carried copies of the same heuristic. This also fixes openrouter, whose stripped model keeps a slash for the same reason, and the old startswith test that read openai as a prefix of openai_like. Fixes #38829 --- .../websearch_interception/handler.py | 8 ++-- litellm/litellm_core_utils/core_helpers.py | 14 +++++++ litellm/llms/custom_httpx/llm_http_handler.py | 6 +-- .../litellm_core_utils/test_core_helpers.py | 41 +++++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 6ebf485d717..9a73eff2195 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -1925,11 +1925,9 @@ class WebSearchInterceptionLogger(CustomLogger): k: v for k, v in kwargs.items() if not k.startswith("_websearch_interception") and k not in internal_params } - full_model_name = model - if "custom_llm_provider" in kwargs: - custom_llm_provider: Final = kwargs["custom_llm_provider"] - if not model.startswith(custom_llm_provider) and "/" not in model: - full_model_name = f"{custom_llm_provider}/{model}" + from litellm.litellm_core_utils.core_helpers import qualify_provider_stripped_model + + full_model_name: Final = qualify_provider_stripped_model(model, kwargs.get("custom_llm_provider", "")) verbose_logger.debug( "WebSearchInterception: Built chat completion request patch model=%s messages=%d", diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index b095b4b12c6..1dac19a1765 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -30,6 +30,20 @@ def is_codex_user_agent(user_agent: str) -> bool: return bool(_CODEX_CLIENT_PREFIX_RE.match(user_agent)) +def qualify_provider_stripped_model(model: str, custom_llm_provider: str) -> str: + """ + Put the provider prefix back on a model an agentic follow-up re-dispatches with. + + Handlers are handed the provider-stripped model, and for providers that route through + a sub-path (``bedrock/mantle/...``, ``openrouter/openai/...``) that remainder still + holds a slash. Treating any slash as "already qualified" drops the prefix and leaves a + string no provider can be resolved from. + """ + if not custom_llm_provider or model.startswith(f"{custom_llm_provider}/"): + return model + return f"{custom_llm_provider}/{model}" + + 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 9eccfe12e71..59d082e365f 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") - full_model_name = patch.model or model - if "/" not in full_model_name: - full_model_name = f"{custom_llm_provider}/{full_model_name}" + from litellm.litellm_core_utils.core_helpers import qualify_provider_stripped_model + + full_model_name: Final = qualify_provider_stripped_model(patch.model or 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 2a6dd347d5f..6703defb947 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_provider_stripped_model, reconstruct_model_name, redact_nested_match_and_regex_keys, set_provider_response_headers_in_hidden_params, @@ -557,3 +558,43 @@ class TestProviderResponseHeadersInHiddenParams: assert get_provider_response_headers_from_hidden_params(sibling) is None assert "additional_headers" not in sibling._hidden_params + + +class TestQualifyProviderStrippedModel: + """#38829: an agentic follow-up re-dispatched the provider-stripped model. For providers + that route through a sub-path the remainder still holds a slash, and the old + "any slash means already qualified" test dropped the prefix, leaving a string + litellm.acompletion could not resolve a provider from.""" + + @pytest.mark.parametrize( + "model,provider,expected", + [ + # the reported case: bedrock's OpenAI-compatible sub-path + ("mantle/anthropic.claude-sonnet-5", "bedrock", "bedrock/mantle/anthropic.claude-sonnet-5"), + ("invoke/anthropic.claude-v2", "bedrock", "bedrock/invoke/anthropic.claude-v2"), + # another provider whose stripped model keeps a slash + ("openai/gpt-4o", "openrouter", "openrouter/openai/gpt-4o"), + # the ordinary case still works + ("gpt-4o", "openai", "openai/gpt-4o"), + ("claude-sonnet-4-5", "anthropic", "anthropic/claude-sonnet-4-5"), + ], + ) + def test_the_provider_prefix_is_restored(self, model, provider, expected): + assert qualify_provider_stripped_model(model, provider) == expected + + @pytest.mark.parametrize( + "model,provider", + [ + ("bedrock/mantle/anthropic.claude-sonnet-5", "bedrock"), + ("openai/gpt-4o", "openai"), + ], + ) + def test_an_already_qualified_model_is_left_alone(self, model, provider): + assert qualify_provider_stripped_model(model, provider) == model + + def test_a_provider_that_only_shares_a_prefix_is_still_qualified(self): + """`openai` must not be read as a prefix of `openai_like`.""" + assert qualify_provider_stripped_model("openai_like/foo", "openai") == "openai/openai_like/foo" + + def test_no_provider_leaves_the_model_untouched(self): + assert qualify_provider_stripped_model("gpt-4o", "") == "gpt-4o" From 01a577c4e69d5c3d3d026c8e61014d46d5d6750e Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 30 Aug 2026 14:13:55 +0530 Subject: [PATCH 2/5] test(websearch): cover the agentic follow-up call site The helper had unit tests but the call site did not, so the two changed lines in _execute_chat_completion_agentic_plan were uncovered. Drive the real follow-up with mock_response instead of patching litellm, which keeps the test on the behaviour: against the old code the sub-path case raises "LLM Provider NOT provided", and the ordinary-model case passes either way as a guard. --- .../custom_httpx/test_llm_http_handler.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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 399e4dbf206..433cd467730 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -4302,3 +4302,51 @@ async def test_async_text_to_speech_handler_records_upstream_response_headers(): assert response.content == b"audio-bytes" _assert_upstream_headers_recorded(response) + + +class TestAgenticFollowUpKeepsTheProviderPrefix: + """#38829: the follow-up re-dispatched the provider-stripped model. For a provider that + routes through a sub-path the remainder still holds a slash, so the old + "a slash means already qualified" test dropped the prefix and the follow-up raised + "LLM Provider NOT provided".""" + + @staticmethod + def _plan(): + from litellm.types.integrations.custom_logger import ( + AgenticLoopPlan, + AgenticLoopRequestPatch, + ) + + return AgenticLoopPlan( + run_agentic_loop=True, + request_patch=AgenticLoopRequestPatch(messages=[{"role": "user", "content": "hi"}]), + ) + + async def _run_followup(self, model: str, custom_llm_provider: str): + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + + return await BaseLLMHTTPHandler()._execute_chat_completion_agentic_plan( + plan=self._plan(), + model=model, + messages=[{"role": "user", "content": "hi"}], + optional_params={"mock_response": "ok from followup"}, + kwargs={}, + custom_llm_provider=custom_llm_provider, + depth=0, + max_loops=2, + fingerprints=[], + fingerprint="fp", + ) + + @pytest.mark.asyncio + async def test_a_sub_path_model_still_resolves_a_provider(self): + """bedrock/mantle/... reaches the handler as mantle/..., which alone is unroutable.""" + response = await self._run_followup("mantle/anthropic.claude-sonnet-5", "bedrock") + + assert response.choices[0].message.content == "ok from followup" + + @pytest.mark.asyncio + async def test_an_ordinary_model_still_resolves_a_provider(self): + response = await self._run_followup("gpt-4o-mini", "openai") + + assert response.choices[0].message.content == "ok from followup" From 8c612c268e12567ac6c3726092ccbd63de2fed29 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sat, 5 Sep 2026 13:00:50 +0530 Subject: [PATCH 3/5] refactor(websearch): trim the comments on the provider-qualify helper --- litellm/litellm_core_utils/core_helpers.py | 9 +++------ tests/unit/litellm_core_utils/test_core_helpers.py | 3 --- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 1dac19a1765..5af2be774c0 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -31,13 +31,10 @@ def is_codex_user_agent(user_agent: str) -> bool: def qualify_provider_stripped_model(model: str, custom_llm_provider: str) -> str: - """ - Put the provider prefix back on a model an agentic follow-up re-dispatches with. + """Put the provider prefix back on a provider-stripped model. - Handlers are handed the provider-stripped model, and for providers that route through - a sub-path (``bedrock/mantle/...``, ``openrouter/openai/...``) that remainder still - holds a slash. Treating any slash as "already qualified" drops the prefix and leaves a - string no provider can be resolved from. + A sub-path provider (``bedrock/mantle/...``) leaves a slash in the remainder, so + treating any slash as "already qualified" would drop the prefix. """ if not custom_llm_provider or model.startswith(f"{custom_llm_provider}/"): return model diff --git a/tests/unit/litellm_core_utils/test_core_helpers.py b/tests/unit/litellm_core_utils/test_core_helpers.py index 6703defb947..cf61a765002 100644 --- a/tests/unit/litellm_core_utils/test_core_helpers.py +++ b/tests/unit/litellm_core_utils/test_core_helpers.py @@ -569,12 +569,9 @@ class TestQualifyProviderStrippedModel: @pytest.mark.parametrize( "model,provider,expected", [ - # the reported case: bedrock's OpenAI-compatible sub-path ("mantle/anthropic.claude-sonnet-5", "bedrock", "bedrock/mantle/anthropic.claude-sonnet-5"), ("invoke/anthropic.claude-v2", "bedrock", "bedrock/invoke/anthropic.claude-v2"), - # another provider whose stripped model keeps a slash ("openai/gpt-4o", "openrouter", "openrouter/openai/gpt-4o"), - # the ordinary case still works ("gpt-4o", "openai", "openai/gpt-4o"), ("claude-sonnet-4-5", "anthropic", "anthropic/claude-sonnet-4-5"), ], From de54d8a72c59fd90b382d47dd3b2e06ed830b359 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 6 Sep 2026 11:34:39 +0530 Subject: [PATCH 4/5] refactor(websearch): cut the helper and test docstrings to one line --- litellm/litellm_core_utils/core_helpers.py | 6 +----- tests/unit/litellm_core_utils/test_core_helpers.py | 6 +----- tests/unit/llms/custom_httpx/test_llm_http_handler.py | 6 +----- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 5af2be774c0..25fca539f17 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -31,11 +31,7 @@ def is_codex_user_agent(user_agent: str) -> bool: def qualify_provider_stripped_model(model: str, custom_llm_provider: str) -> str: - """Put the provider prefix back on a provider-stripped model. - - A sub-path provider (``bedrock/mantle/...``) leaves a slash in the remainder, so - treating any slash as "already qualified" would drop the prefix. - """ + """Put the provider prefix back on a provider-stripped model.""" if not custom_llm_provider or model.startswith(f"{custom_llm_provider}/"): return model return f"{custom_llm_provider}/{model}" diff --git a/tests/unit/litellm_core_utils/test_core_helpers.py b/tests/unit/litellm_core_utils/test_core_helpers.py index cf61a765002..9d382cb5ae1 100644 --- a/tests/unit/litellm_core_utils/test_core_helpers.py +++ b/tests/unit/litellm_core_utils/test_core_helpers.py @@ -561,10 +561,7 @@ class TestProviderResponseHeadersInHiddenParams: class TestQualifyProviderStrippedModel: - """#38829: an agentic follow-up re-dispatched the provider-stripped model. For providers - that route through a sub-path the remainder still holds a slash, and the old - "any slash means already qualified" test dropped the prefix, leaving a string - litellm.acompletion could not resolve a provider from.""" + """#38829: a model whose remainder still holds a slash must keep its provider prefix.""" @pytest.mark.parametrize( "model,provider,expected", @@ -590,7 +587,6 @@ class TestQualifyProviderStrippedModel: assert qualify_provider_stripped_model(model, provider) == model def test_a_provider_that_only_shares_a_prefix_is_still_qualified(self): - """`openai` must not be read as a prefix of `openai_like`.""" assert qualify_provider_stripped_model("openai_like/foo", "openai") == "openai/openai_like/foo" def test_no_provider_leaves_the_model_untouched(self): 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 433cd467730..dd81f106d6a 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -4305,10 +4305,7 @@ async def test_async_text_to_speech_handler_records_upstream_response_headers(): class TestAgenticFollowUpKeepsTheProviderPrefix: - """#38829: the follow-up re-dispatched the provider-stripped model. For a provider that - routes through a sub-path the remainder still holds a slash, so the old - "a slash means already qualified" test dropped the prefix and the follow-up raised - "LLM Provider NOT provided".""" + """#38829: the follow-up must re-dispatch a model litellm.acompletion can route.""" @staticmethod def _plan(): @@ -4340,7 +4337,6 @@ class TestAgenticFollowUpKeepsTheProviderPrefix: @pytest.mark.asyncio async def test_a_sub_path_model_still_resolves_a_provider(self): - """bedrock/mantle/... reaches the handler as mantle/..., which alone is unroutable.""" response = await self._run_followup("mantle/anthropic.claude-sonnet-5", "bedrock") assert response.choices[0].message.content == "ok from followup" From cc56e3c43b37863e9a754b35068e0cb22748afec Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 27 Sep 2026 14:15:48 +0530 Subject: [PATCH 5/5] 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"