Add backend AI usage chat endpoint with tool calling

Backend:
- New /usage/ai/chat SSE streaming endpoint
- AI agent has get_usage_data tool that queries /user/daily/activity/aggregated
- Follows same architecture as policy AI suggest (litellm.acompletion + tools)
- Non-admin users are restricted to their own data
- 12 backend unit tests

Frontend:
- Panel now calls /usage/ai/chat backend endpoint via SSE
- Removed direct OpenAI client calls from frontend
- Added usageAiChatStream networking function following enrichPolicyTemplateStream pattern

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-02-24 20:46:02 +00:00
parent 1a6ded461a
commit fb8eaf2f7d
10 changed files with 751 additions and 242 deletions

View file

@ -0,0 +1,9 @@
"""
Usage endpoints package.
Re-exports the router from endpoints module.
"""
from litellm.proxy.management_endpoints.usage_endpoints.endpoints import ( # noqa: F401
router,
)

View file

@ -0,0 +1,254 @@
"""
AI Usage Chat - uses LLM tool calling to answer questions about
usage/spend data from the /user/daily/activity endpoints.
"""
import json
from datetime import date, timedelta
from typing import Any, AsyncIterator, Dict, List, Optional
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import DEFAULT_COMPETITOR_DISCOVERY_MODEL
GET_USAGE_DATA_TOOL = {
"type": "function",
"function": {
"name": "get_usage_data",
"description": (
"Fetch aggregated usage/spend data for the LiteLLM proxy. "
"Returns daily spend, token usage, request counts, and breakdowns "
"by model, provider, and API key for the given date range. "
"Always call this tool first to get data before answering."
),
"parameters": {
"type": "object",
"properties": {
"start_date": {
"type": "string",
"description": "Start date in YYYY-MM-DD format",
},
"end_date": {
"type": "string",
"description": "End date in YYYY-MM-DD format",
},
"user_id": {
"type": "string",
"description": "Optional user ID to filter by a specific user. Omit for global view.",
},
},
"required": ["start_date", "end_date"],
},
},
}
SYSTEM_PROMPT = (
"You are an AI assistant embedded in the LiteLLM Usage dashboard. "
"You help users understand their LLM API spend and usage data.\n\n"
"You have access to a tool called `get_usage_data` which fetches "
"aggregated usage data from the LiteLLM database. You MUST call "
"this tool first to retrieve the data, then answer the user's question "
"based on the returned data.\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 models or providers, show a ranked list.\n"
"- If data is empty, say so clearly.\n"
"- Do not hallucinate data — only use what the tool returns."
)
async def _fetch_usage_data(
start_date: str,
end_date: str,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Call the aggregated daily activity query and return serialisable dict."""
from litellm.proxy.management_endpoints.common_daily_activity import (
get_daily_activity_aggregated,
)
from litellm.proxy.proxy_server import prisma_client
response = await get_daily_activity_aggregated(
prisma_client=prisma_client,
table_name="litellm_dailyuserspend",
entity_id_field="user_id",
entity_id=user_id,
entity_metadata_field=None,
start_date=start_date,
end_date=end_date,
model=None,
api_key=None,
)
return response.model_dump(mode="json")
def _summarise_usage_data(data: Dict[str, Any]) -> str:
"""Convert the raw aggregated response into a concise text summary for the LLM."""
meta = data.get("metadata", {})
results = data.get("results", [])
lines = [
f"Date Range: {results[0]['date'] if results else 'N/A'} to {results[-1]['date'] if results else 'N/A'}",
f"Total Spend: ${meta.get('total_spend', 0):.4f}",
f"Total Requests: {meta.get('total_api_requests', 0)}",
f"Successful Requests: {meta.get('total_successful_requests', 0)}",
f"Failed Requests: {meta.get('total_failed_requests', 0)}",
f"Total Tokens: {meta.get('total_tokens', 0)}",
"",
]
model_spend: Dict[str, Dict[str, float]] = {}
provider_spend: Dict[str, Dict[str, float]] = {}
key_spend: Dict[str, Dict[str, Any]] = {}
for day in results:
breakdown = day.get("breakdown", {})
for model, metrics in breakdown.get("models", {}).items():
if model not in model_spend:
model_spend[model] = {"spend": 0, "requests": 0, "tokens": 0}
m = metrics.get("metrics", {})
model_spend[model]["spend"] += m.get("spend", 0)
model_spend[model]["requests"] += m.get("api_requests", 0)
model_spend[model]["tokens"] += m.get("total_tokens", 0)
for provider, metrics in breakdown.get("providers", {}).items():
if provider not in provider_spend:
provider_spend[provider] = {"spend": 0, "requests": 0}
m = metrics.get("metrics", {})
provider_spend[provider]["spend"] += m.get("spend", 0)
provider_spend[provider]["requests"] += m.get("api_requests", 0)
for key, metrics in breakdown.get("api_keys", {}).items():
if key not in key_spend:
alias = metrics.get("metadata", {}).get("key_alias")
key_spend[key] = {"spend": 0, "alias": alias}
key_spend[key]["spend"] += metrics.get("metrics", {}).get("spend", 0)
if model_spend:
lines.append("Top Models by Spend:")
for name, d in sorted(model_spend.items(), key=lambda x: -x[1]["spend"])[:15]:
lines.append(f" - {name}: ${d['spend']:.4f} ({int(d['requests'])} requests, {int(d['tokens'])} tokens)")
else:
lines.append("Models: (no data)")
lines.append("")
if provider_spend:
lines.append("Top Providers by Spend:")
for name, d in sorted(provider_spend.items(), key=lambda x: -x[1]["spend"])[:10]:
lines.append(f" - {name}: ${d['spend']:.4f} ({int(d['requests'])} requests)")
else:
lines.append("Providers: (no data)")
lines.append("")
if key_spend:
lines.append("Top API Keys by Spend:")
for key, d in sorted(key_spend.items(), key=lambda x: -x[1]["spend"])[:10]:
label = d["alias"] or key
lines.append(f" - {label}: ${d['spend']:.4f}")
else:
lines.append("API Keys: (no data)")
lines.append("")
if results:
lines.append("Daily Spend:")
sorted_days = sorted(results, key=lambda x: x["date"])
for day in sorted_days:
m = day.get("metrics", {})
lines.append(f" - {day['date']}: ${m.get('spend', 0):.4f} ({m.get('api_requests', 0)} requests)")
return "\n".join(lines)
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 an AI chat response about usage data.
Yields SSE-formatted events:
data: {"type": "chunk", "content": "..."}
data: {"type": "done"}
data: {"type": "error", "message": "..."}
"""
model = model or DEFAULT_COMPETITOR_DISCOVERY_MODEL
chat_messages: List[Dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT},
*messages,
]
try:
response = await litellm.acompletion(
model=model,
messages=chat_messages,
tools=[GET_USAGE_DATA_TOOL],
temperature=0.2,
)
choice = response.choices[0] # type: ignore
tool_calls = choice.message.tool_calls
if tool_calls:
chat_messages.append(choice.message.model_dump())
for tool_call in tool_calls:
fn_name = tool_call.function.name
fn_args = json.loads(tool_call.function.arguments)
if fn_name == "get_usage_data":
effective_user = None
if not is_admin:
effective_user = user_id
elif fn_args.get("user_id"):
effective_user = fn_args["user_id"]
try:
raw_data = await _fetch_usage_data(
start_date=fn_args["start_date"],
end_date=fn_args["end_date"],
user_id=effective_user,
)
tool_result = _summarise_usage_data(raw_data)
except Exception as e:
tool_result = f"Error fetching usage data: {str(e)}"
else:
tool_result = f"Unknown tool: {fn_name}"
chat_messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
}
)
final_response = await litellm.acompletion(
model=model,
messages=chat_messages,
stream=True,
temperature=0.2,
)
async for chunk in final_response:
delta_content = chunk.choices[0].delta.content
if delta_content:
event = json.dumps({"type": "chunk", "content": delta_content})
yield f"data: {event}\n\n"
else:
content = choice.message.content or ""
if content:
event = json.dumps({"type": "chunk", "content": content})
yield f"data: {event}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
except Exception as e:
verbose_proxy_logger.error("AI usage chat failed: %s", e)
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"

View file

@ -0,0 +1,74 @@
"""
USAGE AI CHAT ENDPOINTS
/usage/ai/chat - Stream AI chat responses about usage data
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
router = APIRouter()
class ChatMessage(BaseModel):
role: str
content: str
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"
)
@router.post(
"/usage/ai/chat",
tags=["Budget & Spend Tracking"],
dependencies=[Depends(user_api_key_auth)],
)
async def usage_ai_chat(
data: UsageAIChatRequest,
request: Request,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
AI chat about usage data.
Streams SSE events with the AI response. The AI agent has access
to the `get_usage_data` tool which queries the aggregated daily
activity endpoint internally.
"""
from litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat import (
stream_usage_ai_chat,
)
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_view,
)
is_admin = _user_has_admin_view(user_api_key_dict)
user_id = user_api_key_dict.user_id
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,
),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)

View file

@ -391,6 +391,7 @@ from litellm.proxy.management_endpoints.organization_endpoints import (
router as organization_router,
)
from litellm.proxy.management_endpoints.policy_endpoints import router as policy_router
from litellm.proxy.management_endpoints.usage_endpoints import router as usage_ai_router
from litellm.proxy.management_endpoints.project_endpoints import (
router as project_router,
)
@ -12844,6 +12845,7 @@ app.include_router(caching_router)
app.include_router(analytics_router)
app.include_router(guardrails_router)
app.include_router(policy_router)
app.include_router(usage_ai_router)
app.include_router(policy_crud_router)
app.include_router(policy_resolve_router)
app.include_router(search_tool_management_router)

View file

@ -0,0 +1,325 @@
"""
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 (
GET_USAGE_DATA_TOOL,
SYSTEM_PROMPT,
_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,
"prompt_tokens": 15000,
"completion_tokens": 10000,
"successful_requests": 290,
"failed_requests": 10,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
},
"metadata": {},
"api_key_breakdown": {},
},
"gpt-3.5-turbo": {
"metrics": {
"spend": 10.25,
"api_requests": 200,
"total_tokens": 5000,
"prompt_tokens": 3000,
"completion_tokens": 2000,
"successful_requests": 190,
"failed_requests": 10,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
},
"metadata": {},
"api_key_breakdown": {},
},
},
"providers": {
"openai": {
"metrics": {
"spend": 50.25,
"api_requests": 500,
"total_tokens": 30000,
"prompt_tokens": 20000,
"completion_tokens": 10000,
"successful_requests": 480,
"failed_requests": 20,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
},
"metadata": {},
"api_key_breakdown": {},
},
},
"api_keys": {
"sk-test123": {
"metrics": {
"spend": 50.25,
"api_requests": 500,
"total_tokens": 30000,
"prompt_tokens": 20000,
"completion_tokens": 10000,
"successful_requests": 480,
"failed_requests": 20,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
},
"metadata": {"key_alias": "Production Key"},
},
},
"model_groups": {},
"mcp_servers": {},
"entities": {},
},
},
],
"metadata": {
"total_spend": 50.25,
"total_prompt_tokens": 20000,
"total_completion_tokens": 10000,
"total_tokens": 30000,
"total_api_requests": 500,
"total_successful_requests": 480,
"total_failed_requests": 20,
"total_cache_read_input_tokens": 0,
"total_cache_creation_input_tokens": 0,
"page": 1,
"total_pages": 1,
"has_more": False,
},
}
class TestToolSchema:
def test_tool_schema_is_valid(self):
assert GET_USAGE_DATA_TOOL["type"] == "function"
func = GET_USAGE_DATA_TOOL["function"]
assert func["name"] == "get_usage_data"
params = func["parameters"]
assert "start_date" in params["properties"]
assert "end_date" in params["properties"]
assert "user_id" in params["properties"]
assert params["required"] == ["start_date", "end_date"]
def test_system_prompt_mentions_tool(self):
assert "get_usage_data" in SYSTEM_PROMPT
assert "usage" in SYSTEM_PROMPT.lower()
class TestSummariseUsageData:
def test_summarise_includes_totals(self):
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
assert "$50.25" in summary
assert "500" in summary
assert "480" in summary
assert "20" in summary
def test_summarise_includes_models(self):
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
assert "gpt-4" in summary
assert "gpt-3.5-turbo" in summary
assert "$40.0" in summary
def test_summarise_includes_providers(self):
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
assert "openai" in summary
def test_summarise_includes_api_keys(self):
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
assert "Production Key" in summary
def test_summarise_includes_daily(self):
summary = _summarise_usage_data(SAMPLE_AGGREGATED_RESPONSE)
assert "2025-01-15" in summary
def test_summarise_handles_empty_data(self):
empty = {"results": [], "metadata": {}}
summary = _summarise_usage_data(empty)
assert "no data" in summary.lower()
class TestStreamUsageAiChat:
@pytest.mark.asyncio
async def test_stream_with_tool_call(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 = "Your 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(event)
assert len(events) >= 2
chunk_event = json.loads(events[0].replace("data: ", "").strip())
assert chunk_event["type"] == "chunk"
assert "$50.25" in chunk_event["content"]
done_event = json.loads(events[-1].replace("data: ", "").strip())
assert done_event["type"] == "done"
@pytest.mark.asyncio
async def test_stream_without_tool_call(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.tool_calls = None
mock_response.choices[0].message.content = "I need more context."
with patch("litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm") as mock_litellm:
mock_litellm.acompletion = AsyncMock(return_value=mock_response)
events = []
async for event in stream_usage_ai_chat(
messages=[{"role": "user", "content": "Hello"}],
):
events.append(event)
assert len(events) >= 2
chunk_event = json.loads(events[0].replace("data: ", "").strip())
assert chunk_event["type"] == "chunk"
assert "context" in chunk_event["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(event)
assert len(events) == 1
error_event = json.loads(events[0].replace("data: ", "").strip())
assert error_event["type"] == "error"
assert "LLM error" in error_event["message"]
@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 = "Here is your data."
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": "Show me other user 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",
)

View file

@ -20,116 +20,13 @@ vi.mock("../../networking", () => ({
{ model_group: "claude-3-opus" },
],
}),
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
usageAiChatStream: vi.fn(),
}));
vi.mock("openai", () => {
return {
default: {
OpenAI: vi.fn().mockImplementation(() => ({
chat: {
completions: {
create: vi.fn(),
},
},
})),
},
};
});
const mockUserSpendData = {
results: [
{
date: "2025-01-01",
metrics: {
spend: 100.5,
api_requests: 1000,
successful_requests: 950,
failed_requests: 50,
total_tokens: 50000,
prompt_tokens: 30000,
completion_tokens: 20000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
breakdown: {
models: {
"gpt-4": {
metrics: {
spend: 80.0,
api_requests: 800,
successful_requests: 780,
failed_requests: 20,
total_tokens: 40000,
prompt_tokens: 24000,
completion_tokens: 16000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
},
},
model_groups: {},
mcp_servers: {},
providers: {
openai: {
metrics: {
spend: 100.5,
api_requests: 1000,
successful_requests: 950,
failed_requests: 50,
total_tokens: 50000,
prompt_tokens: 30000,
completion_tokens: 20000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
},
},
api_keys: {
"sk-test": {
metrics: {
spend: 100.5,
api_requests: 1000,
successful_requests: 950,
failed_requests: 50,
total_tokens: 50000,
prompt_tokens: 30000,
completion_tokens: 20000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {
key_alias: "Test Key",
team_id: null,
},
},
},
entities: {},
},
},
],
metadata: {
total_spend: 100.5,
total_api_requests: 1000,
total_successful_requests: 950,
total_failed_requests: 50,
total_tokens: 50000,
},
};
const defaultProps = {
open: true,
onClose: vi.fn(),
accessToken: "test-token",
userSpendData: mockUserSpendData,
dateRange: {
from: new Date("2025-01-01"),
to: new Date("2025-01-07"),
},
};
describe("UsageAIChatPanel", () => {

View file

@ -1,9 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Select, Input, Spin } from "antd";
import { Button } from "@tremor/react";
import { getProxyBaseUrl, modelHubCall } from "../../networking";
import { DailyData } from "../types";
import openai from "openai";
import { modelHubCall, usageAiChatStream } from "../../networking";
const { TextArea } = Input;
@ -16,103 +14,12 @@ interface UsageAIChatPanelProps {
open: boolean;
onClose: () => void;
accessToken: string | null;
userSpendData: {
results: DailyData[];
metadata: any;
};
dateRange: {
from?: Date;
to?: Date;
};
}
function buildUsageSummary(
userSpendData: UsageAIChatPanelProps["userSpendData"],
dateRange: UsageAIChatPanelProps["dateRange"]
): string {
const meta = userSpendData.metadata || {};
const results = userSpendData.results || [];
const fromStr = dateRange.from?.toLocaleDateString() ?? "N/A";
const toStr = dateRange.to?.toLocaleDateString() ?? "N/A";
const modelSpend: Record<string, { spend: number; requests: number; tokens: number }> = {};
const providerSpend: Record<string, { spend: number; requests: number }> = {};
const keySpend: Record<string, { spend: number; alias: string | null }> = {};
for (const day of results) {
for (const [model, metrics] of Object.entries(day.breakdown.models || {})) {
if (!modelSpend[model]) modelSpend[model] = { spend: 0, requests: 0, tokens: 0 };
modelSpend[model].spend += metrics.metrics.spend;
modelSpend[model].requests += metrics.metrics.api_requests;
modelSpend[model].tokens += metrics.metrics.total_tokens;
}
for (const [provider, metrics] of Object.entries(day.breakdown.providers || {})) {
if (!providerSpend[provider]) providerSpend[provider] = { spend: 0, requests: 0 };
providerSpend[provider].spend += metrics.metrics.spend;
providerSpend[provider].requests += metrics.metrics.api_requests;
}
for (const [key, metrics] of Object.entries(day.breakdown.api_keys || {})) {
if (!keySpend[key]) keySpend[key] = { spend: 0, alias: metrics.metadata.key_alias };
keySpend[key].spend += metrics.metrics.spend;
}
}
const topModels = Object.entries(modelSpend)
.sort((a, b) => b[1].spend - a[1].spend)
.slice(0, 10)
.map(([name, d]) => ` - ${name}: $${d.spend.toFixed(4)} (${d.requests} requests, ${d.tokens} tokens)`)
.join("\n");
const topProviders = Object.entries(providerSpend)
.sort((a, b) => b[1].spend - a[1].spend)
.slice(0, 10)
.map(([name, d]) => ` - ${name}: $${d.spend.toFixed(4)} (${d.requests} requests)`)
.join("\n");
const topKeys = Object.entries(keySpend)
.sort((a, b) => b[1].spend - a[1].spend)
.slice(0, 10)
.map(([key, d]) => ` - ${d.alias || key}: $${d.spend.toFixed(4)}`)
.join("\n");
const dailySummary = results
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((d) => ` - ${d.date}: $${d.metrics.spend.toFixed(4)} (${d.metrics.api_requests} requests)`)
.join("\n");
return `Date Range: ${fromStr} to ${toStr}
Total Spend: $${(meta.total_spend || 0).toFixed(4)}
Total Requests: ${meta.total_api_requests || 0}
Successful Requests: ${meta.total_successful_requests || 0}
Failed Requests: ${meta.total_failed_requests || 0}
Total Tokens: ${meta.total_tokens || 0}
Top Models by Spend:
${topModels || " (no data)"}
Top Providers by Spend:
${topProviders || " (no data)"}
Top API Keys by Spend:
${topKeys || " (no data)"}
Daily Spend:
${dailySummary || " (no data)"}`;
}
const SYSTEM_PROMPT = `You are an AI assistant that helps users understand their LLM API usage data. You are embedded in the LiteLLM Usage dashboard.
You have access to the user's current usage data which is provided below. Use it to answer questions about their spending, model usage, API key activity, provider costs, request volumes, and trends.
Be concise and helpful. Use specific numbers from the data. When discussing costs, format them as dollar amounts. If the user asks about something not available in the data, let them know.`;
const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
open,
onClose,
accessToken,
userSpendData,
dateRange,
}) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState("");
@ -154,11 +61,6 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
}
};
const usageSummary = useMemo(
() => buildUsageSummary(userSpendData, dateRange),
[userSpendData, dateRange]
);
const handleSend = async () => {
if (!accessToken || !inputText.trim() || !selectedModel || isLoading) return;
@ -172,44 +74,30 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
const abortController = new AbortController();
abortControllerRef.current = abortController;
let accumulated = "";
try {
const proxyBaseUrl = getProxyBaseUrl();
const client = new openai.OpenAI({
apiKey: accessToken,
baseURL: proxyBaseUrl,
dangerouslyAllowBrowser: true,
});
const chatHistory = [
{
role: "system" as const,
content: `${SYSTEM_PROMPT}\n\nCurrent Usage Data:\n${usageSummary}`,
await usageAiChatStream(
accessToken,
updatedMessages.map((m) => ({ role: m.role, content: m.content })),
selectedModel,
(content: string) => {
accumulated += content;
setStreamingContent(accumulated);
},
...updatedMessages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
];
const response = await client.chat.completions.create(
{
model: selectedModel,
stream: true,
messages: chatHistory,
() => {
setMessages((prev) => [...prev, { role: "assistant", content: accumulated }]);
setStreamingContent("");
},
{ signal: abortController.signal }
(errorMsg: string) => {
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${errorMsg}` },
]);
setStreamingContent("");
},
abortController.signal,
);
let fullContent = "";
for await (const chunk of response) {
if (chunk.choices[0]?.delta?.content) {
fullContent += chunk.choices[0].delta.content;
setStreamingContent(fullContent);
}
}
setMessages((prev) => [...prev, { role: "assistant", content: fullContent }]);
setStreamingContent("");
} catch (error: any) {
if (error?.name === "AbortError" || abortController.signal.aborted) {
return;
@ -293,7 +181,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
/>
</div>
{/* Chat messages — fills remaining space */}
{/* Chat messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
{messages.length === 0 && !streamingContent && (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
@ -341,7 +229,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
<div ref={messagesEndRef} />
</div>
{/* Input area — pinned to bottom */}
{/* Input area */}
<div className="px-4 py-3 border-t border-gray-200 bg-white flex-shrink-0">
<div className="flex gap-2">
<TextArea

View file

@ -945,8 +945,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
open={isAiChatOpen}
onClose={() => setIsAiChatOpen(false)}
accessToken={accessToken}
userSpendData={userSpendData}
dateRange={dateValue}
/>
</div>
);

View file

@ -5907,6 +5907,68 @@ export const enrichPolicyTemplateStream = async (
}
};
export const usageAiChatStream = async (
accessToken: string,
messages: { role: string; content: string }[],
model: string,
onChunk: (content: string) => void,
onDone: () => void,
onError?: (error: string) => void,
signal?: AbortSignal,
) => {
const url = proxyBaseUrl
? `${proxyBaseUrl}/usage/ai/chat`
: `/usage/ai/chat`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ messages, model }),
signal,
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const event = JSON.parse(line.slice(6));
if (event.type === "chunk") {
onChunk(event.content);
} else if (event.type === "done") {
onDone();
} else if (event.type === "error") {
onError?.(event.message);
}
} catch {
// skip malformed events
}
}
}
};
export const createPolicyCall = async (accessToken: string, policyData: any) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/policies` : `/policies`;