From 0196c9da77dea475a86b3886accc00cbf88df3c2 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 30 Aug 2026 12:17:38 +0530 Subject: [PATCH 1/4] 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 587da997f94..ba701ba1ef2 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -1794,11 +1794,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 aa7d6ca1699..cc7fab718f3 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -29,6 +29,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 7109e6942d1..593edf77c5b 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5599,9 +5599,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/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index e937be47441..b1575cadc26 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -11,6 +11,7 @@ from litellm.litellm_core_utils.core_helpers import ( get_or_create_metadata_bucket, map_finish_reason, normalize_drop_params, + qualify_provider_stripped_model, reconstruct_model_name, redact_nested_match_and_regex_keys, ) @@ -406,3 +407,43 @@ class TestIsExpectedClientError: category=RateLimitErrorCategory.VENDOR_RATE_LIMIT, ) assert is_expected_client_error(vendor_limit) is False + + +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 c45c594a1bcdbed910fbbd6a91e1f14ac476f9c2 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 30 Aug 2026 14:13:55 +0530 Subject: [PATCH 2/4] 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/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index c39779972c0..87e0360e76e 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -3713,3 +3713,51 @@ def test_image_edit_handler_keeps_the_sync_transform(): assert config.transform_calls == ["sync"] assert captured["body"] == {"transformed_by": "sync"} assert response.data[0].b64_json == "sync" + + +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 f28713241220ed0fe36ddd8e70d5e0731f2a305d Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sat, 5 Sep 2026 13:00:50 +0530 Subject: [PATCH 3/4] refactor(websearch): trim the comments on the provider-qualify helper --- litellm/litellm_core_utils/core_helpers.py | 9 +++------ .../test_litellm/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 cc7fab718f3..af6e0996e3a 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -30,13 +30,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/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index b1575cadc26..cd834beff92 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -418,12 +418,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 cbdffd27f95b9ada0b72ee9f16231d2edef17526 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 6 Sep 2026 11:34:39 +0530 Subject: [PATCH 4/4] refactor(websearch): cut the helper and test docstrings to one line --- litellm/litellm_core_utils/core_helpers.py | 6 +----- tests/test_litellm/litellm_core_utils/test_core_helpers.py | 6 +----- .../test_litellm/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 af6e0996e3a..642aa1f13bb 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -30,11 +30,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/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index cd834beff92..9f266adcec3 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -410,10 +410,7 @@ class TestIsExpectedClientError: 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", @@ -439,7 +436,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/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 87e0360e76e..9dd8dcafd04 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -3716,10 +3716,7 @@ def test_image_edit_handler_keeps_the_sync_transform(): 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(): @@ -3751,7 +3748,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"