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
This commit is contained in:
Priyansh Nandwana 2026-08-30 12:17:38 +05:30
parent 98d46ee59d
commit 0196c9da77
4 changed files with 61 additions and 8 deletions

View file

@ -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",

View file

@ -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.

View file

@ -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)

View file

@ -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"