Add team based usage dashboard at 1m+ spend logs (+ new /team/daily/activity API) (#10081)

* feat(ui/): add team based usage to dashboard

allows admin to see spend across teams + within teams at 1m+ spend logs

* fix(entity_usage.tsx): add activity page to entity usage

* style(entity_usage.tsx): place filter above tab switcher
This commit is contained in:
Krish Dholakia 2025-04-16 18:10:14 -07:00 committed by GitHub
parent 8a17398ea1
commit f200bb2c70
5 changed files with 372 additions and 240 deletions

View file

@ -364,6 +364,7 @@ async def delete_tag(
"/tag/daily/activity", "/tag/daily/activity",
response_model=SpendAnalyticsPaginatedResponse, response_model=SpendAnalyticsPaginatedResponse,
tags=["tag management"], tags=["tag management"],
dependencies=[Depends(user_api_key_auth)],
) )
async def get_tag_daily_activity( async def get_tag_daily_activity(
tags: Optional[str] = None, tags: Optional[str] = None,

View file

@ -62,6 +62,9 @@ from litellm.proxy.management_endpoints.common_utils import (
_is_user_team_admin, _is_user_team_admin,
_set_object_metadata_field, _set_object_metadata_field,
) )
from litellm.proxy.management_endpoints.tag_management_endpoints import (
get_daily_activity,
)
from litellm.proxy.management_helpers.team_member_permission_checks import ( from litellm.proxy.management_helpers.team_member_permission_checks import (
TeamMemberPermissionChecks, TeamMemberPermissionChecks,
) )
@ -75,6 +78,9 @@ from litellm.proxy.utils import (
handle_exception_on_proxy, handle_exception_on_proxy,
) )
from litellm.router import Router from litellm.router import Router
from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
)
from litellm.types.proxy.management_endpoints.team_endpoints import ( from litellm.types.proxy.management_endpoints.team_endpoints import (
GetTeamMemberPermissionsResponse, GetTeamMemberPermissionsResponse,
UpdateTeamMemberPermissionsRequest, UpdateTeamMemberPermissionsRequest,
@ -515,12 +521,12 @@ async def update_team(
updated_kv["model_id"] = _model_id updated_kv["model_id"] = _model_id
updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv) updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv)
team_row: Optional[LiteLLM_TeamTable] = ( team_row: Optional[
await prisma_client.db.litellm_teamtable.update( LiteLLM_TeamTable
where={"team_id": data.team_id}, ] = await prisma_client.db.litellm_teamtable.update(
data=updated_kv, where={"team_id": data.team_id},
include={"litellm_model_table": True}, # type: ignore data=updated_kv,
) include={"litellm_model_table": True}, # type: ignore
) )
if team_row is None or team_row.team_id is None: if team_row is None or team_row.team_id is None:
@ -1146,10 +1152,10 @@ async def delete_team(
team_rows: List[LiteLLM_TeamTable] = [] team_rows: List[LiteLLM_TeamTable] = []
for team_id in data.team_ids: for team_id in data.team_ids:
try: try:
team_row_base: Optional[BaseModel] = ( team_row_base: Optional[
await prisma_client.db.litellm_teamtable.find_unique( BaseModel
where={"team_id": team_id} ] = await prisma_client.db.litellm_teamtable.find_unique(
) where={"team_id": team_id}
) )
if team_row_base is None: if team_row_base is None:
raise Exception raise Exception
@ -1307,10 +1313,10 @@ async def team_info(
) )
try: try:
team_info: Optional[BaseModel] = ( team_info: Optional[
await prisma_client.db.litellm_teamtable.find_unique( BaseModel
where={"team_id": team_id} ] = await prisma_client.db.litellm_teamtable.find_unique(
) where={"team_id": team_id}
) )
if team_info is None: if team_info is None:
raise Exception raise Exception
@ -2079,3 +2085,52 @@ async def update_team_member_permissions(
) )
return updated_team return updated_team
@router.get(
"/team/daily/activity",
response_model=SpendAnalyticsPaginatedResponse,
tags=["team management"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_team_daily_activity(
team_ids: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
model: Optional[str] = None,
api_key: Optional[str] = None,
page: int = 1,
page_size: int = 10,
):
"""
Get daily activity for specific teams or all teams.
Args:
team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams.
start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD).
end_date (Optional[str]): End date for the activity period (YYYY-MM-DD).
model (Optional[str]): Filter by model name.
api_key (Optional[str]): Filter by API key.
page (int): Page number for pagination.
page_size (int): Number of items per page.
Returns:
SpendAnalyticsPaginatedResponse: Paginated response containing daily activity data.
"""
from litellm.proxy.proxy_server import prisma_client
# Convert comma-separated tags string to list if provided
team_ids_list = team_ids.split(",") if team_ids else None
return await get_daily_activity(
prisma_client=prisma_client,
table_name="litellm_dailyteamspend",
entity_id_field="team_id",
entity_id=team_ids_list,
start_date=start_date,
end_date=end_date,
model=model,
api_key=api_key,
page=page,
page_size=page_size,
)

View file

@ -3,12 +3,13 @@ import {
BarChart, Card, Title, Text, BarChart, Card, Title, Text,
Grid, Col, DateRangePicker, DateRangePickerValue, Grid, Col, DateRangePicker, DateRangePickerValue,
Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell,
DonutChart DonutChart,
TabPanel, TabGroup, TabList, Tab, TabPanels
} from "@tremor/react"; } from "@tremor/react";
import { Select } from 'antd'; import { Select } from 'antd';
import { ActivityMetrics, processActivityData } from './activity_metrics'; import { ActivityMetrics, processActivityData } from './activity_metrics';
import { SpendMetrics, DailyData } from './usage/types'; import { SpendMetrics, DailyData } from './usage/types';
import { tagDailyActivityCall } from './networking'; import { tagDailyActivityCall, teamDailyActivityCall } from './networking';
interface EntityMetrics { interface EntityMetrics {
metrics: { metrics: {
@ -69,6 +70,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
} }
}); });
const modelMetrics = processActivityData(spendData);
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [dateValue, setDateValue] = useState<DateRangePickerValue>({ const [dateValue, setDateValue] = useState<DateRangePickerValue>({
from: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000), from: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000),
@ -89,6 +92,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
selectedTags.length > 0 ? selectedTags : null selectedTags.length > 0 ? selectedTags : null
); );
setSpendData(data); setSpendData(data);
} else if (entityType === 'team') {
const data = await teamDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags : null
);
setSpendData(data);
} else { } else {
throw new Error("Invalid entity type"); throw new Error("Invalid entity type");
} }
@ -236,240 +248,254 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
return filterDataByTags(result); return filterDataByTags(result);
}; };
return ( return (
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
<Grid numItems={2} className="gap-2 w-full mb-4"> <Grid numItems={2} className="gap-2 w-full mb-4">
<Col> <Col>
<Text>Select Time Range</Text> <Text>Select Time Range</Text>
<DateRangePicker <DateRangePicker
enableSelect={true} enableSelect={true}
value={dateValue} value={dateValue}
onValueChange={setDateValue} onValueChange={setDateValue}
/>
</Col>
<Col>
<Text>Filter by {entityType === 'tag' ? 'Tags' : 'Teams'}</Text>
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder={`Select ${entityType === 'tag' ? 'tags' : 'teams'} to filter...`}
value={selectedTags}
onChange={setSelectedTags}
options={getAllTags()}
className="mt-2"
allowClear
/>
</Col>
</Grid>
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Card>
<Title>{entityType === 'tag' ? 'Tag' : 'Team'} Spend Overview</Title>
<Grid numItems={5} className="gap-4 mt-4">
<Card>
<Title>Total Spend</Title>
<Text className="text-2xl font-bold mt-2">
${spendData.metadata.total_spend.toFixed(2)}
</Text>
</Card>
<Card>
<Title>Total Requests</Title>
<Text className="text-2xl font-bold mt-2">
{spendData.metadata.total_api_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Successful Requests</Title>
<Text className="text-2xl font-bold mt-2 text-green-600">
{spendData.metadata.total_successful_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Failed Requests</Title>
<Text className="text-2xl font-bold mt-2 text-red-600">
{spendData.metadata.total_failed_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Total Tokens</Title>
<Text className="text-2xl font-bold mt-2">
{spendData.metadata.total_tokens.toLocaleString()}
</Text>
</Card>
</Grid>
</Card>
</Col>
{/* Daily Spend Chart */}
<Col numColSpan={2}>
<Card>
<Title>Daily Spend</Title>
<BarChart
data={[...spendData.results].sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
)}
index="date"
categories={["metrics.spend"]}
colors={["cyan"]}
valueFormatter={(value) => `$${value.toFixed(2)}`}
yAxisWidth={100}
showLegend={false}
/> />
</Card> </Col>
</Col> <Col>
<Text>Filter by {entityType === 'tag' ? 'Tags' : 'Teams'}</Text>
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder={`Select ${entityType === 'tag' ? 'tags' : 'teams'} to filter...`}
value={selectedTags}
onChange={setSelectedTags}
options={getAllTags()}
className="mt-2"
allowClear
/>
</Col>
</Grid>
<TabGroup>
<TabList variant="solid" className="mt-1">
<Tab>Cost</Tab>
<Tab>Activity</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Card>
<Title>{entityType === 'tag' ? 'Tag' : 'Team'} Spend Overview</Title>
<Grid numItems={5} className="gap-4 mt-4">
<Card>
<Title>Total Spend</Title>
<Text className="text-2xl font-bold mt-2">
${spendData.metadata.total_spend.toFixed(2)}
</Text>
</Card>
<Card>
<Title>Total Requests</Title>
<Text className="text-2xl font-bold mt-2">
{spendData.metadata.total_api_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Successful Requests</Title>
<Text className="text-2xl font-bold mt-2 text-green-600">
{spendData.metadata.total_successful_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Failed Requests</Title>
<Text className="text-2xl font-bold mt-2 text-red-600">
{spendData.metadata.total_failed_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Total Tokens</Title>
<Text className="text-2xl font-bold mt-2">
{spendData.metadata.total_tokens.toLocaleString()}
</Text>
</Card>
</Grid>
</Card>
</Col>
{/* Entity Breakdown Section */} {/* Daily Spend Chart */}
<Col numColSpan={2}> <Col numColSpan={2}>
<Card> <Card>
<div className="flex flex-col space-y-4"> <Title>Daily Spend</Title>
<div className="flex flex-col space-y-2">
<Title>Spend Per {entityType === 'tag' ? 'Tag' : 'Team'}</Title>
<div className="flex items-center text-sm text-gray-500">
<span>Get Started Tracking cost per {entityType} </span>
<a href="https://docs.litellm.ai/docs/proxy/tags" className="text-blue-500 hover:text-blue-700 ml-1">
here
</a>
</div>
</div>
<Grid numItems={2}>
<Col numColSpan={1}>
<BarChart <BarChart
className="mt-4 h-52" data={[...spendData.results].sort((a, b) =>
data={getEntityBreakdown()} new Date(a.date).getTime() - new Date(b.date).getTime()
index="entity" )}
index="date"
categories={["metrics.spend"]}
colors={["cyan"]}
valueFormatter={(value) => `$${value.toFixed(2)}`}
yAxisWidth={100}
showLegend={false}
/>
</Card>
</Col>
{/* Entity Breakdown Section */}
<Col numColSpan={2}>
<Card>
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-2">
<Title>Spend Per {entityType === 'tag' ? 'Tag' : 'Team'}</Title>
<div className="flex items-center text-sm text-gray-500">
<span>Get Started Tracking cost per {entityType} </span>
<a href="https://docs.litellm.ai/docs/proxy/tags" className="text-blue-500 hover:text-blue-700 ml-1">
here
</a>
</div>
</div>
<Grid numItems={2}>
<Col numColSpan={1}>
<BarChart
className="mt-4 h-52"
data={getEntityBreakdown()}
index="entity"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={(value) => `$${value.toFixed(4)}`}
layout="vertical"
showLegend={false}
yAxisWidth={100}
/>
</Col>
<Col numColSpan={1}>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{entityType === 'tag' ? 'Tag' : 'Team'}</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{getEntityBreakdown()
.filter(entity => entity.spend > 0)
.map((entity) => (
<TableRow key={entity.entity}>
<TableCell>{entity.entity}</TableCell>
<TableCell>${entity.spend.toFixed(4)}</TableCell>
<TableCell className="text-green-600">
{entity.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{entity.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{entity.tokens.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Col>
</Grid>
</div>
</Card>
</Col>
{/* Top API Keys */}
<Col numColSpan={1}>
<Card>
<Title>Top API Keys</Title>
<BarChart
className="mt-4 h-40"
data={getTopApiKeys()}
index="key"
categories={["spend"]} categories={["spend"]}
colors={["cyan"]} colors={["cyan"]}
valueFormatter={(value) => `$${value.toFixed(4)}`}
layout="vertical"
showLegend={false}
yAxisWidth={100}
/>
</Col>
<Col numColSpan={1}>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{entityType === 'tag' ? 'Tag' : 'Team'}</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{getEntityBreakdown()
.filter(entity => entity.spend > 0)
.map((entity) => (
<TableRow key={entity.entity}>
<TableCell>{entity.entity}</TableCell>
<TableCell>${entity.spend.toFixed(4)}</TableCell>
<TableCell className="text-green-600">
{entity.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{entity.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{entity.tokens.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Col>
</Grid>
</div>
</Card>
</Col>
{/* Top API Keys */}
<Col numColSpan={1}>
<Card>
<Title>Top API Keys</Title>
<BarChart
className="mt-4 h-40"
data={getTopApiKeys()}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={(value) => `$${value.toFixed(2)}`}
layout="vertical"
yAxisWidth={200}
showLegend={false}
/>
</Card>
</Col>
{/* Top Models */}
<Col numColSpan={1}>
<Card>
<Title>Top Models</Title>
<BarChart
className="mt-4 h-40"
data={getTopModels()}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={(value) => `$${value.toFixed(2)}`}
layout="vertical"
yAxisWidth={200}
showLegend={false}
/>
</Card>
</Col>
{/* Spend by Provider */}
<Col numColSpan={2}>
<Card>
<div className="flex flex-col space-y-4">
<Title>Provider Usage</Title>
<Grid numItems={2}>
<Col numColSpan={1}>
<DonutChart
className="mt-4 h-40"
data={getProviderSpend()}
index="provider"
category="spend"
valueFormatter={(value) => `$${value.toFixed(2)}`} valueFormatter={(value) => `$${value.toFixed(2)}`}
colors={["cyan", "blue", "indigo", "violet", "purple"]} layout="vertical"
yAxisWidth={200}
showLegend={false}
/> />
</Col> </Card>
<Col numColSpan={1}> </Col>
<Table>
<TableHead> {/* Top Models */}
<TableRow> <Col numColSpan={1}>
<TableHeaderCell>Provider</TableHeaderCell> <Card>
<TableHeaderCell>Spend</TableHeaderCell> <Title>Top Models</Title>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell> <BarChart
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell> className="mt-4 h-40"
<TableHeaderCell>Tokens</TableHeaderCell> data={getTopModels()}
</TableRow> index="key"
</TableHead> categories={["spend"]}
<TableBody> colors={["cyan"]}
{getProviderSpend().map((provider) => ( valueFormatter={(value) => `$${value.toFixed(2)}`}
<TableRow key={provider.provider}> layout="vertical"
<TableCell>{provider.provider}</TableCell> yAxisWidth={200}
<TableCell>${provider.spend.toFixed(2)}</TableCell> showLegend={false}
<TableCell className="text-green-600"> />
{provider.successful_requests.toLocaleString()} </Card>
</TableCell> </Col>
<TableCell className="text-red-600">
{provider.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{provider.tokens.toLocaleString()}</TableCell> {/* Spend by Provider */}
</TableRow> <Col numColSpan={2}>
))} <Card>
</TableBody> <div className="flex flex-col space-y-4">
</Table> <Title>Provider Usage</Title>
</Col> <Grid numItems={2}>
</Grid> <Col numColSpan={1}>
</div> <DonutChart
</Card> className="mt-4 h-40"
</Col> data={getProviderSpend()}
</Grid> index="provider"
category="spend"
valueFormatter={(value) => `$${value.toFixed(2)}`}
colors={["cyan", "blue", "indigo", "violet", "purple"]}
/>
</Col>
<Col numColSpan={1}>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{getProviderSpend().map((provider) => (
<TableRow key={provider.provider}>
<TableCell>{provider.provider}</TableCell>
<TableCell>${provider.spend.toFixed(2)}</TableCell>
<TableCell className="text-green-600">
{provider.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{provider.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Col>
</Grid>
</div>
</Card>
</Col>
</Grid>
</TabPanel>
<TabPanel>
<ActivityMetrics modelMetrics={modelMetrics} />
</TabPanel>
</TabPanels>
</TabGroup>
</div> </div>
); );
}; };

View file

@ -1186,6 +1186,47 @@ export const tagDailyActivityCall = async (accessToken: String, startTime: Date,
} }
}; };
export const teamDailyActivityCall = async (accessToken: String, startTime: Date, endTime: Date, page: number = 1, teamIds: string[] | null = null) => {
/**
* Get daily user activity on proxy
*/
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/team/daily/activity` : `/team/daily/activity`;
const queryParams = new URLSearchParams();
queryParams.append('start_date', startTime.toISOString());
queryParams.append('end_date', endTime.toISOString());
queryParams.append('page_size', '1000');
queryParams.append('page', page.toString());
if (teamIds) {
queryParams.append('team_ids', teamIds.join(','));
}
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;
}
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to create key:", error);
throw error;
}
};
export const getTotalSpendCall = async (accessToken: String) => { export const getTotalSpendCall = async (accessToken: String) => {
/** /**
* Get all models on proxy * Get all models on proxy

View file

@ -236,6 +236,7 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({
<TabList variant="solid" className="mt-1"> <TabList variant="solid" className="mt-1">
<Tab>Your Usage</Tab> <Tab>Your Usage</Tab>
<Tab>Tag Usage</Tab> <Tab>Tag Usage</Tab>
<Tab>Team Usage</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
{/* Your Usage Panel */} {/* Your Usage Panel */}
@ -469,6 +470,14 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({
entityType="tag" entityType="tag"
/> />
</TabPanel> </TabPanel>
{/* Team Usage Panel */}
<TabPanel>
<EntityUsage
accessToken={accessToken}
entityType="team"
/>
</TabPanel>
</TabPanels> </TabPanels>
</TabGroup> </TabGroup>
</div> </div>