From ef6ac426580ff4c0ae2cb58253a7d15f6027faa2 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Sat, 19 Apr 2025 07:32:23 -0700 Subject: [PATCH] Litellm dev 04 18 2025 p2 (#10157) * fix(proxy/_types.py): allow internal user to call api playground * fix(new_usage.tsx): cleanup tag based usage - only show for proxy admin not clear what tags internal user should be allowed to see * fix(team_endpoints.py): allow internal user view spend for teams they belong to * fix(team_endpoints.py): return team alias on `/team/daily/activity` API allows displaying team alias on ui * fix: fix linting error * fix(entity_usage.tsx): allow viewing top keys by team * fix(entity_usage.tsx): show alias, if available in breakdown allows entity alias to be easily displayed * Show usage by key (on all up, team, and tag usage dashboards) (#10152) * fix(entity_usage.tsx): allow user to select team in team usage tab * fix(new_usage.tsx): load all tags for filtering * fix(tag_management_endpoints.py): return dynamic tags from db on `/tag/list` * fix(litellm_pre_call_utils.py): support x-litellm-tags even if tag based routing not enabled * fix(new_usage.tsx): show breakdown of usage by api key on dashboard helpful when looking at spend by team * fix(networking.tsx): exclude litellm-dashboard team id's from calls adds noisy ui tokens to key activity * fix(new_usage.tsx): allow user to see activity by key on main tab * feat(internal_user_endpoints.py): refactor to use common_daily_activity function reuses same logic across teams/keys/tags Allows returning team_alias in api_keys consistently * fix(leftnav.tsx): swap old usage with new usage tab * fix(entity_usage.tsx): show breakdown of teams in daily spend chart * style(new_usage.tsx): show global usage tab if user is admin / has admin view * fix(new_usage.tsx): add disclaimer for new usage dashboard * fix(new_usage.tsx): fix linting error * Allow filtering usage dashboard by team + tag (#10150) * fix(entity_usage.tsx): allow user to select team in team usage tab * fix(new_usage.tsx): load all tags for filtering * fix(tag_management_endpoints.py): return dynamic tags from db on `/tag/list` * fix(litellm_pre_call_utils.py): support x-litellm-tags even if tag based routing not enabled * fix: fix linting error --- litellm/proxy/_types.py | 68 ++--- litellm/proxy/litellm_pre_call_utils.py | 15 +- .../common_daily_activity.py | 21 +- .../management_endpoints/common_utils.py | 8 + .../internal_user_endpoints.py | 146 ++--------- .../tag_management_endpoints.py | 31 ++- .../management_endpoints/team_endpoints.py | 69 +++++- .../common_daily_activity.py | 1 + litellm/types/tag_management.py | 21 ++ ui/litellm-dashboard/src/app/page.tsx | 1 + .../src/components/activity_metrics.tsx | 18 +- .../src/components/entity_usage.tsx | 232 +++++++++++------- .../src/components/leftnav.tsx | 4 +- .../src/components/networking.tsx | 1 + .../src/components/new_usage.tsx | 68 +++-- .../src/components/usage/types.ts | 12 +- 16 files changed, 429 insertions(+), 287 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index f72fcd5b33..150528fd8f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -287,6 +287,7 @@ class LiteLLMRoutes(enum.Enum): "/v1/models", # token counter "/utils/token_counter", + "/utils/transform_request", # rerank "/rerank", "/v1/rerank", @@ -462,6 +463,7 @@ class LiteLLMRoutes(enum.Enum): "/team/member_delete", "/team/permissions_list", "/team/permissions_update", + "/team/daily/activity", "/model/new", "/model/update", "/model/delete", @@ -650,9 +652,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase): allowed_cache_controls: Optional[list] = [] config: Optional[dict] = {} permissions: Optional[dict] = {} - model_max_budget: Optional[dict] = ( - {} - ) # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {} + model_max_budget: Optional[ + dict + ] = {} # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {} model_config = ConfigDict(protected_namespaces=()) model_rpm_limit: Optional[dict] = None @@ -911,12 +913,12 @@ class NewCustomerRequest(BudgetNewRequest): alias: Optional[str] = None # human-friendly alias blocked: bool = False # allow/disallow requests for this end-user budget_id: Optional[str] = None # give either a budget_id or max_budget - allowed_model_region: Optional[AllowedModelRegion] = ( - None # require all user requests to use models in this specific region - ) - default_model: Optional[str] = ( - None # if no equivalent model in allowed region - default all requests to this model - ) + allowed_model_region: Optional[ + AllowedModelRegion + ] = None # require all user requests to use models in this specific region + default_model: Optional[ + str + ] = None # if no equivalent model in allowed region - default all requests to this model @model_validator(mode="before") @classmethod @@ -938,12 +940,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase): blocked: bool = False # allow/disallow requests for this end-user max_budget: Optional[float] = None budget_id: Optional[str] = None # give either a budget_id or max_budget - allowed_model_region: Optional[AllowedModelRegion] = ( - None # require all user requests to use models in this specific region - ) - default_model: Optional[str] = ( - None # if no equivalent model in allowed region - default all requests to this model - ) + allowed_model_region: Optional[ + AllowedModelRegion + ] = None # require all user requests to use models in this specific region + default_model: Optional[ + str + ] = None # if no equivalent model in allowed region - default all requests to this model class DeleteCustomerRequest(LiteLLMPydanticObjectBase): @@ -1079,9 +1081,9 @@ class BlockKeyRequest(LiteLLMPydanticObjectBase): class AddTeamCallback(LiteLLMPydanticObjectBase): callback_name: str - callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = ( - "success_and_failure" - ) + callback_type: Optional[ + Literal["success", "failure", "success_and_failure"] + ] = "success_and_failure" callback_vars: Dict[str, str] @model_validator(mode="before") @@ -1339,9 +1341,9 @@ class ConfigList(LiteLLMPydanticObjectBase): stored_in_db: Optional[bool] field_default_value: Any premium_field: bool = False - nested_fields: Optional[List[FieldDetail]] = ( - None # For nested dictionary or Pydantic fields - ) + nested_fields: Optional[ + List[FieldDetail] + ] = None # For nested dictionary or Pydantic fields class ConfigGeneralSettings(LiteLLMPydanticObjectBase): @@ -1609,9 +1611,9 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase): budget_id: Optional[str] = None created_at: datetime updated_at: datetime - user: Optional[Any] = ( - None # You might want to replace 'Any' with a more specific type if available - ) + user: Optional[ + Any + ] = None # You might want to replace 'Any' with a more specific type if available litellm_budget_table: Optional[LiteLLM_BudgetTable] = None model_config = ConfigDict(protected_namespaces=()) @@ -2359,9 +2361,9 @@ class TeamModelDeleteRequest(BaseModel): # Organization Member Requests class OrganizationMemberAddRequest(OrgMemberAddRequest): organization_id: str - max_budget_in_organization: Optional[float] = ( - None # Users max budget within the organization - ) + max_budget_in_organization: Optional[ + float + ] = None # Users max budget within the organization class OrganizationMemberDeleteRequest(MemberDeleteRequest): @@ -2550,9 +2552,9 @@ class ProviderBudgetResponse(LiteLLMPydanticObjectBase): Maps provider names to their budget configs. """ - providers: Dict[str, ProviderBudgetResponseObject] = ( - {} - ) # Dictionary mapping provider names to their budget configurations + providers: Dict[ + str, ProviderBudgetResponseObject + ] = {} # Dictionary mapping provider names to their budget configurations class ProxyStateVariables(TypedDict): @@ -2680,9 +2682,9 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): enforce_rbac: bool = False roles_jwt_field: Optional[str] = None # v2 on role mappings role_mappings: Optional[List[RoleMapping]] = None - object_id_jwt_field: Optional[str] = ( - None # can be either user / team, inferred from the role mapping - ) + object_id_jwt_field: Optional[ + str + ] = None # can be either user / team, inferred from the role mapping scope_mappings: Optional[List[ScopeMapping]] = None enforce_scope_based_access: bool = False enforce_team_based_model_access: bool = False diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 4877ad0a36..097f798de2 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -433,14 +433,13 @@ class LiteLLMProxyRequestSetup: ) -> Optional[List[str]]: tags = None - if llm_router and llm_router.enable_tag_filtering is True: - # Check request headers for tags - if "x-litellm-tags" in headers: - if isinstance(headers["x-litellm-tags"], str): - _tags = headers["x-litellm-tags"].split(",") - tags = [tag.strip() for tag in _tags] - elif isinstance(headers["x-litellm-tags"], list): - tags = headers["x-litellm-tags"] + # Check request headers for tags + if "x-litellm-tags" in headers: + if isinstance(headers["x-litellm-tags"], str): + _tags = headers["x-litellm-tags"].split(",") + tags = [tag.strip() for tag in _tags] + elif isinstance(headers["x-litellm-tags"], list): + tags = headers["x-litellm-tags"] # Check request body for tags if "tags" in data and isinstance(data["tags"], list): tags = data["tags"] diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index e5604ed79d..d5de85311b 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -39,6 +39,7 @@ def update_breakdown_metrics( provider_metadata: Dict[str, Dict[str, Any]], api_key_metadata: Dict[str, Dict[str, Any]], entity_id_field: Optional[str] = None, + entity_metadata_field: Optional[Dict[str, dict]] = None, ) -> BreakdownMetrics: """Updates breakdown metrics for a single record using the existing update_metrics function""" @@ -74,7 +75,8 @@ def update_breakdown_metrics( metadata=KeyMetadata( key_alias=api_key_metadata.get(record.api_key, {}).get( "key_alias", None - ) + ), + team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), ), # Add any api_key-specific metadata here ) breakdown.api_keys[record.api_key].metrics = update_metrics( @@ -87,7 +89,10 @@ def update_breakdown_metrics( if entity_value: if entity_value not in breakdown.entities: breakdown.entities[entity_value] = MetricWithMetadata( - metrics=SpendMetrics(), metadata={} + metrics=SpendMetrics(), + metadata=entity_metadata_field.get(entity_value, {}) + if entity_metadata_field + else {}, ) breakdown.entities[entity_value].metrics = update_metrics( breakdown.entities[entity_value].metrics, record @@ -101,12 +106,14 @@ async def get_daily_activity( table_name: str, entity_id_field: str, entity_id: Optional[Union[str, List[str]]], + entity_metadata_field: Optional[Dict[str, dict]], start_date: Optional[str], end_date: Optional[str], model: Optional[str], api_key: Optional[str], page: int, page_size: int, + exclude_entity_ids: Optional[List[str]] = None, ) -> SpendAnalyticsPaginatedResponse: """Common function to get daily activity for any entity type.""" if prisma_client is None: @@ -139,6 +146,10 @@ async def get_daily_activity( where_conditions[entity_id_field] = {"in": entity_id} else: where_conditions[entity_id_field] = entity_id + if exclude_entity_ids: + where_conditions.setdefault(entity_id_field, {})["not"] = { + "in": exclude_entity_ids + } # Get total count for pagination total_count = await getattr(prisma_client.db, table_name).count( @@ -170,7 +181,10 @@ async def get_daily_activity( where={"token": {"in": list(api_keys)}} ) api_key_metadata.update( - {k.token: {"key_alias": k.key_alias} for k in key_records} + { + k.token: {"key_alias": k.key_alias, "team_id": k.team_id} + for k in key_records + } ) # Process results @@ -198,6 +212,7 @@ async def get_daily_activity( provider_metadata, api_key_metadata, entity_id_field=entity_id_field, + entity_metadata_field=entity_metadata_field, ) # Update total metrics diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 550ff44616..87bf7f5799 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -4,11 +4,19 @@ from litellm.proxy._types import ( GenerateKeyRequest, LiteLLM_ManagementEndpoint_MetadataFields_Premium, LiteLLM_TeamTable, + LitellmUserRoles, UserAPIKeyAuth, ) from litellm.proxy.utils import _premium_user_check +def _user_has_admin_view(user_api_key_dict: UserAPIKeyAuth) -> bool: + return ( + user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + def _is_user_team_admin( user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable ) -> bool: diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index a91bc2dc62..e2f1077095 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -25,6 +25,8 @@ from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.duration_parser import duration_in_seconds from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity +from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, prepare_metadata_fields, @@ -1382,136 +1384,22 @@ async def get_user_daily_activity( ) try: - # Build filter conditions - where_conditions: Dict[str, Any] = { - "date": { - "gte": start_date, - "lte": end_date, - } - } + entity_id: Optional[str] = None + if not _user_has_admin_view(user_api_key_dict): + entity_id = user_api_key_dict.user_id - if model: - where_conditions["model"] = model - if api_key: - where_conditions["api_key"] = api_key - - if ( - user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN - and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY - ): - where_conditions[ - "user_id" - ] = user_api_key_dict.user_id # only allow access to own data - - # Get total count for pagination - total_count = await prisma_client.db.litellm_dailyuserspend.count( - where=where_conditions - ) - - # Fetch paginated results - daily_spend_data = await prisma_client.db.litellm_dailyuserspend.find_many( - where=where_conditions, - order=[ - {"date": "desc"}, - ], - skip=(page - 1) * page_size, - take=page_size, - ) - - daily_spend_data_pydantic_list = [ - LiteLLM_DailyUserSpend(**record.model_dump()) for record in daily_spend_data - ] - - # Get all unique API keys from the spend data - api_keys = set() - for record in daily_spend_data_pydantic_list: - if record.api_key: - api_keys.add(record.api_key) - - # Fetch key aliases in bulk - - api_key_metadata: Dict[str, Dict[str, Any]] = {} - model_metadata: Dict[str, Dict[str, Any]] = {} - provider_metadata: Dict[str, Dict[str, Any]] = {} - if api_keys: - key_records = await prisma_client.db.litellm_verificationtoken.find_many( - where={"token": {"in": list(api_keys)}} - ) - api_key_metadata.update( - {k.token: {"key_alias": k.key_alias} for k in key_records} - ) - # Process results - results = [] - total_metrics = SpendMetrics() - - # Group data by date and other dimensions - - grouped_data: Dict[str, Dict[str, Any]] = {} - for record in daily_spend_data_pydantic_list: - date_str = record.date - if date_str not in grouped_data: - grouped_data[date_str] = { - "metrics": SpendMetrics(), - "breakdown": BreakdownMetrics(), - } - - # Update metrics - grouped_data[date_str]["metrics"] = update_metrics( - grouped_data[date_str]["metrics"], record - ) - # Update breakdowns - grouped_data[date_str]["breakdown"] = update_breakdown_metrics( - grouped_data[date_str]["breakdown"], - record, - model_metadata, - provider_metadata, - api_key_metadata, - ) - - # Update total metrics - total_metrics.spend += record.spend - total_metrics.prompt_tokens += record.prompt_tokens - total_metrics.completion_tokens += record.completion_tokens - total_metrics.total_tokens += ( - record.prompt_tokens + record.completion_tokens - ) - total_metrics.cache_read_input_tokens += record.cache_read_input_tokens - total_metrics.cache_creation_input_tokens += ( - record.cache_creation_input_tokens - ) - total_metrics.api_requests += record.api_requests - total_metrics.successful_requests += record.successful_requests - total_metrics.failed_requests += record.failed_requests - - # Convert grouped data to response format - for date_str, data in grouped_data.items(): - results.append( - DailySpendData( - date=datetime.strptime(date_str, "%Y-%m-%d").date(), - metrics=data["metrics"], - breakdown=data["breakdown"], - ) - ) - - # Sort results by date - results.sort(key=lambda x: x.date, reverse=True) - - return SpendAnalyticsPaginatedResponse( - results=results, - metadata=DailySpendMetadata( - total_spend=total_metrics.spend, - total_prompt_tokens=total_metrics.prompt_tokens, - total_completion_tokens=total_metrics.completion_tokens, - total_tokens=total_metrics.total_tokens, - total_api_requests=total_metrics.api_requests, - total_successful_requests=total_metrics.successful_requests, - total_failed_requests=total_metrics.failed_requests, - total_cache_read_input_tokens=total_metrics.cache_read_input_tokens, - total_cache_creation_input_tokens=total_metrics.cache_creation_input_tokens, - page=page, - total_pages=-(-total_count // page_size), # Ceiling division - has_more=(page * page_size) < total_count, - ), + return await get_daily_activity( + prisma_client=prisma_client, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=entity_id, + entity_metadata_field=None, + start_date=start_date, + end_date=end_date, + model=model, + api_key=api_key, + page=page, + page_size=page_size, ) except Exception as e: diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 79a69a16c1..7c731400fb 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -12,7 +12,7 @@ All /tag management endpoints import datetime import json -from typing import Dict, Optional +from typing import Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException @@ -25,6 +25,7 @@ from litellm.proxy.management_endpoints.common_daily_activity import ( get_daily_activity, ) from litellm.types.tag_management import ( + LiteLLM_DailyTagSpendTable, TagConfig, TagDeleteRequest, TagInfoRequest, @@ -301,6 +302,7 @@ async def info_tag( "/tag/list", tags=["tag management"], dependencies=[Depends(user_api_key_auth)], + response_model=List[TagConfig], ) async def list_tags( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -314,9 +316,33 @@ async def list_tags( raise HTTPException(status_code=500, detail="Database not connected") try: + ## QUERY STORED TAGS ## tags_config = await _get_tags_config(prisma_client) list_of_tags = list(tags_config.values()) - return list_of_tags + + ## QUERY DYNAMIC TAGS ## + dynamic_tags = await prisma_client.db.litellm_dailytagspend.find_many( + distinct=["tag"], + ) + + dynamic_tags_list = [ + LiteLLM_DailyTagSpendTable(**dynamic_tag.model_dump()) + for dynamic_tag in dynamic_tags + ] + + dynamic_tag_config = [ + TagConfig( + name=tag.tag, + description="This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.", + models=None, + created_at=tag.created_at.isoformat(), + updated_at=tag.updated_at.isoformat(), + ) + for tag in dynamic_tags_list + if tag.tag not in tags_config + ] + + return list_of_tags + dynamic_tag_config except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -400,6 +426,7 @@ async def get_tag_daily_activity( table_name="litellm_dailytagspend", entity_id_field="tag", entity_id=tag_list, + entity_metadata_field=None, start_date=start_date, end_date=end_date, model=model, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 706f7d2c2f..1f23a5401c 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -56,11 +56,13 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_checks import ( allowed_route_check_inside_route, get_team_object, + get_user_object, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.common_utils import ( _is_user_team_admin, _set_object_metadata_field, + _user_has_admin_view, ) from litellm.proxy.management_endpoints.tag_management_endpoints import ( get_daily_activity, @@ -2091,7 +2093,6 @@ async def update_team_member_permissions( "/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, @@ -2101,6 +2102,8 @@ async def get_team_daily_activity( api_key: Optional[str] = None, page: int = 1, page_size: int = 10, + exclude_team_ids: Optional[str] = None, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ Get daily activity for specific teams or all teams. @@ -2113,20 +2116,80 @@ async def get_team_daily_activity( api_key (Optional[str]): Filter by API key. page (int): Page number for pagination. page_size (int): Number of items per page. - + exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. Returns: SpendAnalyticsPaginatedResponse: Paginated response containing daily activity data. """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) # Convert comma-separated tags string to list if provided team_ids_list = team_ids.split(",") if team_ids else None + exclude_team_ids_list: Optional[List[str]] = None + + if exclude_team_ids: + exclude_team_ids_list = ( + exclude_team_ids.split(",") if exclude_team_ids else None + ) + + if not _user_has_admin_view(user_api_key_dict): + user_info = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_id_upsert=False, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_dict.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + if user_info is None: + raise HTTPException( + status_code=404, + detail={ + "error": "User= {} not found".format(user_api_key_dict.user_id) + }, + ) + if team_ids_list is None: + team_ids_list = user_info.teams + else: + # check if all team_ids are in user_info.teams + for team_id in team_ids_list: + if team_id not in user_info.teams: + raise HTTPException( + status_code=404, + detail={ + "error": "User does not belong to Team= {}. Call `/user/info` to see user's teams".format( + team_id + ) + }, + ) + + ## Fetch team aliases + where_condition = {} + if team_ids_list: + where_condition["team_id"] = {"in": list(team_ids_list)} + team_aliases = await prisma_client.db.litellm_teamtable.find_many( + where=where_condition + ) + team_alias_metadata = { + t.team_id: {"team_alias": t.team_alias} for t in team_aliases + } return await get_daily_activity( prisma_client=prisma_client, table_name="litellm_dailyteamspend", entity_id_field="team_id", entity_id=team_ids_list, + entity_metadata_field=team_alias_metadata, + exclude_entity_ids=exclude_team_ids_list, start_date=start_date, end_date=end_date, model=model, diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 9408035746..6213087f64 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -39,6 +39,7 @@ class KeyMetadata(BaseModel): """Metadata for a key""" key_alias: Optional[str] = None + team_id: Optional[str] = None class KeyMetricWithMetadata(MetricBase): diff --git a/litellm/types/tag_management.py b/litellm/types/tag_management.py index e530b37cab..a3615b658c 100644 --- a/litellm/types/tag_management.py +++ b/litellm/types/tag_management.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Dict, List, Optional from pydantic import BaseModel @@ -30,3 +31,23 @@ class TagDeleteRequest(BaseModel): class TagInfoRequest(BaseModel): names: List[str] + + +class LiteLLM_DailyTagSpendTable(BaseModel): + id: str + tag: str + date: str + api_key: str + model: str + model_group: Optional[str] + custom_llm_provider: Optional[str] + prompt_tokens: int + completion_tokens: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + spend: float + api_requests: int + successful_requests: int + failed_requests: int + created_at: datetime + updated_at: datetime diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 6790f9dc61..ae256bf0ac 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -370,6 +370,7 @@ export default function CreateKeyPage() { userID={userID} userRole={userRole} accessToken={accessToken} + teams={teams as Team[] ?? []} /> ) : ( diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index df54561b16..32cba95d9e 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Card, Grid, Text, Title, Accordion, AccordionHeader, AccordionBody } from '@tremor/react'; import { AreaChart, BarChart } from '@tremor/react'; -import { SpendMetrics, DailyData, ModelActivityData } from './usage/types'; +import { SpendMetrics, DailyData, ModelActivityData, MetricWithMetadata, KeyMetricWithMetadata } from './usage/types'; import { Collapse } from 'antd'; interface ActivityMetricsProps { @@ -224,7 +224,7 @@ export const ActivityMetrics: React.FC = ({ modelMetrics } key={modelName} header={
- {modelName || 'Unknown Model'} + {modelMetrics[modelName].label || 'Unknown Item'}
${modelMetrics[modelName].total_spend.toFixed(2)} {modelMetrics[modelName].total_requests.toLocaleString()} requests @@ -243,14 +243,24 @@ export const ActivityMetrics: React.FC = ({ modelMetrics } ); }; +// Helper function to format key label +const formatKeyLabel = (modelData: KeyMetricWithMetadata, model: string): string => { + const keyAlias = modelData.metadata.key_alias || `key-hash-${model}`; + const teamId = modelData.metadata.team_id; + return teamId ? `${keyAlias} (team_id: ${teamId})` : keyAlias; +}; + // Process data function -export const processActivityData = (dailyActivity: { results: DailyData[] }): Record => { +export const processActivityData = (dailyActivity: { results: DailyData[] }, key: "models" | "api_keys"): Record => { const modelMetrics: Record = {}; dailyActivity.results.forEach((day) => { - Object.entries(day.breakdown.models || {}).forEach(([model, modelData]) => { + Object.entries(day.breakdown[key] || {}).forEach(([model, modelData]) => { if (!modelMetrics[model]) { modelMetrics[model] = { + label: key === 'api_keys' + ? formatKeyLabel(modelData as KeyMetricWithMetadata, model) + : model, total_requests: 0, total_successful_requests: 0, total_failed_requests: 0, diff --git a/ui/litellm-dashboard/src/components/entity_usage.tsx b/ui/litellm-dashboard/src/components/entity_usage.tsx index e4cfb9bfae..a9a13b3847 100644 --- a/ui/litellm-dashboard/src/components/entity_usage.tsx +++ b/ui/litellm-dashboard/src/components/entity_usage.tsx @@ -8,8 +8,9 @@ import { } from "@tremor/react"; import { Select } from 'antd'; import { ActivityMetrics, processActivityData } from './activity_metrics'; -import { SpendMetrics, DailyData } from './usage/types'; +import { DailyData, KeyMetricWithMetadata, EntityMetricWithMetadata } from './usage/types'; import { tagDailyActivityCall, teamDailyActivityCall } from './networking'; +import TopKeyView from "./top_key_view"; interface EntityMetrics { metrics: { @@ -48,16 +49,27 @@ interface EntitySpendData { }; } +export interface EntityList { + label: string; + value: string; +} + interface EntityUsageProps { accessToken: string | null; entityType: 'tag' | 'team'; entityId?: string | null; + userID: string | null; + userRole: string | null; + entityList: EntityList[] | null; } const EntityUsage: React.FC = ({ accessToken, entityType, - entityId + entityId, + userID, + userRole, + entityList }) => { const [spendData, setSpendData] = useState({ results: [], @@ -70,8 +82,8 @@ const EntityUsage: React.FC = ({ } }); - const modelMetrics = processActivityData(spendData); - + const modelMetrics = processActivityData(spendData, "models"); + const keyMetrics = processActivityData(spendData, "api_keys"); const [selectedTags, setSelectedTags] = useState([]); const [dateValue, setDateValue] = useState({ from: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000), @@ -144,29 +156,46 @@ const EntityUsage: React.FC = ({ .slice(0, 5); }; - const getTopApiKeys = () => { - const apiKeySpend: { [key: string]: any } = {}; + const getTopAPIKeys = () => { + const keySpend: { [key: string]: KeyMetricWithMetadata } = {}; spendData.results.forEach(day => { Object.entries(day.breakdown.api_keys || {}).forEach(([key, metrics]) => { - if (!apiKeySpend[key]) { - apiKeySpend[key] = { - key: key, - spend: 0, - requests: 0, - successful_requests: 0, - failed_requests: 0, - tokens: 0 + if (!keySpend[key]) { + keySpend[key] = { + metrics: { + spend: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + api_requests: 0, + successful_requests: 0, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0 + }, + metadata: { + key_alias: metrics.metadata.key_alias + } }; } - apiKeySpend[key].spend += metrics.metrics.spend; - apiKeySpend[key].requests += metrics.metrics.api_requests; - apiKeySpend[key].successful_requests += metrics.metrics.successful_requests; - apiKeySpend[key].failed_requests += metrics.metrics.failed_requests; - apiKeySpend[key].tokens += metrics.metrics.total_tokens; + keySpend[key].metrics.spend += metrics.metrics.spend; + keySpend[key].metrics.prompt_tokens += metrics.metrics.prompt_tokens; + keySpend[key].metrics.completion_tokens += metrics.metrics.completion_tokens; + keySpend[key].metrics.total_tokens += metrics.metrics.total_tokens; + keySpend[key].metrics.api_requests += metrics.metrics.api_requests; + keySpend[key].metrics.successful_requests += metrics.metrics.successful_requests; + keySpend[key].metrics.failed_requests += metrics.metrics.failed_requests; + keySpend[key].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0; + keySpend[key].metrics.cache_creation_input_tokens += metrics.metrics.cache_creation_input_tokens || 0; }); }); - return Object.values(apiKeySpend) + return Object.entries(keySpend) + .map(([api_key, metrics]) => ({ + api_key, + key_alias: metrics.metadata.key_alias || "-", // Using truncated key as alias + spend: metrics.metrics.spend, + })) .sort((a, b) => b.spend - a.spend) .slice(0, 5); }; @@ -203,47 +232,49 @@ const EntityUsage: React.FC = ({ }; const getAllTags = () => { - const tags = new Set(); - spendData.results.forEach(day => { - Object.keys(day.breakdown.entities || {}).forEach(tag => { - tags.add(tag); - }); - }); - return Array.from(tags).map(tag => ({ - label: tag, - value: tag - })); + if (entityList) { + return entityList; + } }; - const filterDataByTags = (data: any[]) => { + const filterDataByTags = (data: EntityMetricWithMetadata[]) => { if (selectedTags.length === 0) return data; - return data.filter(item => selectedTags.includes(item.entity)); + return data.filter(item => selectedTags.includes(item.metadata.id)); }; const getEntityBreakdown = () => { - const entitySpend: { [key: string]: any } = {}; + const entitySpend: { [key: string]: EntityMetricWithMetadata } = {}; spendData.results.forEach(day => { Object.entries(day.breakdown.entities || {}).forEach(([entity, data]) => { if (!entitySpend[entity]) { entitySpend[entity] = { - entity, - spend: 0, - requests: 0, - successful_requests: 0, - failed_requests: 0, - tokens: 0 + metrics: { + spend: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + api_requests: 0, + successful_requests: 0, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0 + }, + metadata: { + alias: data.metadata.team_alias || entity, + id: entity + } }; } - entitySpend[entity].spend += data.metrics.spend; - entitySpend[entity].requests += data.metrics.api_requests; - entitySpend[entity].successful_requests += data.metrics.successful_requests; - entitySpend[entity].failed_requests += data.metrics.failed_requests; - entitySpend[entity].tokens += data.metrics.total_tokens; + entitySpend[entity].metrics.spend += data.metrics.spend; + entitySpend[entity].metrics.api_requests += data.metrics.api_requests; + entitySpend[entity].metrics.successful_requests += data.metrics.successful_requests; + entitySpend[entity].metrics.failed_requests += data.metrics.failed_requests; + entitySpend[entity].metrics.total_tokens += data.metrics.total_tokens; }); }); const result = Object.values(entitySpend) - .sort((a, b) => b.spend - a.spend); + .sort((a, b) => b.metrics.spend - a.metrics.spend); return filterDataByTags(result); }; @@ -261,9 +292,10 @@ const EntityUsage: React.FC = ({ onValueChange={setDateValue} /> - - Filter by {entityType === 'tag' ? 'Tags' : 'Teams'} - = ({ options={getAllTags()} className="mt-2" allowClear - /> - + /> + + )} Cost - Activity + Model Activity + Key Activity @@ -324,20 +358,45 @@ const EntityUsage: React.FC = ({ {/* Daily Spend Chart */} - - Daily Spend - - 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} - /> - + + Daily Spend + + 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} + customTooltip={({ payload, active }) => { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.date}

+

Total Spend: ${data.metrics.spend.toFixed(2)}

+

Total Requests: {data.metrics.api_requests}

+

Successful: {data.metrics.successful_requests}

+

Failed: {data.metrics.failed_requests}

+

Total Tokens: {data.metrics.total_tokens}

+
+

Spend by {entityType === 'tag' ? 'Tag' : 'Team'}:

+ {Object.entries(data.breakdown.entities || {}).map(([entity, entityData]) => { + const metrics = entityData as EntityMetrics; + return ( +

+ {metrics.metadata.team_alias || entity}: ${metrics.metrics.spend.toFixed(2)} +

+ ); + })} +
+
+ ); + }} + /> +
{/* Entity Breakdown Section */} @@ -353,13 +412,13 @@ const EntityUsage: React.FC = ({
- - + + `$${value.toFixed(4)}`} layout="vertical" @@ -380,18 +439,18 @@ const EntityUsage: React.FC = ({ {getEntityBreakdown() - .filter(entity => entity.spend > 0) + .filter(entity => entity.metrics.spend > 0) .map((entity) => ( - - {entity.entity} - ${entity.spend.toFixed(4)} + + {entity.metadata.alias} + ${entity.metrics.spend.toFixed(4)} - {entity.successful_requests.toLocaleString()} + {entity.metrics.successful_requests.toLocaleString()} - {entity.failed_requests.toLocaleString()} + {entity.metrics.failed_requests.toLocaleString()} - {entity.tokens.toLocaleString()} + {entity.metrics.total_tokens.toLocaleString()} ))} @@ -407,17 +466,13 @@ const EntityUsage: React.FC = ({ Top API Keys - `$${value.toFixed(2)}`} - layout="vertical" - yAxisWidth={200} - showLegend={false} - /> + @@ -494,6 +549,9 @@ const EntityUsage: React.FC = ({ + + + diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index b3cf7018ac..879660ee40 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -57,7 +57,7 @@ const Sidebar: React.FC = ({ { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, { key: "3", page: "llm-playground", label: "Test Key", icon: , roles: rolesWithWriteAccess }, { key: "2", page: "models", label: "Models", icon: , roles: rolesWithWriteAccess }, - { key: "4", page: "usage", label: "Usage", icon: }, + { key: "12", page: "new_usage", label: "Usage", icon: , roles: [...all_admin_roles, ...internalUserRoles] }, { key: "6", page: "teams", label: "Teams", icon: }, { key: "17", page: "organizations", label: "Organizations", icon: , roles: all_admin_roles }, { key: "5", page: "users", label: "Internal Users", icon: , roles: all_admin_roles }, @@ -73,7 +73,7 @@ const Sidebar: React.FC = ({ { key: "9", page: "caching", label: "Caching", icon: , roles: all_admin_roles }, { key: "10", page: "budgets", label: "Budgets", icon: , roles: all_admin_roles }, { key: "11", page: "guardrails", label: "Guardrails", icon: , roles: all_admin_roles }, - { key: "12", page: "new_usage", label: "New Usage", icon: , roles: [...all_admin_roles, ...internalUserRoles] }, + { key: "4", page: "usage", label: "Old Usage", icon: }, { key: "20", page: "transform-request", label: "API Playground", icon: , roles: [...all_admin_roles, ...internalUserRoles] }, { key: "18", page: "mcp-tools", label: "MCP Tools", icon: , roles: all_admin_roles }, { key: "19", page: "tag-management", label: "Tag Management", icon: , roles: all_admin_roles }, diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f14bf0b774..f16aaf30fa 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1200,6 +1200,7 @@ export const teamDailyActivityCall = async (accessToken: String, startTime: Date if (teamIds) { queryParams.append('team_ids', teamIds.join(',')); } + queryParams.append('exclude_team_ids', 'litellm-dashboard'); const queryString = queryParams.toString(); if (queryString) { url += `?${queryString}`; diff --git a/ui/litellm-dashboard/src/components/new_usage.tsx b/ui/litellm-dashboard/src/components/new_usage.tsx index aab1ae3d0d..b6378cd9cc 100644 --- a/ui/litellm-dashboard/src/components/new_usage.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.tsx @@ -17,23 +17,29 @@ import { } from "@tremor/react"; import { AreaChart } from "@tremor/react"; -import { userDailyActivityCall } from "./networking"; +import { userDailyActivityCall, tagListCall } from "./networking"; +import { Tag } from "./tag_management/types"; import ViewUserSpend from "./view_user_spend"; import TopKeyView from "./top_key_view"; import { ActivityMetrics, processActivityData } from './activity_metrics'; import { SpendMetrics, DailyData, ModelActivityData, MetricWithMetadata, KeyMetricWithMetadata } from './usage/types'; import EntityUsage from './entity_usage'; +import { old_admin_roles, v2_admin_role_names, all_admin_roles, rolesAllowedToSeeUsage, rolesWithWriteAccess, internalUserRoles } from '../utils/roles'; +import { Team } from "./key_team_helpers/key_list"; +import { EntityList } from "./entity_usage"; interface NewUsagePageProps { accessToken: string | null; userRole: string | null; userID: string | null; + teams: Team[]; } const NewUsagePage: React.FC = ({ accessToken, userRole, userID, + teams }) => { const [userSpendData, setUserSpendData] = useState<{ results: DailyData[]; @@ -46,6 +52,23 @@ const NewUsagePage: React.FC = ({ to: new Date(), }); + const [allTags, setAllTags] = useState([]); + + const getAllTags = async () => { + if (!accessToken) { + return; + } + const tags = await tagListCall(accessToken); + setAllTags(Object.values(tags).map((tag: Tag) => ({ + label: tag.name, + value: tag.name + }))); + }; + + useEffect(() => { + getAllTags(); + }, [accessToken]); + // Derived states from userSpendData const totalSpend = userSpendData.metadata?.total_spend || 0; @@ -227,16 +250,19 @@ const NewUsagePage: React.FC = ({ fetchUserSpendData(); }, [accessToken, dateValue]); - const modelMetrics = processActivityData(userSpendData); + const modelMetrics = processActivityData(userSpendData, "models"); + const keyMetrics = processActivityData(userSpendData, "api_keys"); return (
- Usage Analytics Dashboard + + This is the new usage dashboard.
You may see empty data, as these use new aggregate tables to allow UI to work at 1M+ spend logs. To access the old dashboard, go to Experimental {'>'} Old Usage. +
- Your Usage - Tag Usage + {all_admin_roles.includes(userRole || "") ? Global Usage : Your Usage} Team Usage + {all_admin_roles.includes(userRole || "") ? Tag Usage : <>} {/* Your Usage Panel */} @@ -256,7 +282,8 @@ const NewUsagePage: React.FC = ({ Cost - Activity + Model Activity + Key Activity {/* Cost Panel */} @@ -459,25 +486,38 @@ const NewUsagePage: React.FC = ({ + + + - {/* Tag Usage Panel */} - - - - {/* Team Usage Panel */} ({ + label: team.team_alias, + value: team.team_id + })) || null} /> + + {/* Tag Usage Panel */} + + + +
diff --git a/ui/litellm-dashboard/src/components/usage/types.ts b/ui/litellm-dashboard/src/components/usage/types.ts index fbc893b7bd..46d96b4aa1 100644 --- a/ui/litellm-dashboard/src/components/usage/types.ts +++ b/ui/litellm-dashboard/src/components/usage/types.ts @@ -31,10 +31,12 @@ export interface KeyMetricWithMetadata { metrics: SpendMetrics; metadata: { key_alias: string | null; + team_id?: string | null; }; } export interface ModelActivityData { + label: string; total_requests: number; total_successful_requests: number; total_failed_requests: number; @@ -62,11 +64,17 @@ export interface ModelActivityData { export interface KeyMetadata { key_alias: string | null; + team_id: string | null; } -export interface KeyMetricWithMetadata { +export interface EntityMetadata { + alias: string; + id: string; +} + +export interface EntityMetricWithMetadata { metrics: SpendMetrics; - metadata: KeyMetadata; + metadata: EntityMetadata; } export interface MetricWithMetadata {