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..642aa1f13bb 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -29,6 +29,13 @@ 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 provider-stripped model.""" + 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..9f266adcec3 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,36 @@ class TestIsExpectedClientError: category=RateLimitErrorCategory.VENDOR_RATE_LIMIT, ) assert is_expected_client_error(vendor_limit) is False + + +class TestQualifyProviderStrippedModel: + """#38829: a model whose remainder still holds a slash must keep its provider prefix.""" + + @pytest.mark.parametrize( + "model,provider,expected", + [ + ("mantle/anthropic.claude-sonnet-5", "bedrock", "bedrock/mantle/anthropic.claude-sonnet-5"), + ("invoke/anthropic.claude-v2", "bedrock", "bedrock/invoke/anthropic.claude-v2"), + ("openai/gpt-4o", "openrouter", "openrouter/openai/gpt-4o"), + ("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): + 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" 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..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 @@ -3713,3 +3713,47 @@ 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 must re-dispatch a model litellm.acompletion can route.""" + + @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): + 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"