fix(websearch): leave a follow-up model alone when its prefix already routes to the request provider

This commit is contained in:
mateo-berri 2026-10-20 22:06:18 -07:00
parent d9026cbcf2
commit 9665a15bf6
4 changed files with 76 additions and 18 deletions

View file

@ -37,9 +37,20 @@ 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
if _already_routes_to(model, custom_llm_provider):
return model
return f"{custom_llm_provider}/{model}"
def _already_routes_to(model: str, custom_llm_provider: str) -> bool:
from litellm.litellm_core_utils.get_llm_provider_logic import inferred_provider
leading_segment: Final = model.split("/", 1)[0]
if leading_segment == model or not names_a_provider(leading_segment):
return False
return inferred_provider(model) == custom_llm_provider
def names_a_provider(candidate: str) -> bool:
"""Whether a model name's leading segment is a provider rather than part of the model."""
import litellm

View file

@ -7,6 +7,7 @@ builds a Responses-format follow-up request.
"""
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -257,14 +258,14 @@ async def test_build_responses_plan_produces_responses_input():
@pytest.mark.asyncio
async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model(monkeypatch):
async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model(monkeypatch: pytest.MonkeyPatch) -> None:
"""#38829 on the Responses surface: a provider-stripped model whose remainder still holds a
slash was left unqualified, so the follow-up could not resolve a provider."""
logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.BEDROCK])
logger: Final = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.BEDROCK])
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(litellm, "asearch", AsyncMock(return_value=SearchResponse(object="search", results=[])))
tools_dict = {
tools_dict: Final = {
"tool_calls": [
{
"id": "fc_1",
@ -280,7 +281,7 @@ async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model(monke
"response_format": "responses",
}
plan = await logger.async_build_responses_agentic_loop_plan(
plan: Final = await logger.async_build_responses_agentic_loop_plan(
tools=tools_dict,
model="mantle/anthropic.claude-sonnet-5",
messages=[{"role": "user", "content": "What's the latest AI news?"}],
@ -294,7 +295,7 @@ async def test_build_responses_plan_keeps_the_provider_on_a_sub_path_model(monke
},
)
patch_obj = plan.request_patch
patch_obj: Final = plan.request_patch
assert patch_obj is not None
assert patch_obj.model == "bedrock/mantle/anthropic.claude-sonnet-5"
litellm.asearch.assert_awaited_once()

View file

@ -364,6 +364,49 @@ async def test_dispatcher_runs_followup_with_incremented_depth_and_patched_messa
assert logger.cleanup_calls == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"model,custom_llm_provider,routed_model",
[
("cohere/command-a-03-2025", "cohere_chat", "command-a-03-2025"),
("anthropic/claude-2", "anthropic_text", "claude-2"),
],
)
async def test_sdk_followup_keeps_a_model_whose_prefix_already_routes_to_its_provider(
model: str, custom_llm_provider: str, routed_model: str
) -> None:
plan: Final = AgenticLoopPlan(
run_agentic_loop=True,
request_patch=AgenticLoopRequestPatch(messages=_patched_messages()),
)
gate: Final = _GateOnlyLogger(plan=plan, tool_calls={"tool_calls": [{"id": "call_abc"}]})
logging_obj: Final = Logging(
model=model,
messages=[{"role": "user", "content": "what is 6*7?"}],
stream=False,
call_type="acompletion",
start_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
litellm_call_id="call-prefix",
function_id="fn-prefix",
dynamic_success_callbacks=[gate],
)
followup: Final = await maybe_run_chat_completion_agentic_loop(
response=_tool_call_model_response(),
model=model,
messages=[{"role": "user", "content": "what is 6*7?"}],
optional_params={},
kwargs={"mock_response": "done"},
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
stream=False,
)
assert isinstance(followup, ModelResponse)
assert followup.model == routed_model
assert followup._hidden_params["custom_llm_provider"] == custom_llm_provider
@pytest.mark.asyncio
async def test_dispatcher_followup_does_not_repeat_a_request_param_found_in_request_kwargs(
restore_callbacks,

View file

@ -647,7 +647,7 @@ class TestProviderResponseHeadersInHiddenParams:
("claude-sonnet-4-5", "anthropic", "anthropic/claude-sonnet-4-5"),
],
)
def test_the_provider_prefix_is_restored(model, provider, expected):
def test_the_provider_prefix_is_restored(model: str, provider: str, expected: str) -> None:
assert qualify_provider_stripped_model(model, provider) == expected
@ -656,60 +656,63 @@ def test_the_provider_prefix_is_restored(model, provider, expected):
[
("bedrock/mantle/anthropic.claude-sonnet-5", "bedrock"),
("openai/gpt-4o", "openai"),
("cohere/command-a-03-2025", "cohere_chat"),
("ai21/jamba-1.5-large", "ai21_chat"),
("anthropic/claude-2", "anthropic_text"),
],
)
def test_an_already_qualified_model_is_left_alone(model, provider):
def test_an_already_qualified_model_is_left_alone(model: str, provider: str) -> None:
assert qualify_provider_stripped_model(model, provider) == model
def test_a_provider_that_only_shares_a_prefix_is_still_qualified():
def test_a_provider_that_only_shares_a_prefix_is_still_qualified() -> None:
assert qualify_provider_stripped_model("openai_like/foo", "openai") == "openai/openai_like/foo"
def test_no_provider_leaves_the_model_untouched():
def test_no_provider_leaves_the_model_untouched() -> None:
assert qualify_provider_stripped_model("gpt-4o", "") == "gpt-4o"
def test_a_cross_provider_patched_model_is_dispatched_as_the_hook_asked():
def test_a_cross_provider_patched_model_is_dispatched_as_the_hook_asked() -> None:
assert qualify_agentic_followup_model("anthropic/claude-sonnet-4-5", "gpt-4o", "openai") == (
"anthropic/claude-sonnet-4-5"
)
def test_a_bare_patched_model_takes_the_request_provider():
def test_a_bare_patched_model_takes_the_request_provider() -> None:
assert qualify_agentic_followup_model("gpt-4o-mini", "gpt-4o", "openai") == "openai/gpt-4o-mini"
def test_a_sub_path_request_model_keeps_its_provider():
def test_a_sub_path_request_model_keeps_its_provider() -> None:
assert qualify_agentic_followup_model(None, "mantle/anthropic.claude-sonnet-5", "bedrock") == (
"bedrock/mantle/anthropic.claude-sonnet-5"
)
def test_an_unpatched_ordinary_model_takes_the_request_provider():
def test_an_unpatched_ordinary_model_takes_the_request_provider() -> None:
assert qualify_agentic_followup_model(None, "gpt-4o", "openai") == "openai/gpt-4o"
def test_a_patched_model_already_holding_the_request_provider_is_left_alone():
def test_a_patched_model_already_holding_the_request_provider_is_left_alone() -> None:
assert qualify_agentic_followup_model("openai/gpt-4o", "gpt-4o", "openai") == "openai/gpt-4o"
@pytest.mark.parametrize("patched", ["", None])
def test_a_patch_without_a_model_falls_back_to_the_request_model(patched):
def test_a_patch_without_a_model_falls_back_to_the_request_model(patched: str | None) -> None:
assert qualify_agentic_followup_model(patched, "gpt-4o", "openai") == "openai/gpt-4o"
def test_a_patched_sub_path_model_still_takes_the_request_provider():
def test_a_patched_sub_path_model_still_takes_the_request_provider() -> None:
assert qualify_agentic_followup_model("mantle/anthropic.claude-sonnet-5", "gpt-4o", "bedrock") == (
"bedrock/mantle/anthropic.claude-sonnet-5"
)
def test_a_patched_model_on_a_json_configured_provider_reaches_that_provider():
def test_a_patched_model_on_a_json_configured_provider_reaches_that_provider() -> None:
assert qualify_agentic_followup_model("aihubmix/gpt-4o", "gpt-4o", "openai") == "aihubmix/gpt-4o"
def test_a_patch_echoing_the_request_model_keeps_the_request_provider():
def test_a_patch_echoing_the_request_model_keeps_the_request_provider() -> None:
assert qualify_agentic_followup_model("openai/gpt-4o", "openai/gpt-4o", "openrouter") == (
"openrouter/openai/gpt-4o"
)