mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(proxy): rebuild Usage Ask AI as a proxy client instead of a bare SDK caller
The Usage dashboard "Ask AI" feature posted the UI-selected model to /usage/ai/chat, and the backend called litellm.acompletion(model=...) directly. When the selected model is a configured proxy alias or model group, the bare SDK tried to parse the name as a raw provider/model string and the call failed, even though the same name works on /chat/completions. Every failure was collapsed into a generic "An internal error occurred", so the real cause was invisible. This replaces the whole usage_endpoints implementation with a v2 that routes the LLM call through the proxy's own llm_router, so model groups resolve and the call gets credentials, spend logging, budgets, rate limits, and guardrails like any other proxy request. Data access moves behind a ScopedUsageDataProvider whose authorization is baked in at construction: a non-admin caller gets a provider that can only ever read its own user_id, so a cross-tenant query is unrepresentable rather than something each tool has to remember to guard, and team/tag breakdowns are refused outright for non-admins. The tool loop now runs multiple rounds and streams the answer, terminal failures are modeled as a tagged union mapped to actionable messages, and the model comes from a usage_ai_model general setting rather than a hardcoded default. The SSE wire contract is unchanged, so the frontend panel is untouched apart from the model-selector placeholder that no longer advertises a hardcoded default.
This commit is contained in:
parent
3e9e52042a
commit
5ce28fb8e2
12 changed files with 1184 additions and 1047 deletions
|
|
@ -27633,7 +27633,7 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Model to use for AI chat",
|
||||
"description": "Model group to use for AI chat",
|
||||
"title": "Model"
|
||||
}
|
||||
},
|
||||
|
|
@ -27681,7 +27681,7 @@
|
|||
"paths": {
|
||||
"/usage/ai/chat": {
|
||||
"post": {
|
||||
"description": "AI chat about usage data. Streams SSE events with the AI response.\nThe AI agent has access to tools that query aggregated daily activity data.",
|
||||
"description": "AI chat about usage data. Streams SSE events with the AI response.\n\nThe agent queries aggregated daily activity data through a provider scoped\nto the caller: admins get a global view, non-admins are restricted to their\nown ``user_id``.",
|
||||
"operationId": "usage_ai_chat_usage_ai_chat_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
|
|
|||
401
litellm/proxy/management_endpoints/usage_endpoints/agent.py
Normal file
401
litellm/proxy/management_endpoints/usage_endpoints/agent.py
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
"""Ask AI agent: answers usage/spend questions by driving an LLM tool-calling
|
||||
loop over the scoped usage data provider.
|
||||
|
||||
The LLM call goes through the proxy's own ``llm_router`` (not the bare
|
||||
``litellm`` SDK), so UI-selected model groups / aliases resolve exactly as they
|
||||
do on ``/chat/completions``, and the call is credentialed, logged, budgeted,
|
||||
rate-limited, and guardrailed like any other proxy request.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Set, Union, cast
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from typing_extensions import TypedDict, assert_never
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.router import Router
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import (
|
||||
ChatCompletionMessageToolCall,
|
||||
Choices,
|
||||
Message,
|
||||
ModelResponse,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.scoped_data import (
|
||||
ScopedUsageDataProvider,
|
||||
summarise_entity_data,
|
||||
summarise_usage_data,
|
||||
)
|
||||
|
||||
USAGE_AI_TEMPERATURE = 0.2
|
||||
MAX_CHAT_MESSAGES = 20
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
USAGE_AI_MODEL_SETTING = "usage_ai_model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE wire events (kept identical to the v1 contract the frontend consumes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SSEStatusEvent(TypedDict):
|
||||
type: Literal["status"]
|
||||
message: str
|
||||
|
||||
|
||||
class SSEToolCallEvent(TypedDict, total=False):
|
||||
type: Literal["tool_call"]
|
||||
tool_name: str
|
||||
tool_label: str
|
||||
arguments: Dict[str, str]
|
||||
status: Literal["running", "complete", "error"]
|
||||
|
||||
|
||||
class SSEChunkEvent(TypedDict):
|
||||
type: Literal["chunk"]
|
||||
content: str
|
||||
|
||||
|
||||
class SSEDoneEvent(TypedDict):
|
||||
type: Literal["done"]
|
||||
|
||||
|
||||
class SSEErrorEvent(TypedDict):
|
||||
type: Literal["error"]
|
||||
message: str
|
||||
|
||||
|
||||
SSEEvent = Union[SSEStatusEvent, SSEToolCallEvent, SSEChunkEvent, SSEDoneEvent, SSEErrorEvent]
|
||||
|
||||
|
||||
def _sse(event: SSEEvent) -> str:
|
||||
return f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Terminal errors modelled as values, mapped to the SSE error contract once
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelNotConfigured:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouterUnavailable:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LLMCallError:
|
||||
detail: str
|
||||
|
||||
|
||||
UsageAiError = Union[ModelNotConfigured, RouterUnavailable, LLMCallError]
|
||||
|
||||
|
||||
def _error_event(err: UsageAiError) -> SSEErrorEvent:
|
||||
match err:
|
||||
case ModelNotConfigured():
|
||||
message = (
|
||||
"No model is configured for Ask AI. Pick a model in the selector, "
|
||||
f"or set '{USAGE_AI_MODEL_SETTING}' under general_settings."
|
||||
)
|
||||
case RouterUnavailable():
|
||||
message = "The proxy has no model router initialized yet. Try again in a moment."
|
||||
case LLMCallError():
|
||||
message = "The AI request failed. Confirm the selected model is configured on this proxy and reachable."
|
||||
case _:
|
||||
assert_never(err)
|
||||
return {"type": "error", "message": message}
|
||||
|
||||
|
||||
class _RouterUnavailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _require_router() -> Router:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
if llm_router is None:
|
||||
raise _RouterUnavailableError()
|
||||
return llm_router
|
||||
|
||||
|
||||
def _assembled_message(chunks: List[object]) -> Optional[Message]:
|
||||
"""Reassemble streamed chunks into a single message (content + tool_calls)."""
|
||||
built = litellm.stream_chunk_builder(chunks)
|
||||
if not isinstance(built, ModelResponse) or not built.choices:
|
||||
return None
|
||||
choice = built.choices[0]
|
||||
return choice.message if isinstance(choice, Choices) else None # pyright: ignore[reportUnnecessaryIsInstance] # choices[0] can be StreamingChoices at runtime
|
||||
|
||||
|
||||
def resolve_model(requested: Optional[str]) -> Union[str, ModelNotConfigured]:
|
||||
"""Resolve the model group to use: explicit request wins, then the
|
||||
configured ``usage_ai_model`` setting, else an actionable error value."""
|
||||
explicit = (requested or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
configured = TypeAdapter(Optional[str]).validate_python(general_settings.get(USAGE_AI_MODEL_SETTING))
|
||||
stripped = (configured or "").strip()
|
||||
return stripped or ModelNotConfigured()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tools (OpenAI function-calling schema) + typed argument validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DATE_PARAMS = {
|
||||
"start_date": {"type": "string", "description": "Start date in YYYY-MM-DD format"},
|
||||
"end_date": {"type": "string", "description": "End date in YYYY-MM-DD format"},
|
||||
}
|
||||
|
||||
_TOOL_USAGE = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_usage_data",
|
||||
"description": (
|
||||
"Fetch aggregated usage/spend data. Returns daily spend, token counts, "
|
||||
"request counts, and breakdowns by model and provider. Use for overall "
|
||||
"spend, top models, and top providers."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_DATE_PARAMS,
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "Optional user ID filter (admin only). Omit for global view.",
|
||||
},
|
||||
},
|
||||
"required": ["start_date", "end_date"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_TOOL_TEAM = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_team_usage_data",
|
||||
"description": (
|
||||
"Fetch usage/spend data broken down by team. Use for questions like "
|
||||
"'which team spends the most' or 'show me team X usage'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_DATE_PARAMS,
|
||||
"team_ids": {"type": "string", "description": "Optional comma-separated team IDs. Omit for all teams."},
|
||||
},
|
||||
"required": ["start_date", "end_date"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_TOOL_TAG = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_tag_usage_data",
|
||||
"description": (
|
||||
"Fetch usage/spend data broken down by tag. Tags are labels attached to "
|
||||
"requests (features, environments, credentials)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_DATE_PARAMS,
|
||||
"tags": {"type": "string", "description": "Optional comma-separated tag names. Omit for all tags."},
|
||||
},
|
||||
"required": ["start_date", "end_date"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def tools_for_role(is_admin: bool) -> List[Dict[str, Any]]:
|
||||
return [_TOOL_USAGE, _TOOL_TEAM, _TOOL_TAG] if is_admin else [_TOOL_USAGE]
|
||||
|
||||
|
||||
_TOOL_LABELS = {
|
||||
"get_usage_data": "global usage data",
|
||||
"get_team_usage_data": "team usage data",
|
||||
"get_tag_usage_data": "tag usage data",
|
||||
}
|
||||
|
||||
|
||||
class _UsageArgs(BaseModel):
|
||||
start_date: str
|
||||
end_date: str
|
||||
user_id: Optional[str] = None
|
||||
|
||||
|
||||
class _TeamArgs(BaseModel):
|
||||
start_date: str
|
||||
end_date: str
|
||||
team_ids: Optional[str] = None
|
||||
|
||||
|
||||
class _TagArgs(BaseModel):
|
||||
start_date: str
|
||||
end_date: str
|
||||
tags: Optional[str] = None
|
||||
|
||||
|
||||
async def _dispatch_tool(name: str, raw_args: Dict[str, Any], provider: ScopedUsageDataProvider) -> str:
|
||||
if name == "get_usage_data":
|
||||
args = _UsageArgs.model_validate(raw_args)
|
||||
data = await provider.usage(args.start_date, args.end_date, args.user_id)
|
||||
return summarise_usage_data(data)
|
||||
if name == "get_team_usage_data":
|
||||
team_args = _TeamArgs.model_validate(raw_args)
|
||||
team_data = await provider.team(team_args.start_date, team_args.end_date, team_args.team_ids)
|
||||
return summarise_entity_data(team_data, "Team")
|
||||
if name == "get_tag_usage_data":
|
||||
tag_args = _TagArgs.model_validate(raw_args)
|
||||
tag_data = await provider.tag(tag_args.start_date, tag_args.end_date, tag_args.tags)
|
||||
return summarise_entity_data(tag_data, "Tag")
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SYSTEM_PROMPT_BASE = (
|
||||
"You are an AI assistant embedded in the LiteLLM Usage dashboard. "
|
||||
"You help users understand their LLM API spend and usage data.\n\n"
|
||||
"ALWAYS call the appropriate tool(s) first to fetch data before answering. "
|
||||
"You may call multiple tools, across multiple turns, if the question spans "
|
||||
"different dimensions or needs follow-up lookups.\n\n"
|
||||
"Guidelines:\n"
|
||||
"- Be concise and specific. Use exact numbers from the data.\n"
|
||||
"- Format costs as dollar amounts (e.g. $12.34).\n"
|
||||
"- When comparing entities, show a ranked list.\n"
|
||||
"- If data is empty or no results found, say so clearly.\n"
|
||||
"- Do not hallucinate data; only use what the tools return.\n"
|
||||
"- Today's date is provided below; use it to interpret relative dates like "
|
||||
"'this week', 'this month', or 'last 7 days'."
|
||||
)
|
||||
|
||||
_TOOL_DESCRIPTIONS_ADMIN = (
|
||||
"You have access to these tools:\n"
|
||||
"- `get_usage_data`: Global/user-level usage (spend, models, providers)\n"
|
||||
"- `get_team_usage_data`: Team-level usage breakdown\n"
|
||||
"- `get_tag_usage_data`: Tag-level usage breakdown\n\n"
|
||||
)
|
||||
|
||||
_TOOL_DESCRIPTIONS_BASE = (
|
||||
"You have access to this tool:\n- `get_usage_data`: Your usage data (spend, models, providers)\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _system_prompt(is_admin: bool) -> str:
|
||||
tool_desc = _TOOL_DESCRIPTIONS_ADMIN if is_admin else _TOOL_DESCRIPTIONS_BASE
|
||||
return f"{_SYSTEM_PROMPT_BASE}\n\n{tool_desc}Today's date: {date.today().isoformat()}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming agent loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _run_tool_call(
|
||||
tc: ChatCompletionMessageToolCall,
|
||||
provider: ScopedUsageDataProvider,
|
||||
allowed_names: Set[str],
|
||||
convo: List[Dict[str, Any]],
|
||||
) -> AsyncIterator[str]:
|
||||
"""Execute one tool call, yield status events, and append its result to convo."""
|
||||
name = tc.function.name
|
||||
try:
|
||||
parsed = json.loads(tc.function.arguments or "{}")
|
||||
except json.JSONDecodeError:
|
||||
parsed = {}
|
||||
raw_args = parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
if name not in allowed_names:
|
||||
convo.append({"role": "tool", "tool_call_id": tc.id, "content": f"Tool not available: {name}"})
|
||||
return
|
||||
|
||||
label = _TOOL_LABELS.get(name, name)
|
||||
base: Dict[str, Any] = {"type": "tool_call", "tool_name": name, "tool_label": label, "arguments": raw_args}
|
||||
yield _sse(cast(SSEToolCallEvent, {**base, "status": "running"}))
|
||||
|
||||
try:
|
||||
result = await _dispatch_tool(name, raw_args, provider)
|
||||
yield _sse(cast(SSEToolCallEvent, {**base, "status": "complete"}))
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("Usage AI tool %s failed: %s", name, e)
|
||||
result = f"Error fetching {label}. Please try again."
|
||||
yield _sse(cast(SSEToolCallEvent, {**base, "status": "error"}))
|
||||
|
||||
convo.append({"role": "tool", "tool_call_id": tc.id, "content": result})
|
||||
|
||||
|
||||
async def stream_usage_ai_chat(
|
||||
provider: ScopedUsageDataProvider,
|
||||
messages: List[Dict[str, str]],
|
||||
model: Optional[str] = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream SSE events: status -> tool_call -> chunk -> done (or a single error)."""
|
||||
resolved = resolve_model(model)
|
||||
if isinstance(resolved, ModelNotConfigured):
|
||||
yield _sse(_error_event(resolved))
|
||||
return
|
||||
|
||||
tools = tools_for_role(provider.is_admin)
|
||||
allowed_names = {t["function"]["name"] for t in tools}
|
||||
history = messages[-MAX_CHAT_MESSAGES:]
|
||||
convo: List[Dict[str, Any]] = [{"role": "system", "content": _system_prompt(provider.is_admin)}, *history]
|
||||
|
||||
try:
|
||||
router = _require_router()
|
||||
yield _sse({"type": "status", "message": "Thinking..."})
|
||||
|
||||
for round_index in range(MAX_TOOL_ROUNDS + 1):
|
||||
use_tools = tools if round_index < MAX_TOOL_ROUNDS else None
|
||||
chunks: List[object] = []
|
||||
response = await router.acompletion(
|
||||
model=resolved,
|
||||
messages=cast(List[AllMessageValues], convo),
|
||||
tools=use_tools,
|
||||
stream=True,
|
||||
temperature=USAGE_AI_TEMPERATURE,
|
||||
metadata={"feature": "usage_ai"},
|
||||
)
|
||||
async for chunk in response:
|
||||
choices = getattr(chunk, "choices", None)
|
||||
delta = choices[0].delta if choices else None
|
||||
content = getattr(delta, "content", None) if delta is not None else None
|
||||
if content:
|
||||
yield _sse({"type": "chunk", "content": content})
|
||||
chunks.append(chunk)
|
||||
|
||||
message = _assembled_message(chunks)
|
||||
tool_calls = message.tool_calls if message is not None else None
|
||||
|
||||
if not message or not tool_calls:
|
||||
yield _sse({"type": "done"})
|
||||
return
|
||||
|
||||
convo.append(message.model_dump())
|
||||
for tc in tool_calls:
|
||||
async for event in _run_tool_call(tc, provider, allowed_names, convo):
|
||||
yield event
|
||||
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
except _RouterUnavailableError:
|
||||
yield _sse(_error_event(RouterUnavailable()))
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("Usage AI chat failed: %s", e)
|
||||
yield _sse(_error_event(LLMCallError(detail=str(e))))
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
"""
|
||||
AI Usage Chat - uses LLM tool calling to answer questions about
|
||||
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_extensions import TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import DEFAULT_COMPETITOR_DISCOVERY_MODEL
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
||||
SpendAnalyticsPaginatedResponse,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
USAGE_AI_TEMPERATURE = 0.2
|
||||
|
||||
TABLE_DAILY_USER_SPEND = "litellm_dailyuserspend"
|
||||
TABLE_DAILY_TEAM_SPEND = "litellm_dailyteamspend"
|
||||
TABLE_DAILY_TAG_SPEND = "litellm_dailytagspend"
|
||||
|
||||
ENTITY_FIELD_USER = "user_id"
|
||||
ENTITY_FIELD_TEAM = "team_id"
|
||||
ENTITY_FIELD_TAG = "tag"
|
||||
|
||||
PAGINATED_PAGE_SIZE = 200
|
||||
MAX_CHAT_MESSAGES = 20
|
||||
TOP_N_MODELS = 15
|
||||
TOP_N_PROVIDERS = 10
|
||||
TOP_N_KEYS = 10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SSEStatusEvent(TypedDict):
|
||||
type: Literal["status"]
|
||||
message: str
|
||||
|
||||
|
||||
class SSEToolCallEvent(TypedDict, total=False):
|
||||
type: Literal["tool_call"]
|
||||
tool_name: str
|
||||
tool_label: str
|
||||
arguments: Dict[str, str]
|
||||
status: Literal["running", "complete", "error"]
|
||||
error: str
|
||||
|
||||
|
||||
class SSEChunkEvent(TypedDict):
|
||||
type: Literal["chunk"]
|
||||
content: str
|
||||
|
||||
|
||||
class SSEDoneEvent(TypedDict):
|
||||
type: Literal["done"]
|
||||
|
||||
|
||||
class SSEErrorEvent(TypedDict):
|
||||
type: Literal["error"]
|
||||
message: str
|
||||
|
||||
|
||||
SSEEvent = SSEStatusEvent | SSEToolCallEvent | SSEChunkEvent | SSEDoneEvent | SSEErrorEvent
|
||||
|
||||
|
||||
class ToolHandler(TypedDict):
|
||||
fetch: Callable[..., Any]
|
||||
summarise: Callable[[Dict[str, Any]], str]
|
||||
label: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool definitions (OpenAI function-calling schema)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DATE_PARAMS = {
|
||||
"start_date": {"type": "string", "description": "Start date in YYYY-MM-DD format"},
|
||||
"end_date": {"type": "string", "description": "End date in YYYY-MM-DD format"},
|
||||
}
|
||||
|
||||
_TOOL_USAGE = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_usage_data",
|
||||
"description": (
|
||||
"Fetch aggregated global usage/spend data. Returns daily spend, "
|
||||
"token counts, request counts, and breakdowns by model, provider, "
|
||||
"and API key. Use for overall spend, top models, top providers."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_DATE_PARAMS,
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "Optional user ID filter. Omit for global view.",
|
||||
},
|
||||
},
|
||||
"required": ["start_date", "end_date"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_TOOL_TEAM = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_team_usage_data",
|
||||
"description": (
|
||||
"Fetch usage/spend data broken down by team. Use for questions "
|
||||
"like 'which team spends the most' or 'show me team X usage'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_DATE_PARAMS,
|
||||
"team_ids": {
|
||||
"type": "string",
|
||||
"description": "Optional comma-separated team IDs. Omit for all teams.",
|
||||
},
|
||||
},
|
||||
"required": ["start_date", "end_date"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_TOOL_TAG = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_tag_usage_data",
|
||||
"description": (
|
||||
"Fetch usage/spend data broken down by tag. Tags are labels "
|
||||
"attached to requests (features, environments, credentials)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_DATE_PARAMS,
|
||||
"tags": {
|
||||
"type": "string",
|
||||
"description": "Optional comma-separated tag names. Omit for all tags.",
|
||||
},
|
||||
},
|
||||
"required": ["start_date", "end_date"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
TOOLS_BASE = [_TOOL_USAGE]
|
||||
TOOLS_ADMIN = [_TOOL_USAGE, _TOOL_TEAM, _TOOL_TAG]
|
||||
|
||||
|
||||
def get_tools_for_role(is_admin: bool) -> List[Dict[str, Any]]:
|
||||
"""Return the tool list appropriate for the user's role."""
|
||||
return TOOLS_ADMIN if is_admin else TOOLS_BASE
|
||||
|
||||
|
||||
_SYSTEM_PROMPT_BASE = (
|
||||
"You are an AI assistant embedded in the LiteLLM Usage dashboard. "
|
||||
"You help users understand their LLM API spend and usage data.\n\n"
|
||||
"ALWAYS call the appropriate tool(s) first to fetch data before answering. "
|
||||
"You may call multiple tools if the question spans different dimensions.\n\n"
|
||||
"Guidelines:\n"
|
||||
"- Be concise and specific. Use exact numbers from the data.\n"
|
||||
"- Format costs as dollar amounts (e.g. $12.34).\n"
|
||||
"- When comparing entities, show a ranked list.\n"
|
||||
"- If data is empty or no results found, say so clearly.\n"
|
||||
"- Do not hallucinate data — only use what the tools return.\n"
|
||||
"- Today's date will be provided below. Use it to interpret relative dates "
|
||||
"like 'this week', 'this month', 'last 7 days', etc."
|
||||
)
|
||||
|
||||
_TOOL_DESCRIPTIONS_ADMIN = (
|
||||
"You have access to these tools:\n"
|
||||
"- `get_usage_data`: Global/user-level usage (spend, models, providers, API keys)\n"
|
||||
"- `get_team_usage_data`: Team-level usage breakdown\n"
|
||||
"- `get_tag_usage_data`: Tag-level usage breakdown\n\n"
|
||||
)
|
||||
|
||||
_TOOL_DESCRIPTIONS_BASE = (
|
||||
"You have access to this tool:\n- `get_usage_data`: Your usage data (spend, models, providers, API keys)\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_system_prompt(is_admin: bool) -> str:
|
||||
"""Build role-appropriate system prompt with today's date."""
|
||||
tool_desc = _TOOL_DESCRIPTIONS_ADMIN if is_admin else _TOOL_DESCRIPTIONS_BASE
|
||||
return f"{_SYSTEM_PROMPT_BASE}\n\n{tool_desc}Today's date: {date.today().isoformat()}"
|
||||
|
||||
|
||||
# keep a public reference for test assertions
|
||||
SYSTEM_PROMPT = _SYSTEM_PROMPT_BASE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data fetchers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_csv_ids(raw: Optional[str]) -> Optional[List[str]]:
|
||||
if not raw:
|
||||
return None
|
||||
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||
|
||||
|
||||
async def _query_activity(
|
||||
table_name: str,
|
||||
entity_id_field: str,
|
||||
entity_id: Optional[Any],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
use_aggregated: bool = False,
|
||||
) -> SpendAnalyticsPaginatedResponse:
|
||||
"""Shared helper that calls the daily activity query layer."""
|
||||
from litellm.proxy.management_endpoints.common_daily_activity import (
|
||||
get_daily_activity,
|
||||
get_daily_activity_aggregated,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if use_aggregated:
|
||||
return await get_daily_activity_aggregated(
|
||||
prisma_client=prisma_client,
|
||||
table_name=table_name,
|
||||
entity_id_field=entity_id_field,
|
||||
entity_id=entity_id,
|
||||
entity_metadata_field=None,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
model=None,
|
||||
api_key=None,
|
||||
)
|
||||
return await get_daily_activity(
|
||||
prisma_client=prisma_client,
|
||||
table_name=table_name,
|
||||
entity_id_field=entity_id_field,
|
||||
entity_id=entity_id,
|
||||
entity_metadata_field=None,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
model=None,
|
||||
api_key=None,
|
||||
page=1,
|
||||
page_size=PAGINATED_PAGE_SIZE,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_usage_data(start_date: str, end_date: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
resp = await _query_activity(
|
||||
TABLE_DAILY_USER_SPEND,
|
||||
ENTITY_FIELD_USER,
|
||||
user_id,
|
||||
start_date,
|
||||
end_date,
|
||||
use_aggregated=True,
|
||||
)
|
||||
return resp.model_dump(mode="json")
|
||||
|
||||
|
||||
async def _fetch_team_usage_data(start_date: str, end_date: str, team_ids: Optional[str] = None) -> Dict[str, Any]:
|
||||
resp = await _query_activity(
|
||||
TABLE_DAILY_TEAM_SPEND,
|
||||
ENTITY_FIELD_TEAM,
|
||||
_parse_csv_ids(team_ids),
|
||||
start_date,
|
||||
end_date,
|
||||
)
|
||||
return resp.model_dump(mode="json")
|
||||
|
||||
|
||||
async def _fetch_tag_usage_data(start_date: str, end_date: str, tags: Optional[str] = None) -> Dict[str, Any]:
|
||||
resp = await _query_activity(
|
||||
TABLE_DAILY_TAG_SPEND,
|
||||
ENTITY_FIELD_TAG,
|
||||
_parse_csv_ids(tags),
|
||||
start_date,
|
||||
end_date,
|
||||
)
|
||||
return resp.model_dump(mode="json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summarisers — convert raw JSON to concise text the LLM can reason over
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _accumulate_breakdown(
|
||||
results: List[Dict[str, Any]], dimension: str, fields: List[str]
|
||||
) -> Dict[str, Dict[str, float]]:
|
||||
"""Aggregate a single breakdown dimension across days."""
|
||||
totals: Dict[str, Dict[str, float]] = {}
|
||||
for day in results:
|
||||
for key, entry in day.get("breakdown", {}).get(dimension, {}).items():
|
||||
if key not in totals:
|
||||
totals[key] = {f: 0.0 for f in fields}
|
||||
m = entry.get("metrics", {})
|
||||
for f in fields:
|
||||
totals[key][f] += m.get(f, 0)
|
||||
return totals
|
||||
|
||||
|
||||
def _ranked_lines(
|
||||
totals: Dict[str, Dict[str, float]],
|
||||
fmt: Callable[[str, Dict[str, float]], str],
|
||||
limit: int,
|
||||
) -> List[str]:
|
||||
"""Sort by spend descending, format each entry, and truncate."""
|
||||
return [fmt(name, vals) for name, vals in sorted(totals.items(), key=lambda x: -x[1].get("spend", 0))[:limit]]
|
||||
|
||||
|
||||
def _summarise_usage_data(data: Dict[str, Any]) -> str:
|
||||
meta = data.get("metadata", {})
|
||||
results = data.get("results", [])
|
||||
|
||||
header = (
|
||||
f"Total Spend: ${meta.get('total_spend', 0):.4f}\n"
|
||||
f"Total Requests: {meta.get('total_api_requests', 0)}\n"
|
||||
f"Successful: {meta.get('total_successful_requests', 0)} | "
|
||||
f"Failed: {meta.get('total_failed_requests', 0)}\n"
|
||||
f"Total Tokens: {meta.get('total_tokens', 0)}"
|
||||
)
|
||||
|
||||
models = _accumulate_breakdown(results, "models", ["spend", "api_requests", "total_tokens"])
|
||||
providers = _accumulate_breakdown(results, "providers", ["spend", "api_requests"])
|
||||
|
||||
model_lines = _ranked_lines(
|
||||
models,
|
||||
lambda n, d: f" - {n}: ${d['spend']:.4f} ({int(d['api_requests'])} reqs, {int(d['total_tokens'])} tokens)",
|
||||
TOP_N_MODELS,
|
||||
)
|
||||
provider_lines = _ranked_lines(
|
||||
providers,
|
||||
lambda n, d: f" - {n}: ${d['spend']:.4f} ({int(d['api_requests'])} reqs)",
|
||||
TOP_N_PROVIDERS,
|
||||
)
|
||||
|
||||
sections = [header, ""]
|
||||
sections += ["Top Models by Spend:"] + (model_lines or [" (no data)"]) + [""]
|
||||
sections += ["Top Providers by Spend:"] + (provider_lines or [" (no data)"])
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def _summarise_entity_data(data: Dict[str, Any], entity_label: str) -> str:
|
||||
"""Summarise team/tag entity usage data."""
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return f"No {entity_label} usage data found for the given date range."
|
||||
|
||||
totals: Dict[str, Dict[str, Any]] = {}
|
||||
for day in results:
|
||||
for eid, entry in day.get("breakdown", {}).get("entities", {}).items():
|
||||
if eid not in totals:
|
||||
alias = entry.get("metadata", {}).get("alias", eid)
|
||||
totals[eid] = {"alias": alias, "spend": 0.0, "requests": 0, "tokens": 0}
|
||||
m = entry.get("metrics", {})
|
||||
totals[eid]["spend"] += m.get("spend", 0)
|
||||
totals[eid]["requests"] += m.get("api_requests", 0)
|
||||
totals[eid]["tokens"] += m.get("total_tokens", 0)
|
||||
|
||||
lines = [f"{entity_label} Usage ({len(totals)} {entity_label.lower()}s):", ""]
|
||||
for eid, d in sorted(totals.items(), key=lambda x: -x[1]["spend"]):
|
||||
label = d["alias"] if d["alias"] != eid else eid
|
||||
lines.append(
|
||||
f"- {label} (ID: {eid}): ${d['spend']:.4f} | {int(d['requests'])} reqs | {int(d['tokens'])} tokens"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool dispatch registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_HANDLERS: Dict[str, ToolHandler] = {
|
||||
"get_usage_data": ToolHandler(
|
||||
fetch=_fetch_usage_data,
|
||||
summarise=_summarise_usage_data,
|
||||
label="global usage data",
|
||||
),
|
||||
"get_team_usage_data": ToolHandler(
|
||||
fetch=_fetch_team_usage_data,
|
||||
summarise=lambda data: _summarise_entity_data(data, "Team"),
|
||||
label="team usage data",
|
||||
),
|
||||
"get_tag_usage_data": ToolHandler(
|
||||
fetch=_fetch_tag_usage_data,
|
||||
summarise=lambda data: _summarise_entity_data(data, "Tag"),
|
||||
label="tag usage data",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sse(event: SSEEvent) -> str:
|
||||
return f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
|
||||
def _resolve_fetch_kwargs(
|
||||
fn_name: str,
|
||||
fn_args: Dict[str, str],
|
||||
user_id: Optional[str],
|
||||
is_admin: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build keyword arguments for a tool's fetch function."""
|
||||
start_date = fn_args.get("start_date", "")
|
||||
end_date = fn_args.get("end_date", "")
|
||||
if not start_date or not end_date:
|
||||
raise ValueError("Missing required start_date or end_date from tool arguments")
|
||||
kwargs: Dict[str, Any] = {"start_date": start_date, "end_date": end_date}
|
||||
if fn_name == "get_usage_data":
|
||||
if not is_admin:
|
||||
if user_id is None:
|
||||
# Defense-in-depth: the endpoint guard in usage_endpoints/endpoints.py
|
||||
# should have already rejected this. If we ever reach here it means
|
||||
# a future caller invoked the helper without scoping — fail loudly
|
||||
# rather than issuing an unfiltered global query.
|
||||
raise ValueError(
|
||||
"Non-admin caller has user_id=None; refusing to issue an "
|
||||
"unscoped query. Endpoint-level guard missing."
|
||||
)
|
||||
kwargs["user_id"] = user_id
|
||||
elif fn_args.get("user_id"):
|
||||
kwargs["user_id"] = fn_args["user_id"]
|
||||
elif fn_name == "get_team_usage_data" and fn_args.get("team_ids"):
|
||||
kwargs["team_ids"] = fn_args["team_ids"]
|
||||
elif fn_name == "get_tag_usage_data" and fn_args.get("tags"):
|
||||
kwargs["tags"] = fn_args["tags"]
|
||||
return kwargs
|
||||
|
||||
|
||||
async def _execute_tool_call(
|
||||
handler: ToolHandler,
|
||||
fn_name: str,
|
||||
fn_args: Dict[str, str],
|
||||
user_id: Optional[str],
|
||||
is_admin: bool,
|
||||
) -> str:
|
||||
"""Run a single tool and return the summarised result text."""
|
||||
kwargs = _resolve_fetch_kwargs(fn_name, fn_args, user_id, is_admin)
|
||||
raw_data = await handler["fetch"](**kwargs)
|
||||
return handler["summarise"](raw_data)
|
||||
|
||||
|
||||
async def _process_tool_call(
|
||||
tc: Any,
|
||||
chat_messages: List[Dict[str, Any]],
|
||||
user_id: Optional[str],
|
||||
is_admin: bool,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Execute a single tool call, yielding SSE events for status."""
|
||||
fn_name = tc.function.name
|
||||
fn_args = json.loads(tc.function.arguments)
|
||||
|
||||
allowed_names = {t["function"]["name"] for t in get_tools_for_role(is_admin)}
|
||||
handler = TOOL_HANDLERS.get(fn_name)
|
||||
|
||||
if fn_name not in allowed_names or not handler:
|
||||
chat_messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": f"Tool not available: {fn_name}",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
tool_event_base = {
|
||||
"type": "tool_call",
|
||||
"tool_name": fn_name,
|
||||
"tool_label": handler["label"],
|
||||
"arguments": fn_args,
|
||||
}
|
||||
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "running"}))
|
||||
|
||||
try:
|
||||
tool_result = await _execute_tool_call(handler, fn_name, fn_args, user_id, is_admin)
|
||||
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "complete"}))
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("Tool %s failed: %s", fn_name, e)
|
||||
tool_result = f"Error fetching {handler['label']}. Please try again."
|
||||
yield _sse(cast(SSEToolCallEvent, {**tool_event_base, "status": "error"}))
|
||||
|
||||
chat_messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_result})
|
||||
|
||||
|
||||
async def _stream_final_response(model: str, chat_messages: List[Dict[str, Any]]) -> AsyncIterator[str]:
|
||||
"""Stream the final LLM response after tool results are appended."""
|
||||
yield _sse({"type": "status", "message": "Analyzing results..."})
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=chat_messages,
|
||||
stream=True,
|
||||
temperature=USAGE_AI_TEMPERATURE,
|
||||
)
|
||||
async for chunk in response:
|
||||
delta = chunk.choices[0].delta.content
|
||||
if delta:
|
||||
yield _sse({"type": "chunk", "content": delta})
|
||||
|
||||
|
||||
async def stream_usage_ai_chat(
|
||||
messages: List[Dict[str, str]],
|
||||
model: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
is_admin: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream SSE events: status → tool_call → chunk → done."""
|
||||
resolved_model = (model or "").strip() or DEFAULT_COMPETITOR_DISCOVERY_MODEL
|
||||
truncated = messages[-MAX_CHAT_MESSAGES:] if len(messages) > MAX_CHAT_MESSAGES else messages
|
||||
chat_messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": _build_system_prompt(is_admin)},
|
||||
*truncated,
|
||||
]
|
||||
|
||||
try:
|
||||
yield _sse({"type": "status", "message": "Thinking..."})
|
||||
tools = get_tools_for_role(is_admin)
|
||||
response = await litellm.acompletion(
|
||||
model=resolved_model,
|
||||
messages=chat_messages,
|
||||
tools=tools,
|
||||
temperature=USAGE_AI_TEMPERATURE,
|
||||
)
|
||||
choice = response.choices[0] # type: ignore
|
||||
|
||||
if not choice.message.tool_calls:
|
||||
if choice.message.content:
|
||||
yield _sse({"type": "chunk", "content": choice.message.content})
|
||||
yield _sse({"type": "done"})
|
||||
return
|
||||
|
||||
chat_messages.append(choice.message.model_dump())
|
||||
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):
|
||||
yield event
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("AI usage chat failed: %s", e)
|
||||
yield _sse(
|
||||
{
|
||||
"type": "error",
|
||||
"message": "An internal error occurred. Please try again.",
|
||||
}
|
||||
)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
USAGE AI CHAT ENDPOINTS
|
||||
USAGE AI CHAT ENDPOINT
|
||||
|
||||
/usage/ai/chat - Stream AI chat responses about usage data
|
||||
"""
|
||||
|
|
@ -23,7 +23,7 @@ class ChatMessage(BaseModel):
|
|||
|
||||
class UsageAIChatRequest(BaseModel):
|
||||
messages: List[ChatMessage] = Field(..., description="Chat messages (user/assistant history)")
|
||||
model: Optional[str] = Field(default=None, description="Model to use for AI chat")
|
||||
model: Optional[str] = Field(default=None, description="Model group to use for AI chat")
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -38,30 +38,35 @@ async def usage_ai_chat(
|
|||
):
|
||||
"""
|
||||
AI chat about usage data. Streams SSE events with the AI response.
|
||||
The AI agent has access to tools that query aggregated daily activity data.
|
||||
|
||||
The agent queries aggregated daily activity data through a provider scoped
|
||||
to the caller: admins get a global view, non-admins are restricted to their
|
||||
own ``user_id``.
|
||||
"""
|
||||
from litellm.proxy._types import user_api_key_has_admin_view
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_user_has_admin_view,
|
||||
require_caller_user_id_for_non_admin,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.agent import (
|
||||
stream_usage_ai_chat,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.scoped_data import (
|
||||
AdminScope,
|
||||
ScopedUsageDataProvider,
|
||||
UserScope,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
is_admin = _user_has_admin_view(user_api_key_dict)
|
||||
if is_admin:
|
||||
user_id = user_api_key_dict.user_id
|
||||
if user_api_key_has_admin_view(user_api_key_dict):
|
||||
scope = AdminScope(caller_user_id=user_api_key_dict.user_id)
|
||||
else:
|
||||
user_id = require_caller_user_id_for_non_admin(user_api_key_dict)
|
||||
scope = UserScope(user_id=require_caller_user_id_for_non_admin(user_api_key_dict))
|
||||
|
||||
provider = ScopedUsageDataProvider(scope=scope, prisma_client=prisma_client)
|
||||
messages = [{"role": m.role, "content": m.content} for m in data.messages]
|
||||
|
||||
return StreamingResponse(
|
||||
stream_usage_ai_chat(
|
||||
messages=messages,
|
||||
model=data.model,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
),
|
||||
stream_usage_ai_chat(provider=provider, messages=messages, model=data.model),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
"""Authorization-scoped access to aggregated usage/spend data for Ask AI.
|
||||
|
||||
The caller's authorization is baked into the provider at construction. A
|
||||
non-admin caller receives a provider that can only ever read its own
|
||||
``user_id``, so an out-of-scope query is unrepresentable rather than something
|
||||
each tool handler has to remember to guard. Team and tag breakdowns are
|
||||
admin-only and the provider refuses them for a user scope as defense in depth.
|
||||
|
||||
Data is read through the same ``common_daily_activity`` helpers that back the
|
||||
``/spend`` REST endpoints, so there is a single source of truth for the
|
||||
aggregation logic.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
||||
DailySpendData,
|
||||
MetricWithMetadata,
|
||||
SpendAnalyticsPaginatedResponse,
|
||||
SpendMetrics,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
TABLE_DAILY_USER_SPEND = "litellm_dailyuserspend"
|
||||
TABLE_DAILY_TEAM_SPEND = "litellm_dailyteamspend"
|
||||
TABLE_DAILY_TAG_SPEND = "litellm_dailytagspend"
|
||||
|
||||
ENTITY_FIELD_USER = "user_id"
|
||||
ENTITY_FIELD_TEAM = "team_id"
|
||||
ENTITY_FIELD_TAG = "tag"
|
||||
|
||||
PAGINATED_PAGE_SIZE = 200
|
||||
TOP_N_MODELS = 15
|
||||
TOP_N_PROVIDERS = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdminScope:
|
||||
"""Global view. ``caller_user_id`` is the admin's own id (may be None) and
|
||||
is not used to filter; admins may optionally pass an explicit user filter."""
|
||||
|
||||
caller_user_id: Optional[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserScope:
|
||||
"""Non-admin view. Every query is forced to this ``user_id``."""
|
||||
|
||||
user_id: str
|
||||
|
||||
|
||||
AiChatScope = Union[AdminScope, UserScope]
|
||||
|
||||
|
||||
def _parse_csv(raw: Optional[str]) -> Optional[List[str]]:
|
||||
if not raw:
|
||||
return None
|
||||
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||
|
||||
|
||||
class ScopedUsageDataProvider:
|
||||
"""Reads daily activity data within the bounds of an ``AiChatScope``."""
|
||||
|
||||
def __init__(self, scope: AiChatScope, prisma_client: Optional["PrismaClient"]) -> None:
|
||||
self._scope = scope
|
||||
self._prisma_client = prisma_client
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return isinstance(self._scope, AdminScope)
|
||||
|
||||
async def usage(
|
||||
self, start_date: str, end_date: str, user_id_filter: Optional[str]
|
||||
) -> SpendAnalyticsPaginatedResponse:
|
||||
scope = self._scope
|
||||
effective_user_id = scope.user_id if isinstance(scope, UserScope) else user_id_filter
|
||||
from litellm.proxy.management_endpoints.common_daily_activity import (
|
||||
get_daily_activity_aggregated,
|
||||
)
|
||||
|
||||
return await get_daily_activity_aggregated(
|
||||
prisma_client=self._prisma_client,
|
||||
table_name=TABLE_DAILY_USER_SPEND,
|
||||
entity_id_field=ENTITY_FIELD_USER,
|
||||
entity_id=effective_user_id,
|
||||
entity_metadata_field=None,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
model=None,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
async def team(self, start_date: str, end_date: str, team_ids: Optional[str]) -> SpendAnalyticsPaginatedResponse:
|
||||
self._require_admin("team usage")
|
||||
return await self._paginated(
|
||||
TABLE_DAILY_TEAM_SPEND, ENTITY_FIELD_TEAM, _parse_csv(team_ids), start_date, end_date
|
||||
)
|
||||
|
||||
async def tag(self, start_date: str, end_date: str, tags: Optional[str]) -> SpendAnalyticsPaginatedResponse:
|
||||
self._require_admin("tag usage")
|
||||
return await self._paginated(TABLE_DAILY_TAG_SPEND, ENTITY_FIELD_TAG, _parse_csv(tags), start_date, end_date)
|
||||
|
||||
def _require_admin(self, what: str) -> None:
|
||||
if not isinstance(self._scope, AdminScope):
|
||||
raise PermissionError(f"{what} data is only available to admin callers")
|
||||
|
||||
async def _paginated(
|
||||
self,
|
||||
table_name: str,
|
||||
entity_id_field: str,
|
||||
entity_id: Optional[List[str]],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> SpendAnalyticsPaginatedResponse:
|
||||
from litellm.proxy.management_endpoints.common_daily_activity import (
|
||||
get_daily_activity,
|
||||
)
|
||||
|
||||
return await get_daily_activity(
|
||||
prisma_client=self._prisma_client,
|
||||
table_name=table_name,
|
||||
entity_id_field=entity_id_field,
|
||||
entity_id=entity_id,
|
||||
entity_metadata_field=None,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
model=None,
|
||||
api_key=None,
|
||||
page=1,
|
||||
page_size=PAGINATED_PAGE_SIZE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Totals:
|
||||
spend: float
|
||||
api_requests: int
|
||||
total_tokens: int
|
||||
|
||||
def plus(self, m: "SpendMetrics") -> "_Totals":
|
||||
return _Totals(
|
||||
spend=self.spend + m.spend,
|
||||
api_requests=self.api_requests + m.api_requests,
|
||||
total_tokens=self.total_tokens + m.total_tokens,
|
||||
)
|
||||
|
||||
|
||||
def _accumulate(
|
||||
days: List[DailySpendData],
|
||||
pick: Callable[[DailySpendData], Dict[str, MetricWithMetadata]],
|
||||
) -> Dict[str, _Totals]:
|
||||
"""Sum one breakdown dimension across days into per-name totals."""
|
||||
totals: Dict[str, _Totals] = {}
|
||||
for day in days:
|
||||
for name, entry in pick(day).items():
|
||||
totals[name] = totals.get(name, _Totals(0.0, 0, 0)).plus(entry.metrics)
|
||||
return totals
|
||||
|
||||
|
||||
def _ranked(totals: Dict[str, _Totals], limit: int) -> List[Tuple[str, _Totals]]:
|
||||
return sorted(totals.items(), key=lambda item: -item[1].spend)[:limit]
|
||||
|
||||
|
||||
def summarise_usage_data(resp: SpendAnalyticsPaginatedResponse) -> str:
|
||||
"""Render global/user usage into concise text the LLM can reason over."""
|
||||
meta = resp.metadata
|
||||
header = (
|
||||
f"Total Spend: ${meta.total_spend:.4f}\n"
|
||||
f"Total Requests: {meta.total_api_requests}\n"
|
||||
f"Successful: {meta.total_successful_requests} | Failed: {meta.total_failed_requests}\n"
|
||||
f"Total Tokens: {meta.total_tokens}"
|
||||
)
|
||||
|
||||
models = _accumulate(resp.results, lambda d: d.breakdown.models)
|
||||
providers = _accumulate(resp.results, lambda d: d.breakdown.providers)
|
||||
|
||||
model_lines = [
|
||||
f" - {name}: ${t.spend:.4f} ({t.api_requests} reqs, {t.total_tokens} tokens)"
|
||||
for name, t in _ranked(models, TOP_N_MODELS)
|
||||
]
|
||||
provider_lines = [
|
||||
f" - {name}: ${t.spend:.4f} ({t.api_requests} reqs)" for name, t in _ranked(providers, TOP_N_PROVIDERS)
|
||||
]
|
||||
|
||||
sections = [header, ""]
|
||||
sections += ["Top Models by Spend:"] + (model_lines or [" (no data)"]) + [""]
|
||||
sections += ["Top Providers by Spend:"] + (provider_lines or [" (no data)"])
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def _entity_alias(entry: MetricWithMetadata, entity_id: str) -> str:
|
||||
raw = entry.metadata.get("alias")
|
||||
return raw if isinstance(raw, str) and raw else entity_id
|
||||
|
||||
|
||||
def summarise_entity_data(resp: SpendAnalyticsPaginatedResponse, entity_label: str) -> str:
|
||||
"""Render team/tag entity usage into concise text."""
|
||||
if not resp.results:
|
||||
return f"No {entity_label} usage data found for the given date range."
|
||||
|
||||
totals = _accumulate(resp.results, lambda d: d.breakdown.entities)
|
||||
aliases = {eid: _entity_alias(entry, eid) for day in resp.results for eid, entry in day.breakdown.entities.items()}
|
||||
|
||||
lines = [f"{entity_label} Usage ({len(totals)} {entity_label.lower()}s):", ""]
|
||||
for eid, t in _ranked(totals, len(totals)):
|
||||
lines.append(
|
||||
f"- {aliases.get(eid, eid)} (ID: {eid}): ${t.spend:.4f} | {t.api_requests} reqs | {t.total_tokens} tokens"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
"""Tests for the Ask AI agent loop.
|
||||
|
||||
Highest-value regression: the LLM call goes through the proxy's ``llm_router``
|
||||
(so UI-selected model groups resolve), not the bare ``litellm`` SDK, and the
|
||||
scope-forced user_id survives an end-to-end tool round.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.types.utils import (
|
||||
ChatCompletionDeltaToolCall,
|
||||
Delta,
|
||||
)
|
||||
from litellm.types.utils import Function as DeltaFunction
|
||||
from litellm.types.utils import (
|
||||
ModelResponseStream,
|
||||
StreamingChoices,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.usage_endpoints import agent as agent_mod
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.agent import (
|
||||
LLMCallError,
|
||||
ModelNotConfigured,
|
||||
RouterUnavailable,
|
||||
_error_event,
|
||||
resolve_model,
|
||||
stream_usage_ai_chat,
|
||||
tools_for_role,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.scoped_data import (
|
||||
AdminScope,
|
||||
ScopedUsageDataProvider,
|
||||
UserScope,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
||||
SpendAnalyticsPaginatedResponse,
|
||||
)
|
||||
|
||||
DATE_ARGS = {"start_date": "2025-01-01", "end_date": "2025-01-31"}
|
||||
|
||||
SAMPLE_USAGE = {
|
||||
"results": [
|
||||
{
|
||||
"date": "2025-01-15",
|
||||
"metrics": {"spend": 12.5, "api_requests": 100},
|
||||
"breakdown": {"models": {}, "providers": {}, "entities": {}},
|
||||
}
|
||||
],
|
||||
"metadata": {"total_spend": 12.5, "total_api_requests": 100},
|
||||
}
|
||||
|
||||
|
||||
def _content_chunk(text: str) -> ModelResponseStream:
|
||||
return ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(content=text))])
|
||||
|
||||
|
||||
def _toolcall_chunk(call_id: str, name: str, args: dict) -> ModelResponseStream:
|
||||
tc = ChatCompletionDeltaToolCall(
|
||||
index=0, id=call_id, type="function", function=DeltaFunction(name=name, arguments=json.dumps(args))
|
||||
)
|
||||
return ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(tool_calls=[tc]))])
|
||||
|
||||
|
||||
class FakeRouter:
|
||||
"""Router stub whose acompletion returns scripted streaming chunks per call."""
|
||||
|
||||
def __init__(self, scripts):
|
||||
self._scripts = list(scripts)
|
||||
self.calls = []
|
||||
|
||||
async def acompletion(self, **kwargs):
|
||||
idx = len(self.calls)
|
||||
self.calls.append(kwargs)
|
||||
chunks = self._scripts[idx]
|
||||
|
||||
async def _gen():
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
return _gen()
|
||||
|
||||
|
||||
def _usage_response_mock():
|
||||
return SpendAnalyticsPaginatedResponse.model_validate(SAMPLE_USAGE)
|
||||
|
||||
|
||||
async def _collect(provider, messages, model, router):
|
||||
"""Run the agent with a patched router; return parsed SSE events."""
|
||||
with (
|
||||
patch.object(agent_mod, "_require_router", return_value=router),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.common_daily_activity.get_daily_activity_aggregated",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_agg,
|
||||
):
|
||||
mock_agg.return_value = _usage_response_mock()
|
||||
events = []
|
||||
async for raw in stream_usage_ai_chat(provider=provider, messages=messages, model=model):
|
||||
events.append(json.loads(raw.replace("data: ", "").strip()))
|
||||
return events, mock_agg
|
||||
|
||||
|
||||
class TestRoutesThroughProxyRouter:
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_model_group_is_passed_to_llm_router_not_sdk(self):
|
||||
"""The core v2 fix: a UI-selected model group is sent to llm_router,
|
||||
and the bare litellm.acompletion SDK is never called."""
|
||||
router = FakeRouter([[_content_chunk("Your spend is $12.50.")]])
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="a1"), prisma_client=MagicMock())
|
||||
|
||||
with patch.object(litellm, "acompletion", new_callable=AsyncMock) as sdk_call:
|
||||
events, _ = await _collect(provider, [{"role": "user", "content": "spend?"}], "my-model-group", router)
|
||||
|
||||
assert sdk_call.call_count == 0
|
||||
assert router.calls[0]["model"] == "my-model-group"
|
||||
assert router.calls[0]["metadata"] == {"feature": "usage_ai"}
|
||||
chunks = [e for e in events if e["type"] == "chunk"]
|
||||
assert chunks and chunks[0]["content"] == "Your spend is $12.50."
|
||||
assert events[-1]["type"] == "done"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_round_then_streamed_answer(self):
|
||||
router = FakeRouter(
|
||||
[
|
||||
[_toolcall_chunk("c1", "get_usage_data", DATE_ARGS)],
|
||||
[_content_chunk("Total spend is $12.50.")],
|
||||
]
|
||||
)
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="a1"), prisma_client=MagicMock())
|
||||
events, mock_agg = await _collect(provider, [{"role": "user", "content": "spend?"}], "m", router)
|
||||
|
||||
assert mock_agg.call_count == 1
|
||||
tool_events = [e for e in events if e["type"] == "tool_call"]
|
||||
assert {e["status"] for e in tool_events} == {"running", "complete"}
|
||||
assert tool_events[0]["tool_name"] == "get_usage_data"
|
||||
assert any(e["type"] == "chunk" and "$12.50" in e["content"] for e in events)
|
||||
assert events[-1]["type"] == "done"
|
||||
|
||||
|
||||
class TestScopeEnforcedThroughLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_tool_call_cannot_reach_other_users_data(self):
|
||||
"""Even if the model asks for another user's data, the scoped provider
|
||||
forces the caller's own user_id at the data layer."""
|
||||
router = FakeRouter(
|
||||
[
|
||||
[_toolcall_chunk("c1", "get_usage_data", {**DATE_ARGS, "user_id": "victim"})],
|
||||
[_content_chunk("Here is your usage.")],
|
||||
]
|
||||
)
|
||||
provider = ScopedUsageDataProvider(scope=UserScope(user_id="caller"), prisma_client=MagicMock())
|
||||
_events, mock_agg = await _collect(provider, [{"role": "user", "content": "show victim usage"}], "m", router)
|
||||
|
||||
assert mock_agg.call_args.kwargs["entity_id"] == "caller"
|
||||
|
||||
|
||||
class TestMultiRoundLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_tool_rounds_then_answer(self):
|
||||
router = FakeRouter(
|
||||
[
|
||||
[_toolcall_chunk("c1", "get_usage_data", DATE_ARGS)],
|
||||
[_toolcall_chunk("c2", "get_team_usage_data", DATE_ARGS)],
|
||||
[_content_chunk("Engineering leads spend.")],
|
||||
]
|
||||
)
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="a1"), prisma_client=MagicMock())
|
||||
|
||||
with (
|
||||
patch.object(agent_mod, "_require_router", return_value=router),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.common_daily_activity.get_daily_activity_aggregated",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_agg,
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.common_daily_activity.get_daily_activity",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_paginated,
|
||||
):
|
||||
mock_agg.return_value = _usage_response_mock()
|
||||
mock_paginated.return_value = _usage_response_mock()
|
||||
events = [
|
||||
json.loads(raw.replace("data: ", "").strip())
|
||||
async for raw in stream_usage_ai_chat(
|
||||
provider=provider, messages=[{"role": "user", "content": "q"}], model="m"
|
||||
)
|
||||
]
|
||||
|
||||
assert len(router.calls) == 3
|
||||
assert mock_agg.call_count == 1
|
||||
assert mock_paginated.call_count == 1
|
||||
assert any(e["type"] == "chunk" and "Engineering" in e["content"] for e in events)
|
||||
assert events[-1]["type"] == "done"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runaway_tool_calls_are_capped_and_still_answer(self):
|
||||
"""If the model never stops calling tools, the loop caps rounds and
|
||||
forces a final (tool-less) answer instead of looping forever."""
|
||||
# More tool-call rounds scripted than MAX_TOOL_ROUNDS; the final call
|
||||
# is made with tools disabled so it must return content.
|
||||
scripts = [[_toolcall_chunk(f"c{i}", "get_usage_data", DATE_ARGS)] for i in range(agent_mod.MAX_TOOL_ROUNDS)]
|
||||
scripts.append([_content_chunk("Final answer.")])
|
||||
router = FakeRouter(scripts)
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="a1"), prisma_client=MagicMock())
|
||||
|
||||
events, _ = await _collect(provider, [{"role": "user", "content": "q"}], "m", router)
|
||||
|
||||
# Last router call must have had tools disabled (the safety net).
|
||||
assert router.calls[-1]["tools"] is None
|
||||
assert any(e["type"] == "chunk" and "Final answer." in e["content"] for e in events)
|
||||
assert events[-1]["type"] == "done"
|
||||
|
||||
|
||||
class TestErrorPaths:
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_not_configured_errors_without_calling_router(self):
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="a1"), prisma_client=MagicMock())
|
||||
with (
|
||||
patch.object(agent_mod, "_require_router") as require_router,
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
):
|
||||
events = [
|
||||
json.loads(raw.replace("data: ", "").strip())
|
||||
async for raw in stream_usage_ai_chat(
|
||||
provider=provider, messages=[{"role": "user", "content": "q"}], model=None
|
||||
)
|
||||
]
|
||||
|
||||
require_router.assert_not_called()
|
||||
assert len(events) == 1
|
||||
assert events[0]["type"] == "error"
|
||||
assert "usage_ai_model" in events[0]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_unavailable_emits_error(self):
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="a1"), prisma_client=MagicMock())
|
||||
with patch.object(agent_mod, "_require_router", side_effect=agent_mod._RouterUnavailableError()):
|
||||
events = [
|
||||
json.loads(raw.replace("data: ", "").strip())
|
||||
async for raw in stream_usage_ai_chat(
|
||||
provider=provider, messages=[{"role": "user", "content": "q"}], model="m"
|
||||
)
|
||||
]
|
||||
|
||||
error_events = [e for e in events if e["type"] == "error"]
|
||||
assert len(error_events) == 1
|
||||
assert "router" in error_events[0]["message"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_exception_emits_error(self):
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="a1"), prisma_client=MagicMock())
|
||||
broken_router = MagicMock()
|
||||
broken_router.acompletion = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
with patch.object(agent_mod, "_require_router", return_value=broken_router):
|
||||
events = [
|
||||
json.loads(raw.replace("data: ", "").strip())
|
||||
async for raw in stream_usage_ai_chat(
|
||||
provider=provider, messages=[{"role": "user", "content": "q"}], model="m"
|
||||
)
|
||||
]
|
||||
|
||||
error_events = [e for e in events if e["type"] == "error"]
|
||||
assert len(error_events) == 1
|
||||
assert "failed" in error_events[0]["message"].lower()
|
||||
|
||||
|
||||
class TestResolveModel:
|
||||
def test_explicit_request_wins(self):
|
||||
assert resolve_model("chosen-group") == "chosen-group"
|
||||
|
||||
def test_falls_back_to_configured_setting(self):
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {"usage_ai_model": "configured-group"}):
|
||||
assert resolve_model(None) == "configured-group"
|
||||
|
||||
def test_blank_request_falls_back_to_setting(self):
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {"usage_ai_model": "configured-group"}):
|
||||
assert resolve_model(" ") == "configured-group"
|
||||
|
||||
def test_no_model_anywhere_returns_error_value(self):
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
assert isinstance(resolve_model(None), ModelNotConfigured)
|
||||
|
||||
|
||||
class TestToolsAndErrorMapping:
|
||||
def test_admin_gets_all_tools_non_admin_gets_usage_only(self):
|
||||
assert {t["function"]["name"] for t in tools_for_role(True)} == {
|
||||
"get_usage_data",
|
||||
"get_team_usage_data",
|
||||
"get_tag_usage_data",
|
||||
}
|
||||
assert {t["function"]["name"] for t in tools_for_role(False)} == {"get_usage_data"}
|
||||
|
||||
def test_error_messages_are_distinct_and_actionable(self):
|
||||
assert "usage_ai_model" in _error_event(ModelNotConfigured())["message"]
|
||||
assert "router" in _error_event(RouterUnavailable())["message"].lower()
|
||||
assert "failed" in _error_event(LLMCallError(detail="x"))["message"].lower()
|
||||
|
|
@ -1,468 +0,0 @@
|
|||
"""
|
||||
Tests for AI Usage Chat module.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
|
||||
TOOL_HANDLERS,
|
||||
TOOLS_ADMIN,
|
||||
TOOLS_BASE,
|
||||
_build_system_prompt,
|
||||
_summarise_entity_data,
|
||||
_summarise_usage_data,
|
||||
stream_usage_ai_chat,
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_AGGREGATED_RESPONSE = {
|
||||
"results": [
|
||||
{
|
||||
"date": "2025-01-15",
|
||||
"metrics": {
|
||||
"spend": 50.25,
|
||||
"prompt_tokens": 20000,
|
||||
"completion_tokens": 10000,
|
||||
"total_tokens": 30000,
|
||||
"api_requests": 500,
|
||||
"successful_requests": 480,
|
||||
"failed_requests": 20,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
},
|
||||
"breakdown": {
|
||||
"models": {
|
||||
"gpt-4": {
|
||||
"metrics": {
|
||||
"spend": 40.0,
|
||||
"api_requests": 300,
|
||||
"total_tokens": 25000,
|
||||
},
|
||||
"metadata": {},
|
||||
"api_key_breakdown": {},
|
||||
},
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"metrics": {"spend": 50.25, "api_requests": 500},
|
||||
"metadata": {},
|
||||
"api_key_breakdown": {},
|
||||
},
|
||||
},
|
||||
"api_keys": {
|
||||
"sk-test123": {
|
||||
"metrics": {"spend": 50.25},
|
||||
"metadata": {"key_alias": "Production Key"},
|
||||
},
|
||||
},
|
||||
"model_groups": {},
|
||||
"mcp_servers": {},
|
||||
"entities": {},
|
||||
},
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"total_spend": 50.25,
|
||||
"total_api_requests": 500,
|
||||
"total_successful_requests": 480,
|
||||
"total_failed_requests": 20,
|
||||
"total_tokens": 30000,
|
||||
},
|
||||
}
|
||||
|
||||
SAMPLE_TEAM_RESPONSE = {
|
||||
"results": [
|
||||
{
|
||||
"date": "2025-01-15",
|
||||
"metrics": {"spend": 100.0, "api_requests": 1000, "total_tokens": 50000},
|
||||
"breakdown": {
|
||||
"entities": {
|
||||
"team-1": {
|
||||
"metrics": {
|
||||
"spend": 60.0,
|
||||
"api_requests": 600,
|
||||
"total_tokens": 30000,
|
||||
},
|
||||
"metadata": {"alias": "Engineering"},
|
||||
"api_key_breakdown": {},
|
||||
},
|
||||
"team-2": {
|
||||
"metrics": {
|
||||
"spend": 40.0,
|
||||
"api_requests": 400,
|
||||
"total_tokens": 20000,
|
||||
},
|
||||
"metadata": {"alias": "Marketing"},
|
||||
"api_key_breakdown": {},
|
||||
},
|
||||
},
|
||||
"models": {},
|
||||
"providers": {},
|
||||
"api_keys": {},
|
||||
"model_groups": {},
|
||||
"mcp_servers": {},
|
||||
},
|
||||
},
|
||||
],
|
||||
"metadata": {"total_spend": 100.0, "total_api_requests": 1000},
|
||||
}
|
||||
|
||||
|
||||
class TestToolSchemas:
|
||||
def test_admin_tools_include_all(self):
|
||||
assert len(TOOLS_ADMIN) == 3
|
||||
names = {t["function"]["name"] for t in TOOLS_ADMIN}
|
||||
assert "get_usage_data" in names
|
||||
assert "get_team_usage_data" in names
|
||||
assert "get_tag_usage_data" in names
|
||||
|
||||
def test_base_tools_restricted_to_usage_only(self):
|
||||
assert len(TOOLS_BASE) == 1
|
||||
assert TOOLS_BASE[0]["function"]["name"] == "get_usage_data"
|
||||
|
||||
def test_admin_prompt_mentions_all_tools(self):
|
||||
prompt = _build_system_prompt(is_admin=True)
|
||||
assert "get_usage_data" in prompt
|
||||
assert "get_team_usage_data" in prompt
|
||||
assert "get_tag_usage_data" in prompt
|
||||
|
||||
def test_non_admin_prompt_only_mentions_usage_tool(self):
|
||||
prompt = _build_system_prompt(is_admin=False)
|
||||
assert "get_usage_data" in prompt
|
||||
assert "get_team_usage_data" not in prompt
|
||||
assert "get_tag_usage_data" not in prompt
|
||||
|
||||
def test_system_prompt_includes_todays_date(self):
|
||||
from datetime import date
|
||||
|
||||
prompt = _build_system_prompt(is_admin=True)
|
||||
assert date.today().isoformat() in prompt
|
||||
|
||||
|
||||
class TestSummariseUsageData:
|
||||
def test_summarise_includes_totals(self):
|
||||
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
|
||||
assert "$50.25" in summary
|
||||
assert "500" in summary
|
||||
|
||||
def test_summarise_includes_models(self):
|
||||
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
|
||||
assert "gpt-4" in summary
|
||||
|
||||
def test_summarise_includes_providers(self):
|
||||
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
|
||||
assert "openai" in summary
|
||||
|
||||
def test_summarise_handles_empty_data(self):
|
||||
empty = {"results": [], "metadata": {}}
|
||||
summary = _summarise_usage_data(empty)
|
||||
assert "no data" in summary.lower()
|
||||
|
||||
|
||||
class TestSummariseEntityData:
|
||||
def test_team_summary_includes_teams(self):
|
||||
summary = _summarise_entity_data(SAMPLE_TEAM_RESPONSE, "Team")
|
||||
assert "Engineering" in summary
|
||||
assert "Marketing" in summary
|
||||
assert "$60.0" in summary
|
||||
assert "$40.0" in summary
|
||||
|
||||
def test_team_summary_empty(self):
|
||||
empty = {"results": [], "metadata": {}}
|
||||
summary = _summarise_entity_data(empty, "Team")
|
||||
assert "No Team usage data" in summary
|
||||
|
||||
|
||||
class TestStreamUsageAiChat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_emits_status_events(self):
|
||||
mock_tool_call = MagicMock()
|
||||
mock_tool_call.id = "call_123"
|
||||
mock_tool_call.function.name = "get_usage_data"
|
||||
mock_tool_call.function.arguments = json.dumps(
|
||||
{
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-31",
|
||||
}
|
||||
)
|
||||
|
||||
mock_first_response = MagicMock()
|
||||
mock_first_response.choices = [MagicMock()]
|
||||
mock_first_response.choices[0].message.tool_calls = [mock_tool_call]
|
||||
mock_first_response.choices[0].message.model_dump.return_value = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_usage_data",
|
||||
"arguments": '{"start_date":"2025-01-01","end_date":"2025-01-31"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def mock_stream():
|
||||
chunk = MagicMock()
|
||||
chunk.choices = [MagicMock()]
|
||||
chunk.choices[0].delta.content = "Total spend is $50.25"
|
||||
yield chunk
|
||||
|
||||
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,
|
||||
) as mock_fetch,
|
||||
):
|
||||
mock_litellm.acompletion = AsyncMock(
|
||||
side_effect=[
|
||||
mock_first_response,
|
||||
mock_stream(),
|
||||
]
|
||||
)
|
||||
mock_fetch.return_value = SAMPLE_AGGREGATED_RESPONSE
|
||||
|
||||
events = []
|
||||
async for event in stream_usage_ai_chat(
|
||||
messages=[{"role": "user", "content": "What is my total spend?"}],
|
||||
model="gpt-4o-mini",
|
||||
user_id="user-123",
|
||||
is_admin=True,
|
||||
):
|
||||
events.append(json.loads(event.replace("data: ", "").strip()))
|
||||
|
||||
status_events = [e for e in events if e["type"] == "status"]
|
||||
tool_call_events = [e for e in events if e["type"] == "tool_call"]
|
||||
chunk_events = [e for e in events if e["type"] == "chunk"]
|
||||
done_events = [e for e in events if e["type"] == "done"]
|
||||
|
||||
assert len(status_events) >= 1
|
||||
assert "Thinking" in status_events[0]["message"]
|
||||
assert len(tool_call_events) >= 1
|
||||
assert tool_call_events[0]["tool_name"] == "get_usage_data"
|
||||
assert tool_call_events[0]["status"] in ("running", "complete")
|
||||
assert len(chunk_events) >= 1
|
||||
assert len(done_events) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_handles_team_tool(self):
|
||||
mock_tool_call = MagicMock()
|
||||
mock_tool_call.id = "call_team"
|
||||
mock_tool_call.function.name = "get_team_usage_data"
|
||||
mock_tool_call.function.arguments = json.dumps(
|
||||
{
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-31",
|
||||
}
|
||||
)
|
||||
|
||||
mock_first_response = MagicMock()
|
||||
mock_first_response.choices = [MagicMock()]
|
||||
mock_first_response.choices[0].message.tool_calls = [mock_tool_call]
|
||||
mock_first_response.choices[0].message.model_dump.return_value = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_team",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_team_usage_data",
|
||||
"arguments": '{"start_date":"2025-01-01","end_date":"2025-01-31"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def mock_stream():
|
||||
chunk = MagicMock()
|
||||
chunk.choices = [MagicMock()]
|
||||
chunk.choices[0].delta.content = "Engineering is the top team."
|
||||
yield chunk
|
||||
|
||||
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_team_usage_data",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_fetch,
|
||||
):
|
||||
mock_litellm.acompletion = AsyncMock(
|
||||
side_effect=[
|
||||
mock_first_response,
|
||||
mock_stream(),
|
||||
]
|
||||
)
|
||||
mock_fetch.return_value = SAMPLE_TEAM_RESPONSE
|
||||
|
||||
events = []
|
||||
async for event in stream_usage_ai_chat(
|
||||
messages=[{"role": "user", "content": "Which team spends the most?"}],
|
||||
model="gpt-4o-mini",
|
||||
is_admin=True,
|
||||
):
|
||||
events.append(json.loads(event.replace("data: ", "").strip()))
|
||||
|
||||
chunk_events = [e for e in events if e["type"] == "chunk"]
|
||||
assert len(chunk_events) >= 1
|
||||
assert "Engineering" in chunk_events[0]["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_handles_error(self):
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
|
||||
) as mock_litellm:
|
||||
mock_litellm.acompletion = AsyncMock(side_effect=Exception("LLM error"))
|
||||
|
||||
events = []
|
||||
async for event in stream_usage_ai_chat(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
):
|
||||
events.append(json.loads(event.replace("data: ", "").strip()))
|
||||
|
||||
error_events = [e for e in events if e["type"] == "error"]
|
||||
assert len(error_events) == 1
|
||||
assert "internal error" in error_events[0]["message"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_enforces_user_id(self):
|
||||
mock_tool_call = MagicMock()
|
||||
mock_tool_call.id = "call_456"
|
||||
mock_tool_call.function.name = "get_usage_data"
|
||||
mock_tool_call.function.arguments = json.dumps(
|
||||
{
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-31",
|
||||
"user_id": "other-user",
|
||||
}
|
||||
)
|
||||
|
||||
mock_first_response = MagicMock()
|
||||
mock_first_response.choices = [MagicMock()]
|
||||
mock_first_response.choices[0].message.tool_calls = [mock_tool_call]
|
||||
mock_first_response.choices[0].message.model_dump.return_value = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_456",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_usage_data",
|
||||
"arguments": '{"start_date":"2025-01-01","end_date":"2025-01-31","user_id":"other-user"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def mock_stream():
|
||||
chunk = MagicMock()
|
||||
chunk.choices = [MagicMock()]
|
||||
chunk.choices[0].delta.content = "Data."
|
||||
yield chunk
|
||||
|
||||
mock_fetch = AsyncMock(return_value=SAMPLE_AGGREGATED_RESPONSE)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm"
|
||||
) as mock_litellm,
|
||||
patch.dict(
|
||||
"litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.TOOL_HANDLERS",
|
||||
{
|
||||
"get_usage_data": {
|
||||
"fetch": mock_fetch,
|
||||
"summarise": _summarise_usage_data,
|
||||
"label": "global usage data",
|
||||
}
|
||||
},
|
||||
),
|
||||
):
|
||||
mock_litellm.acompletion = AsyncMock(
|
||||
side_effect=[
|
||||
mock_first_response,
|
||||
mock_stream(),
|
||||
]
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in stream_usage_ai_chat(
|
||||
messages=[{"role": "user", "content": "Show data"}],
|
||||
model="gpt-4o-mini",
|
||||
user_id="my-user-id",
|
||||
is_admin=False,
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
mock_fetch.assert_called_once_with(
|
||||
start_date="2025-01-01",
|
||||
end_date="2025-01-31",
|
||||
user_id="my-user-id",
|
||||
)
|
||||
|
||||
|
||||
class TestUsageAiChatServiceAccountGuard:
|
||||
"""
|
||||
Security regression: a non-admin caller with user_id=None (service-account
|
||||
key) must be rejected at the endpoint boundary, before any tool dispatch.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_with_user_id_none_is_rejected(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.endpoints import (
|
||||
ChatMessage,
|
||||
UsageAIChatRequest,
|
||||
usage_ai_chat,
|
||||
)
|
||||
|
||||
service_account_key = UserAPIKeyAuth(
|
||||
user_id=None,
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
)
|
||||
request = MagicMock()
|
||||
body = UsageAIChatRequest(
|
||||
messages=[ChatMessage(role="user", content="hi")],
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await usage_ai_chat(
|
||||
data=body,
|
||||
request=request,
|
||||
user_api_key_dict=service_account_key,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Service-account keys" in str(exc_info.value.detail)
|
||||
|
||||
def test_resolve_fetch_kwargs_tripwire_fires_on_none_user_id(self):
|
||||
"""
|
||||
Defense-in-depth: if a future endpoint forgets the entry guard and
|
||||
a non-admin caller with user_id=None reaches _resolve_fetch_kwargs,
|
||||
the tripwire must fire rather than issuing an unscoped query.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
|
||||
_resolve_fetch_kwargs,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_resolve_fetch_kwargs(
|
||||
fn_name="get_usage_data",
|
||||
fn_args={"start_date": "2025-01-01", "end_date": "2025-01-31"},
|
||||
user_id=None,
|
||||
is_admin=False,
|
||||
)
|
||||
assert "Endpoint-level guard missing" in str(exc_info.value)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""Endpoint-boundary tests for /usage/ai/chat.
|
||||
|
||||
Security regression: a non-admin caller with user_id=None (a service-account
|
||||
key) must be rejected at the endpoint before any scope/provider is built, so it
|
||||
can never fall through to an unscoped global query.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.endpoints import (
|
||||
ChatMessage,
|
||||
UsageAIChatRequest,
|
||||
usage_ai_chat,
|
||||
)
|
||||
|
||||
|
||||
class TestServiceAccountGuard:
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_with_user_id_none_is_rejected(self):
|
||||
service_account_key = UserAPIKeyAuth(user_id=None, user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
body = UsageAIChatRequest(messages=[ChatMessage(role="user", content="hi")], model="m")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await usage_ai_chat(data=body, request=MagicMock(), user_api_key_dict=service_account_key)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Service-account keys" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
class TestScopeSelection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_caller_builds_admin_scope(self):
|
||||
admin_key = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
body = UsageAIChatRequest(messages=[ChatMessage(role="user", content="hi")], model="m")
|
||||
|
||||
captured = {}
|
||||
|
||||
async def _fake_stream(*, provider, messages, model):
|
||||
captured["is_admin"] = provider.is_admin
|
||||
captured["messages"] = messages
|
||||
if False:
|
||||
yield "" # pragma: no cover
|
||||
|
||||
# Inject a fake agent stream + prisma via patching the lazily imported names.
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
original_prisma = getattr(proxy_server, "prisma_client", None)
|
||||
proxy_server.prisma_client = MagicMock()
|
||||
try:
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(
|
||||
"litellm.proxy.management_endpoints.usage_endpoints.agent.stream_usage_ai_chat",
|
||||
_fake_stream,
|
||||
)
|
||||
response = await usage_ai_chat(data=body, request=MagicMock(), user_api_key_dict=admin_key)
|
||||
# Drain the streaming body so the generator runs.
|
||||
async for _ in response.body_iterator:
|
||||
pass
|
||||
finally:
|
||||
proxy_server.prisma_client = original_prisma
|
||||
|
||||
assert captured["is_admin"] is True
|
||||
assert captured["messages"] == [{"role": "user", "content": "hi"}]
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
"""Tests for the authorization-scoped usage data provider.
|
||||
|
||||
The security property under test: a non-admin (UserScope) provider can only
|
||||
ever read its own user_id, no matter what filter a tool passes, and team/tag
|
||||
breakdowns are refused for non-admins.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.management_endpoints.usage_endpoints.scoped_data import (
|
||||
AdminScope,
|
||||
ScopedUsageDataProvider,
|
||||
UserScope,
|
||||
summarise_entity_data,
|
||||
summarise_usage_data,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
||||
SpendAnalyticsPaginatedResponse,
|
||||
)
|
||||
|
||||
|
||||
def _as_response(payload) -> SpendAnalyticsPaginatedResponse:
|
||||
return SpendAnalyticsPaginatedResponse.model_validate(payload)
|
||||
|
||||
|
||||
SAMPLE_AGGREGATED_RESPONSE = {
|
||||
"results": [
|
||||
{
|
||||
"date": "2025-01-15",
|
||||
"metrics": {"spend": 50.25, "total_tokens": 30000, "api_requests": 500},
|
||||
"breakdown": {
|
||||
"models": {
|
||||
"gpt-4": {"metrics": {"spend": 40.0, "api_requests": 300, "total_tokens": 25000}, "metadata": {}},
|
||||
},
|
||||
"providers": {
|
||||
"openai": {"metrics": {"spend": 50.25, "api_requests": 500}, "metadata": {}},
|
||||
},
|
||||
"entities": {},
|
||||
},
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"total_spend": 50.25,
|
||||
"total_api_requests": 500,
|
||||
"total_successful_requests": 480,
|
||||
"total_failed_requests": 20,
|
||||
"total_tokens": 30000,
|
||||
},
|
||||
}
|
||||
|
||||
SAMPLE_TEAM_RESPONSE = {
|
||||
"results": [
|
||||
{
|
||||
"date": "2025-01-15",
|
||||
"metrics": {"spend": 100.0},
|
||||
"breakdown": {
|
||||
"entities": {
|
||||
"team-1": {
|
||||
"metrics": {"spend": 60.0, "api_requests": 600, "total_tokens": 30000},
|
||||
"metadata": {"alias": "Engineering"},
|
||||
},
|
||||
"team-2": {
|
||||
"metrics": {"spend": 40.0, "api_requests": 400, "total_tokens": 20000},
|
||||
"metadata": {"alias": "Marketing"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"metadata": {"total_spend": 100.0},
|
||||
}
|
||||
|
||||
|
||||
def _response_mock(payload):
|
||||
resp = MagicMock()
|
||||
resp.model_dump.return_value = payload
|
||||
return resp
|
||||
|
||||
|
||||
class TestUserScopeForcesOwnUserId:
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_scope_ignores_model_supplied_user_id_filter(self):
|
||||
"""Cross-tenant regression: a non-admin query must be scoped to the
|
||||
caller's own user_id even when the tool arguments name a different one."""
|
||||
provider = ScopedUsageDataProvider(scope=UserScope(user_id="my-user"), prisma_client=MagicMock())
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.common_daily_activity.get_daily_activity_aggregated",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_agg:
|
||||
mock_agg.return_value = _response_mock(SAMPLE_AGGREGATED_RESPONSE)
|
||||
|
||||
await provider.usage(start_date="2025-01-01", end_date="2025-01-31", user_id_filter="other-user")
|
||||
|
||||
assert mock_agg.call_args.kwargs["entity_id"] == "my-user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_scope_honors_supplied_user_id_filter(self):
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="admin-1"), prisma_client=MagicMock())
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.common_daily_activity.get_daily_activity_aggregated",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_agg:
|
||||
mock_agg.return_value = _response_mock(SAMPLE_AGGREGATED_RESPONSE)
|
||||
|
||||
await provider.usage(start_date="2025-01-01", end_date="2025-01-31", user_id_filter="target-user")
|
||||
|
||||
assert mock_agg.call_args.kwargs["entity_id"] == "target-user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_scope_global_view_when_no_filter(self):
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id="admin-1"), prisma_client=MagicMock())
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.common_daily_activity.get_daily_activity_aggregated",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_agg:
|
||||
mock_agg.return_value = _response_mock(SAMPLE_AGGREGATED_RESPONSE)
|
||||
|
||||
await provider.usage(start_date="2025-01-01", end_date="2025-01-31", user_id_filter=None)
|
||||
|
||||
assert mock_agg.call_args.kwargs["entity_id"] is None
|
||||
|
||||
|
||||
class TestAdminOnlyBreakdowns:
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_scope_cannot_query_team_data(self):
|
||||
provider = ScopedUsageDataProvider(scope=UserScope(user_id="u1"), prisma_client=MagicMock())
|
||||
with pytest.raises(PermissionError):
|
||||
await provider.team(start_date="2025-01-01", end_date="2025-01-31", team_ids=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_scope_cannot_query_tag_data(self):
|
||||
provider = ScopedUsageDataProvider(scope=UserScope(user_id="u1"), prisma_client=MagicMock())
|
||||
with pytest.raises(PermissionError):
|
||||
await provider.tag(start_date="2025-01-01", end_date="2025-01-31", tags=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_scope_can_query_team_data_with_parsed_ids(self):
|
||||
provider = ScopedUsageDataProvider(scope=AdminScope(caller_user_id=None), prisma_client=MagicMock())
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.common_daily_activity.get_daily_activity",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_paginated:
|
||||
mock_paginated.return_value = _response_mock(SAMPLE_TEAM_RESPONSE)
|
||||
await provider.team(start_date="2025-01-01", end_date="2025-01-31", team_ids="team-1, team-2")
|
||||
|
||||
assert mock_paginated.call_args.kwargs["entity_id"] == ["team-1", "team-2"]
|
||||
|
||||
|
||||
class TestIsAdminFlag:
|
||||
def test_admin_scope_is_admin(self):
|
||||
assert ScopedUsageDataProvider(scope=AdminScope(caller_user_id=None), prisma_client=None).is_admin is True
|
||||
|
||||
def test_user_scope_is_not_admin(self):
|
||||
assert ScopedUsageDataProvider(scope=UserScope(user_id="u1"), prisma_client=None).is_admin is False
|
||||
|
||||
|
||||
class TestSummarisers:
|
||||
def test_usage_summary_includes_totals_models_providers(self):
|
||||
summary = summarise_usage_data(_as_response(SAMPLE_AGGREGATED_RESPONSE))
|
||||
assert "$50.25" in summary
|
||||
assert "gpt-4" in summary
|
||||
assert "openai" in summary
|
||||
|
||||
def test_usage_summary_handles_empty(self):
|
||||
assert "no data" in summarise_usage_data(_as_response({"results": [], "metadata": {}})).lower()
|
||||
|
||||
def test_entity_summary_ranks_by_spend(self):
|
||||
summary = summarise_entity_data(_as_response(SAMPLE_TEAM_RESPONSE), "Team")
|
||||
assert "Engineering" in summary
|
||||
assert "Marketing" in summary
|
||||
# Engineering (higher spend) must appear before Marketing
|
||||
assert summary.index("Engineering") < summary.index("Marketing")
|
||||
|
||||
def test_entity_summary_empty(self):
|
||||
assert "No Team usage data" in summarise_entity_data(_as_response({"results": [], "metadata": {}}), "Team")
|
||||
|
|
@ -37,7 +37,7 @@ describe("UsageAIChatPanel", () => {
|
|||
it("should render model selector", () => {
|
||||
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Select a model (optional, defaults to gpt-4o-mini)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select a model (uses the configured default if left empty)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render empty state message when no conversation", () => {
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
|
|||
{/* Model selector */}
|
||||
<div className="px-5 py-3 border-b border-gray-100 shrink-0">
|
||||
<Select
|
||||
placeholder="Select a model (optional, defaults to gpt-4o-mini)"
|
||||
placeholder="Select a model (uses the configured default if left empty)"
|
||||
value={selectedModel}
|
||||
onChange={(value) => setSelectedModel(value)}
|
||||
loading={isLoadingModels}
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -32610,7 +32610,7 @@ export interface components {
|
|||
messages: components["schemas"]["ChatMessage"][];
|
||||
/**
|
||||
* Model
|
||||
* @description Model to use for AI chat
|
||||
* @description Model group to use for AI chat
|
||||
*/
|
||||
model?: string | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue