mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-28 06:23:07 +00:00
fix(websearch): leave a follow-up model alone when its prefix already routes to the request provider
This commit is contained in:
parent
d9026cbcf2
commit
9665a15bf6
4 changed files with 76 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue