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"