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"