mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(proxy): resolve Usage AI chat model groups via the proxy router
The Usage dashboard "Ask AI" feature posts the UI-selected model to /usage/ai/chat, which called litellm.acompletion(model=...) directly. For a configured proxy alias / model group such as "mylitellmmodel" litellm then tried to parse the name as a raw provider/model string and failed with "GetLLMProvider Exception - list index out of range" or "LLM Provider NOT provided ... You passed model=<group>", even though the same name works on /chat/completions. Route the selected model through the proxy router when it is a name the router can resolve (a model group, a model_group_alias, or a deployment id); fall back to a direct litellm call otherwise so the default model and bare provider models keep working. The router is dependency-injected from the endpoint rather than imported inside the streaming helpers. Resolves #24513
This commit is contained in:
parent
1cdb6cd3ac
commit
adb4c5b0d2
3 changed files with 195 additions and 5 deletions
|
|
@ -5,7 +5,19 @@ usage/spend data by querying the aggregated daily activity endpoints.
|
|||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Literal, Optional, cast
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
|
@ -16,6 +28,10 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
|||
SpendAnalyticsPaginatedResponse,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
from litellm.utils import CustomStreamWrapper, ModelResponse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -518,13 +534,37 @@ async def _process_tool_call(
|
|||
)
|
||||
|
||||
|
||||
def _is_router_model(llm_router: "Router", model: str) -> bool:
|
||||
"""True when ``model`` is a name the proxy router can resolve: a configured
|
||||
model group, a model_group_alias, or a concrete deployment id."""
|
||||
return (
|
||||
model in llm_router.model_names
|
||||
or model in llm_router.model_group_alias
|
||||
or llm_router.has_model_id(model)
|
||||
)
|
||||
|
||||
|
||||
def _completion_fn(
|
||||
llm_router: Optional["Router"], model: str
|
||||
) -> Callable[..., Awaitable[Union["ModelResponse", "CustomStreamWrapper"]]]:
|
||||
"""Pick the completion entrypoint for ``model``: the proxy router when it
|
||||
can resolve the name (a configured model group, a model_group_alias, or a
|
||||
deployment id) so Usage AI chat accepts the same names as the UI dropdown,
|
||||
otherwise a direct litellm call."""
|
||||
if llm_router is not None and _is_router_model(llm_router, model):
|
||||
return llm_router.acompletion
|
||||
return litellm.acompletion
|
||||
|
||||
|
||||
async def _stream_final_response(
|
||||
model: str, chat_messages: List[Dict[str, Any]]
|
||||
model: str,
|
||||
chat_messages: List[Dict[str, Any]],
|
||||
llm_router: Optional["Router"],
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream the final LLM response after tool results are appended."""
|
||||
yield _sse({"type": "status", "message": "Analyzing results..."})
|
||||
|
||||
response = await litellm.acompletion(
|
||||
response = await _completion_fn(llm_router, model)(
|
||||
model=model,
|
||||
messages=chat_messages,
|
||||
stream=True,
|
||||
|
|
@ -541,6 +581,7 @@ async def stream_usage_ai_chat(
|
|||
model: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
is_admin: bool = False,
|
||||
llm_router: Optional["Router"] = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream SSE events: status → tool_call → chunk → done."""
|
||||
resolved_model = (model or "").strip() or DEFAULT_COMPETITOR_DISCOVERY_MODEL
|
||||
|
|
@ -555,7 +596,7 @@ async def stream_usage_ai_chat(
|
|||
try:
|
||||
yield _sse({"type": "status", "message": "Thinking..."})
|
||||
tools = get_tools_for_role(is_admin)
|
||||
response = await litellm.acompletion(
|
||||
response = await _completion_fn(llm_router, resolved_model)(
|
||||
model=resolved_model,
|
||||
messages=chat_messages,
|
||||
tools=tools,
|
||||
|
|
@ -573,7 +614,9 @@ async def stream_usage_ai_chat(
|
|||
for tc in choice.message.tool_calls:
|
||||
async for event in _process_tool_call(tc, chat_messages, user_id, is_admin):
|
||||
yield event
|
||||
async for event in _stream_final_response(resolved_model, chat_messages):
|
||||
async for event in _stream_final_response(
|
||||
resolved_model, chat_messages, llm_router
|
||||
):
|
||||
yield event
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ async def usage_ai_chat(
|
|||
from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
|
||||
stream_usage_ai_chat,
|
||||
)
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
is_admin = _user_has_admin_view(user_api_key_dict)
|
||||
if is_admin:
|
||||
|
|
@ -63,6 +64,7 @@ async def usage_ai_chat(
|
|||
model=data.model,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
llm_router=llm_router,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
|
|
|
|||
|
|
@ -12,12 +12,24 @@ from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
|
|||
TOOLS_ADMIN,
|
||||
TOOLS_BASE,
|
||||
_build_system_prompt,
|
||||
_is_router_model,
|
||||
_summarise_entity_data,
|
||||
_summarise_usage_data,
|
||||
stream_usage_ai_chat,
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_router(model_names, *, alias=None, model_ids=()):
|
||||
"""A stand-in proxy router exposing only the attributes the model-resolution
|
||||
logic touches, so tests can inject it without a real Router."""
|
||||
router = MagicMock()
|
||||
router.model_names = set(model_names)
|
||||
router.model_group_alias = dict(alias or {})
|
||||
router.has_model_id = lambda candidate: candidate in set(model_ids)
|
||||
router.acompletion = AsyncMock()
|
||||
return router
|
||||
|
||||
|
||||
SAMPLE_AGGREGATED_RESPONSE = {
|
||||
"results": [
|
||||
{
|
||||
|
|
@ -466,3 +478,136 @@ class TestUsageAiChatServiceAccountGuard:
|
|||
is_admin=False,
|
||||
)
|
||||
assert "Endpoint-level guard missing" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestIsRouterModel:
|
||||
"""The predicate that decides whether a selected model can be resolved by
|
||||
the proxy router rather than treated as a raw provider/model string."""
|
||||
|
||||
def test_configured_model_group(self):
|
||||
router = _make_fake_router({"glm-5"})
|
||||
assert _is_router_model(router, "glm-5") is True
|
||||
|
||||
def test_model_group_alias(self):
|
||||
router = _make_fake_router(set(), alias={"my-alias": "openai/gpt-4o-mini"})
|
||||
assert _is_router_model(router, "my-alias") is True
|
||||
|
||||
def test_concrete_deployment_id(self):
|
||||
router = _make_fake_router(set(), model_ids={"deployment-123"})
|
||||
assert _is_router_model(router, "deployment-123") is True
|
||||
|
||||
def test_unknown_model_is_not_router_resolvable(self):
|
||||
router = _make_fake_router({"glm-5"})
|
||||
assert _is_router_model(router, "openai/gpt-4o-mini") is False
|
||||
|
||||
|
||||
def _tool_call_then_stream():
|
||||
"""Build (first_response_with_tool_call, streaming_response) mock pair that
|
||||
drives stream_usage_ai_chat through both of its completion calls."""
|
||||
tool_call = MagicMock()
|
||||
tool_call.id = "call_1"
|
||||
tool_call.function.name = "get_usage_data"
|
||||
tool_call.function.arguments = json.dumps(
|
||||
{"start_date": "2025-01-01", "end_date": "2025-01-31"}
|
||||
)
|
||||
|
||||
first_response = MagicMock()
|
||||
first_response.choices = [MagicMock()]
|
||||
first_response.choices[0].message.tool_calls = [tool_call]
|
||||
first_response.choices[0].message.model_dump.return_value = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_usage_data",
|
||||
"arguments": '{"start_date":"2025-01-01","end_date":"2025-01-31"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def stream():
|
||||
chunk = MagicMock()
|
||||
chunk.choices = [MagicMock()]
|
||||
chunk.choices[0].delta.content = "Total spend is $50.25"
|
||||
yield chunk
|
||||
|
||||
return first_response, stream()
|
||||
|
||||
|
||||
class TestModelResolutionRouting:
|
||||
"""Regression for GitHub #24513: a selected proxy alias / model group must
|
||||
resolve through the router instead of being passed to litellm.acompletion
|
||||
as a raw provider/model string."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_model_group_routes_through_router(self):
|
||||
first_response, stream = _tool_call_then_stream()
|
||||
fake_router = _make_fake_router({"mylitellmmodel"})
|
||||
fake_router.acompletion = AsyncMock(side_effect=[first_response, stream])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
|
||||
) as mock_litellm,
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat._fetch_usage_data",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SAMPLE_AGGREGATED_RESPONSE,
|
||||
),
|
||||
):
|
||||
mock_litellm.acompletion = AsyncMock()
|
||||
|
||||
events = []
|
||||
async for event in stream_usage_ai_chat(
|
||||
messages=[{"role": "user", "content": "What is my total spend?"}],
|
||||
model="mylitellmmodel",
|
||||
user_id="user-1",
|
||||
is_admin=True,
|
||||
llm_router=fake_router,
|
||||
):
|
||||
events.append(json.loads(event.replace("data: ", "").strip()))
|
||||
|
||||
mock_litellm.acompletion.assert_not_called()
|
||||
assert fake_router.acompletion.await_count == 2
|
||||
assert (
|
||||
fake_router.acompletion.await_args_list[0].kwargs["model"]
|
||||
== "mylitellmmodel"
|
||||
)
|
||||
assert (
|
||||
fake_router.acompletion.await_args_list[1].kwargs["model"]
|
||||
== "mylitellmmodel"
|
||||
)
|
||||
assert any(e["type"] == "done" for e in events)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconfigured_model_falls_back_to_litellm(self):
|
||||
first_response, stream = _tool_call_then_stream()
|
||||
fake_router = _make_fake_router({"some-other-group"})
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
|
||||
) as mock_litellm,
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat._fetch_usage_data",
|
||||
new_callable=AsyncMock,
|
||||
return_value=SAMPLE_AGGREGATED_RESPONSE,
|
||||
),
|
||||
):
|
||||
mock_litellm.acompletion = AsyncMock(side_effect=[first_response, stream])
|
||||
|
||||
async for _ in stream_usage_ai_chat(
|
||||
messages=[{"role": "user", "content": "spend?"}],
|
||||
model="openai/gpt-4o-mini",
|
||||
user_id="user-1",
|
||||
is_admin=True,
|
||||
llm_router=fake_router,
|
||||
):
|
||||
pass
|
||||
|
||||
fake_router.acompletion.assert_not_called()
|
||||
assert mock_litellm.acompletion.await_count == 2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue