diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index b2eda76f9ae..97ab91a46ac 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -11,11 +11,13 @@ Endpoints for /project operations #### PROJECT MANAGEMENT #### import json -from collections.abc import Sequence -from typing import TYPE_CHECKING, Final +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Final, NamedTuple, TypedDict from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import TypeAdapter +from typing_extensions import ReadOnly from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -34,6 +36,10 @@ from litellm.repositories.project_repository import ProjectRepository from litellm.repositories.team_repository import TeamRepository from litellm.repositories.user_repository import UserRepository from litellm.repositories.verification_token_repository import VerificationTokenRepository +from litellm.types.proxy.management_endpoints.project_endpoints import ( + ProjectDailySpendResponse, + ProjectDailySpendRow, +) if TYPE_CHECKING: from prisma import models as prisma_models @@ -1083,3 +1089,198 @@ async def list_projects( ) ) raise handle_exception_on_proxy(e) + + +def _project_daily_activity_error(*, status_code: int, message: str) -> HTTPException: + """Mirrors _daily_activity_error() from team_endpoints.py.""" + return HTTPException(status_code=status_code, detail={"error": message}) # mutable-ok: FastAPI JSON detail + + +_MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS: Final = 400 + + +def _project_daily_activity_date_range_error(start_date: str | None, end_date: str | None) -> str | None: + """Mirrors _aggregated_date_range_error() from team_endpoints.py. + + There is no daily-aggregated project spend table, so this endpoint scans + LiteLLM_SpendLogs directly and needs the same guard against an unbounded range. + """ + if start_date is None or end_date is None: + return "Please provide start_date and end_date" + try: + parsed_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + parsed_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + return "start_date and end_date must be valid YYYY-MM-DD dates" + if parsed_end < parsed_start: + return "end_date must be on or after start_date" + if (parsed_end - parsed_start).days > _MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS: + return f"Date range must be at most {_MAX_PROJECT_DAILY_ACTIVITY_RANGE_DAYS} days" + return None + + +def _project_daily_spend_sql(*, project_count: int) -> str: + project_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + project_count)) + return f""" + SELECT + (DATE_TRUNC('day', sl."startTime" AT TIME ZONE 'UTC'))::date::text AS spend_date, + sl.metadata->>'user_api_key_project_id' AS project_id, + SUM(sl.spend)::float AS spend, + SUM(sl.prompt_tokens)::bigint AS prompt_tokens, + SUM(sl.completion_tokens)::bigint AS completion_tokens, + SUM(sl.total_tokens)::bigint AS total_tokens, + COUNT(*)::bigint AS api_requests, + COUNT(*) FILTER (WHERE sl.status IS DISTINCT FROM 'failure')::bigint AS successful_requests, + COUNT(*) FILTER (WHERE sl.status = 'failure')::bigint AS failed_requests + FROM "LiteLLM_SpendLogs" sl + WHERE sl."startTime" >= $1::timestamp + AND sl."startTime" < $2::timestamp + INTERVAL '1 day' + AND sl.metadata->>'user_api_key_project_id' IN ({project_placeholders}) + GROUP BY spend_date, project_id + ORDER BY spend_date, project_id + """ + + +class _ProjectDailySpendDbRow(TypedDict): + spend_date: ReadOnly[str] + project_id: ReadOnly[str | None] + spend: ReadOnly[float] + prompt_tokens: ReadOnly[int] + completion_tokens: ReadOnly[int] + total_tokens: ReadOnly[int] + api_requests: ReadOnly[int] + successful_requests: ReadOnly[int] + failed_requests: ReadOnly[int] + + +class _ProjectDailyActivityScope(NamedTuple): + project_ids: tuple[str, ...] + project_alias_by_id: Mapping[str, str | None] + + +async def _resolve_project_daily_activity_scope( + *, + project_ids: str, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, +) -> _ProjectDailyActivityScope: + """ + Resolve which of the requested projects the caller may view. + + Proxy admins may query any project. Everyone else must be an admin of the + project's team, the same permission /project/update enforces. + """ + requested: Final = tuple(pid.strip() for pid in project_ids.split(",") if pid.strip()) + if not requested: + return _ProjectDailyActivityScope(project_ids=(), project_alias_by_id={}) + + projects: Final = await _project_table(prisma_client).find_many(where={"project_id": {"in": list(requested)}}) + found_by_id: Final = {p.project_id: p for p in projects} + missing: Final = [pid for pid in requested if pid not in found_by_id] + if missing: + raise _project_daily_activity_error( + status_code=404, message=f"Project(s) not found: {', '.join(sorted(missing))}" + ) + + if not user_api_key_has_admin_view(user_api_key_dict): + for project_id in requested: + has_permission = await _check_user_permission_for_project( + user_api_key_dict=user_api_key_dict, + team_id=found_by_id[project_id].team_id, + prisma_client=prisma_client, + ) + if not has_permission: + raise _project_daily_activity_error( + status_code=403, + message=f"Not authorized to view daily activity for project_id={project_id}", + ) + + return _ProjectDailyActivityScope( + project_ids=requested, + project_alias_by_id={pid: found_by_id[pid].project_alias for pid in requested}, + ) + + +@router.get( + "/project/daily/activity", + tags=["project management"], + dependencies=[Depends(user_api_key_auth)], + response_model=ProjectDailySpendResponse, +) +async def get_project_daily_activity( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + project_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, +) -> ProjectDailySpendResponse: + """ + Daily spend per project, attributed per request from spend logs. + + Scans LiteLLM_SpendLogs directly and groups by day and the project_id + stored in each request's metadata: there is no daily-aggregated project + spend table, unlike /team/daily/activity. + + Proxy admins may query any project. Team admins may query projects + belonging to teams they administer. + + Example: + ```bash + curl --location 'http://0.0.0.0:4000/project/daily/activity?project_ids=project-123&start_date=2026-09-01&end_date=2026-09-04' \\ + --header 'Authorization: Bearer sk-1234' + ``` + """ + from litellm.proxy.proxy_server import premium_user, prisma_client + + if not premium_user: + raise HTTPException( + status_code=403, + detail={ + "error": "Project management is an enterprise feature. " + CommonProxyErrors.not_premium_user.value + }, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + range_error: Final = _project_daily_activity_date_range_error(start_date, end_date) + if range_error is not None or start_date is None or end_date is None: + raise _project_daily_activity_error( + status_code=400, message=range_error or "Please provide start_date and end_date" + ) + + if not project_ids: + raise _project_daily_activity_error(status_code=400, message="Please provide project_ids") + + scope: Final = await _resolve_project_daily_activity_scope( + project_ids=project_ids, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + if not scope.project_ids: + return ProjectDailySpendResponse(start_date=start_date, end_date=end_date, results=()) + + rows: Final[Sequence[_ProjectDailySpendDbRow]] = await prisma_client.db.query_raw( + _project_daily_spend_sql(project_count=len(scope.project_ids)), + start_date, + end_date, + *scope.project_ids, + ) + results: Final = tuple( + ProjectDailySpendRow( + date=row["spend_date"], + project_id=row["project_id"] or "", + project_alias=scope.project_alias_by_id.get(row["project_id"] or ""), + spend=row["spend"], + prompt_tokens=row["prompt_tokens"], + completion_tokens=row["completion_tokens"], + total_tokens=row["total_tokens"], + api_requests=row["api_requests"], + successful_requests=row["successful_requests"], + failed_requests=row["failed_requests"], + ) + for row in rows + ) + return ProjectDailySpendResponse(start_date=start_date, end_date=end_date, results=results) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 8cfb6354dd0..cb4cb25f36d 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2354,6 +2354,10 @@ async def ui_view_spend_logs( default=None, description="Filter spend logs by team_id", ), + project_id: str | None = fastapi.Query( + default=None, + description="Filter spend logs by project_id", + ), min_spend: float | None = fastapi.Query( default=None, description="Filter logs with spend greater than or equal to this value", @@ -2768,6 +2772,10 @@ async def ui_view_spend_logs( sql_conditions.append(f"metadata->'error_information'->>'error_message' LIKE ${p}") sql_params.append(f"%{error_message}%") p += 1 + if project_id is not None: + sql_conditions.append(f"metadata->>'user_api_key_project_id' = ${p}") + sql_params.append(project_id) + p += 1 if ( group_by_session is True diff --git a/litellm/types/proxy/management_endpoints/project_endpoints.py b/litellm/types/proxy/management_endpoints/project_endpoints.py new file mode 100644 index 00000000000..248fc6f8dbf --- /dev/null +++ b/litellm/types/proxy/management_endpoints/project_endpoints.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + + +class ProjectDailySpendRow(BaseModel): + date: str + project_id: str + project_alias: str | None = None + spend: float = 0.0 + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + api_requests: int = 0 + successful_requests: int = 0 + failed_requests: int = 0 + + +class ProjectDailySpendResponse(BaseModel): + start_date: str + end_date: str + results: tuple[ProjectDailySpendRow, ...] diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 6bc8947e89f..0faf07f819a 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -19,6 +19,7 @@ GET /guardrails/usage/overview GET /key/spend/report GET /organization/daily/activity GET /organization/spend/report +GET /project/daily/activity GET /tag/daily/activity GET /tag/dau GET /tag/distinct diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index c23b203feba..1f7221fd2dd 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -23,6 +23,7 @@ from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( update_project, delete_project, project_info, + get_project_daily_activity, ) from litellm.proxy.proxy_server import ( LitellmUserRoles, @@ -1368,3 +1369,190 @@ async def test_new_project_flag_on_access_group_model_returns_400(monkeypatch): assert "prod-models" in str(exc_info.value) assert "expand to multiple models at request time" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_requires_project_ids(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + mock_prisma = MagicMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + with pytest.raises(HTTPException) as exc_info: + await get_project_daily_activity( + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"), + project_ids=None, + start_date="2026-09-01", + end_date="2026-09-04", + ) + assert exc_info.value.status_code == 400 + assert "project_ids" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_admin_groups_by_day_and_project(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + project_alpha = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1") + project_beta = MagicMock(project_id="project-beta", project_alias="Beta", team_id="team-1") + + mock_prisma = MagicMock() + mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project_alpha, project_beta]) + mock_prisma.db.query_raw = AsyncMock( + return_value=[ + { + "spend_date": "2026-09-01", + "project_id": "project-alpha", + "spend": 0.5, + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + "api_requests": 3, + "successful_requests": 2, + "failed_requests": 1, + }, + { + "spend_date": "2026-09-02", + "project_id": "project-beta", + "spend": 0.25, + "prompt_tokens": 4, + "completion_tokens": 2, + "total_tokens": 6, + "api_requests": 1, + "successful_requests": 1, + "failed_requests": 0, + }, + ] + ) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") + response = await get_project_daily_activity( + user_api_key_dict=admin, + project_ids="project-alpha,project-beta", + start_date="2026-09-01", + end_date="2026-09-02", + ) + + sql, *params = mock_prisma.db.query_raw.call_args.args + assert params == ["2026-09-01", "2026-09-02", "project-alpha", "project-beta"] + assert 'FROM "LiteLLM_SpendLogs" sl' in sql + assert "sl.metadata->>'user_api_key_project_id' IN ($3, $4)" in sql + assert "GROUP BY spend_date, project_id" in sql + + assert response.start_date == "2026-09-01" + assert response.end_date == "2026-09-02" + assert [(r.date, r.project_id, r.project_alias, r.spend, r.api_requests) for r in response.results] == [ + ("2026-09-01", "project-alpha", "Alpha", 0.5, 3), + ("2026-09-02", "project-beta", "Beta", 0.25, 1), + ] + assert (response.results[0].successful_requests, response.results[0].failed_requests) == (2, 1) + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_team_admin_can_view_own_project(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + project = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1") + team = MagicMock(admins=["alice"]) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project]) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice") + response = await get_project_daily_activity( + user_api_key_dict=caller, + project_ids="project-alpha", + start_date="2026-09-01", + end_date="2026-09-02", + ) + assert response.results == () + mock_prisma.db.query_raw.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_non_team_admin_forbidden(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + project = MagicMock(project_id="project-alpha", project_alias="Alpha", team_id="team-1") + team = MagicMock(admins=["someone-else"]) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[project]) + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="bob") + with pytest.raises(HTTPException) as exc_info: + await get_project_daily_activity( + user_api_key_dict=caller, + project_ids="project-alpha", + start_date="2026-09-01", + end_date="2026-09-02", + ) + assert exc_info.value.status_code == 403 + mock_prisma.db.query_raw.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_project_daily_activity_unknown_project_404(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + mock_prisma = MagicMock() + mock_prisma.db.litellm_projecttable.find_many = AsyncMock(return_value=[]) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") + with pytest.raises(HTTPException) as exc_info: + await get_project_daily_activity( + user_api_key_dict=admin, + project_ids="does-not-exist,also-missing", + start_date="2026-09-01", + end_date="2026-09-02", + ) + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == {"error": "Project(s) not found: also-missing, does-not-exist"} + mock_prisma.db.query_raw.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "project_ids,start_date,end_date,expected_error", + [ + (None, "2026-09-01", "2026-09-04", "project_ids"), + ("", "2026-09-01", "2026-09-04", "project_ids"), + ("project-alpha", None, "2026-09-04", "start_date and end_date"), + ("project-alpha", "2026-09-04", "2026-09-01", "on or after"), + ("project-alpha", "2020-01-01", "2026-12-31", "at most 400 days"), + ("project-alpha", "nope", "2026-09-04", "valid YYYY-MM-DD"), + ], +) +async def test_get_project_daily_activity_rejects_bad_input( + monkeypatch, project_ids, start_date, end_date, expected_error +): + from unittest.mock import AsyncMock, MagicMock + + mock_prisma = MagicMock() + mock_prisma.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + + admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") + with pytest.raises(HTTPException) as exc_info: + await get_project_daily_activity( + user_api_key_dict=admin, project_ids=project_ids, start_date=start_date, end_date=end_date + ) + assert exc_info.value.status_code == 400 + assert expected_error in str(exc_info.value.detail) + mock_prisma.db.query_raw.assert_not_called() diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 6e43ac4a12b..642bc61fe4e 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -119,6 +119,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): gte = re.search(r'"startTime" >= \(\$(\d+)', cond) lte = re.search(r'"startTime" <= \(\$(\d+)', cond) alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond) + project = re.search(r"user_api_key_project_id' = \$(\d+)", cond) code = re.search(r"error_code' = \$(\d+)", cond) msg = re.search(r"error_message' LIKE \$(\d+)", cond) sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond) @@ -152,6 +153,13 @@ def _reconstruct_ui_where_from_sql(sql_query, params): "string_contains": str(params[int(alias.group(1)) - 1]).strip("%"), } ) + elif project: + metadata_conds.append( + { + "path": ["user_api_key_project_id"], + "equals": params[int(project.group(1)) - 1], + } + ) elif code: metadata_conds.append( { @@ -4254,6 +4262,71 @@ async def test_ui_view_spend_logs_with_error_message(client): app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_project_id(client, monkeypatch): + """Test filtering spend logs by project_id""" + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "metadata": '{"user_api_key_project_id": "project-1"}', + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": '{"user_api_key_project_id": "project-2"}', + }, + ] + + def filter_by_project_id(where): + if "metadata" in where: + mf = where["metadata"] + if mf.get("path") == ["user_api_key_project_id"]: + project_id = mf.get("equals") + return [log for log in mock_spend_logs if json.loads(log["metadata"])["user_api_key_project_id"] == project_id] + return mock_spend_logs + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + monkeypatch.setattr(ps, "prisma_client", make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id)) + + try: + start_date, end_date = _default_date_range() + + response = client.get( + "/spend/logs/ui", + params={ + "project_id": "project-1", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["id"] == "log1" + assert data["data"][0]["metadata"]["user_api_key_project_id"] == "project-1" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_error_code_and_key_alias(client): """Test merging error_code and key_alias filters with AND logic""" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts index 749fc98c0d8..0f6a9b805ba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -4,6 +4,16 @@ import { createQueryKeys } from "../common/queryKeysFactory"; const uiSettingsKeys = createQueryKeys("uiSettings"); +export interface UISettingsFieldSchema { + description?: string; + properties?: Record; +} + +export interface UISettingsData { + field_schema: UISettingsFieldSchema; + values: Record; +} + /** * UI settings, cached for an hour by default because they rarely change. * @@ -14,11 +24,12 @@ const uiSettingsKeys = createQueryKeys("uiSettings"); * so a caller that needs to notice a change also has to poll. */ export const useUISettings = (options?: { staleTime?: number; refetchInterval?: number }) => { - return useQuery>({ + const queryOptions = { queryKey: uiSettingsKeys.list({}), queryFn: async () => await getUiSettings(), - staleTime: options?.staleTime ?? 60 * 60 * 1000, // 1 hour - data rarely changes + staleTime: options?.staleTime ?? 60 * 60 * 1000, gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour refetchInterval: options?.refetchInterval, - }); + }; + return useQuery(queryOptions); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 273e478528e..4081acc0d0b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -10,16 +10,15 @@ import { type ProviderSpendRow, } from "./entityUsageAggregations"; import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary"; +import { SummaryTileCard } from "./SummaryTileCard"; import { MoneyCell } from "@/components/shared/table_cells"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { hasCapability, type Capability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import { ChevronDown, ChevronRight, Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { type ReactNode, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import UserDropdown from "@/components/common_components/UserDropdown"; @@ -36,7 +35,7 @@ import { userDailyActivityCall, } from "@/components/networking"; import { Logo } from "@/components/molecules/logo/Logo"; -import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity"; +import { usePaginatedDailyActivity, type FetchPageFn } from "../../hooks/usePaginatedDailyActivity"; import { EntityMetricWithMetadata } from "@/components/UsagePage/types"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; import EndpointUsage from "../EndpointUsage/EndpointUsage"; @@ -45,6 +44,15 @@ import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView import TopModelView from "./TopModelView"; import TeamUserSpendCard from "./TeamUserSpendCard"; +/** The entity metadata shape actually probed by getEntityLabel: whichever of these + * fields the backend populated for a given entity type (team, user, ...). */ +interface EntityBreakdownMetadata { + team_alias?: string; + user_email?: string; + user_alias?: string; + [key: string]: unknown; +} + interface EntityMetrics { metrics: { spend: number; @@ -57,7 +65,7 @@ interface EntityMetrics { failed_requests: number; api_requests: number; }; - metadata: Record; + metadata: EntityBreakdownMetadata; } interface EntitySpendData { @@ -89,7 +97,7 @@ interface EntityUsageProps { isOrgAdmin?: boolean; } -const ENTITY_FETCH_FNS: Record Promise> = { +const ENTITY_FETCH_FNS: Record = { tag: tagDailyActivityCall, team: teamDailyActivityCall, organization: organizationDailyActivityCall, @@ -100,7 +108,7 @@ const ENTITY_FETCH_FNS: Record Promise> = { // Single-shot endpoints returning the whole range in one response; entity types // without one fall back to page-draining the paginated endpoint. -const ENTITY_AGGREGATED_FETCH_FNS: Partial Promise>> = { +const ENTITY_AGGREGATED_FETCH_FNS: Partial> = { team: teamDailyActivityAggregatedCall, }; @@ -142,18 +150,19 @@ const EntityUsage: React.FC = ({ const hasRequestWindow = !!accessToken && !!startTime && !!endTime; const enabled = hasRequestWindow && canViewEntity; + const entityPaginatedActivityOptions = { + fetchFn, + args: [accessToken, startTime, endTime, entityFilterArg], + enabled, + aggregatedFetchFn, + }; const { data: spendDataRaw, isFetchingMore, progress, cancelled, cancel, - } = usePaginatedDailyActivity({ - fetchFn, - args: [accessToken, startTime, endTime, entityFilterArg], - enabled, - aggregatedFetchFn, - }); + } = usePaginatedDailyActivity(entityPaginatedActivityOptions); const spendData = spendDataRaw as unknown as EntitySpendData; @@ -182,7 +191,7 @@ const EntityUsage: React.FC = ({ } }; - const getEntityLabel = (entity: string, metadata?: Record): string => { + const getEntityLabel = (entity: string, metadata?: EntityBreakdownMetadata): string => { if (entityList) { const entityItem = entityList.find((item) => item.value === entity); if (entityItem) { @@ -227,7 +236,7 @@ const EntityUsage: React.FC = ({ cache_creation_input_tokens: 0, }, metadata: { - alias: getEntityLabel(entity, data.metadata as any), + alias: getEntityLabel(entity, data.metadata as EntityBreakdownMetadata), id: entity, }, }; @@ -358,29 +367,13 @@ const EntityUsage: React.FC = ({ [], ); - const chev = "size-3 text-muted-foreground"; - const expandIcon = showCostBreakdown ? : ; - - const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => ( - setShowCostBreakdown(!showCostBreakdown) : undefined} - > - -
-

{title}

- {tooltip ? ( - - } /> - {tooltip} - - ) : null} - {expandable ? expandIcon : null} -
-

{value}

-
-
+ const renderSummaryTile = (tile: SummaryTile) => ( + setShowCostBreakdown(!showCostBreakdown)} + /> ); const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : []; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx index f5d6db67478..5ace97421e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx @@ -1,15 +1,12 @@ -import { DonutChart } from "@/components/shared/charts"; -import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { useState } from "react"; import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; -import { ChartLoader } from "@/components/shared/chart_loader"; + +import { SpendByCategoryPanel } from "../SpendByCategoryPanel"; type ProviderSpendData = { provider: string; @@ -70,13 +67,10 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi const filteredProviderSpend = providerSpend.filter((provider) => { const isUnknown = provider.provider?.toLowerCase() === "unknown"; - // If includeUnknown is true, always include unknown provider if (isUnknown) { return includeUnknown; } - // If includeZeroSpend is true, include all providers (including those with 0 spend) - // Otherwise, only include providers with spend > 0 if (includeZeroSpend) { return true; } @@ -84,54 +78,37 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi return provider.spend > 0; }); + const headerAction = ( + <> +
+ + +
+
+
+ + + } /> + Requests that failed to route to a provider + +
+ +
+ + ); + return ( - - - Spend by Provider - -
- - -
-
-
- - - } /> - Requests that failed to route to a provider - -
- -
-
-
- - {loading ? ( - - ) : ( -
- `$${formatNumberWithCommas(value, 2)}`} - colors={["cyan"]} - showLabel - startAngle={90} - endAngle={-270} - /> - row.provider} - noDataMessage="No provider usage data" - size="compact" - /> -
- )} -
-
+ row.provider} + noDataMessage="No provider usage data" + /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx new file mode 100644 index 00000000000..fcf616bca99 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx @@ -0,0 +1,41 @@ +import { ChevronDown, ChevronRight, Info } from "lucide-react"; + +import { Card as ShadcnCard, CardContent } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +import type { SummaryTile } from "./entityUsageSummary"; + +interface SummaryTileCardProps { + tile: SummaryTile; + expanded?: boolean; + onToggleExpand?: () => void; +} + +export function SummaryTileCard({ tile, expanded = false, onToggleExpand }: SummaryTileCardProps) { + const { title, value, className, tooltip, expandable } = tile; + const chev = "size-3 text-muted-foreground"; + const expandIcon = expanded ? : ; + + return ( + + +
+

{title}

+ {tooltip ? ( + + } /> + {tooltip} + + ) : null} + {expandable ? expandIcon : null} +
+

{value}

+
+
+ ); +} + +export default SummaryTileCard; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx new file mode 100644 index 00000000000..4e67c870de0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; +import type { ProjectSpendRow } from "./projectUsageAggregations"; + +vi.mock("@/components/shared/chart_loader", () => ({ + ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => ( +
{isDateChanging ? "Processing date selection..." : "Loading chart data..."}
+ ), +})); + +const mockProjectSpend: ProjectSpendRow[] = [ + { + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 150.5, + requests: 100, + successful_requests: 95, + failed_requests: 5, + tokens: 50000, + }, + { + project_id: "project-beta", + project_alias: "Project Beta", + spend: 200.75, + requests: 120, + successful_requests: 115, + failed_requests: 5, + tokens: 75000, + }, +]; + +describe("ProjectSpendBreakdown", () => { + it("displays the title", () => { + render(); + expect(screen.getByText("Spend by Project")).toBeInTheDocument(); + }); + + it("shows the loader instead of chart content while loading", () => { + render(); + expect(screen.getByTestId("chart-loader")).toBeInTheDocument(); + expect(screen.queryByText("No project usage data")).not.toBeInTheDocument(); + }); + + it("displays table headers", () => { + render(); + expect(screen.getByText("Project")).toBeInTheDocument(); + expect(screen.getByText("Spend")).toBeInTheDocument(); + expect(screen.getByText("Successful")).toBeInTheDocument(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.getByText("Tokens")).toBeInTheDocument(); + }); + + it("displays each project's alias and formatted spend in the table", () => { + render(); + expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0); + expect(screen.getAllByText("Project Beta").length).toBeGreaterThan(0); + expect(screen.getByText("$150.50")).toBeInTheDocument(); + expect(screen.getByText("$200.75")).toBeInTheDocument(); + }); + + it("shows the empty message when no project has usage", () => { + render(); + expect(screen.getByText("No project usage data")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx new file mode 100644 index 00000000000..d2459e65665 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { MoneyCell } from "@/components/shared/table_cells"; + +import { SpendByCategoryPanel } from "../SpendByCategoryPanel"; +import type { ProjectSpendRow } from "./projectUsageAggregations"; + +interface ProjectSpendBreakdownProps { + loading: boolean; + isDateChanging: boolean; + projectSpend: ProjectSpendRow[]; +} + +const columns: ColumnDef[] = [ + { + header: "Project", + accessorKey: "project_alias", + }, + { + header: "Spend", + accessorKey: "spend", + meta: { numeric: true }, + cell: ({ row }) => , + }, + { + header: "Successful", + accessorKey: "successful_requests", + meta: { numeric: true, className: "text-success" }, + cell: ({ row }) => row.original.successful_requests.toLocaleString(), + }, + { + header: "Failed", + accessorKey: "failed_requests", + meta: { numeric: true, className: "text-destructive" }, + cell: ({ row }) => row.original.failed_requests.toLocaleString(), + }, + { + header: "Tokens", + accessorKey: "tokens", + meta: { numeric: true }, + cell: ({ row }) => row.original.tokens.toLocaleString(), + }, +]; + +const ProjectSpendBreakdown: React.FC = ({ loading, isDateChanging, projectSpend }) => ( + row.project_id} + noDataMessage="No project usage data" + /> +); + +export default ProjectSpendBreakdown; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx new file mode 100644 index 00000000000..7ea78355188 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx @@ -0,0 +1,165 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils"; +import * as networking from "@/components/networking"; + +import ProjectUsage from "./ProjectUsage"; +import type { EntityList } from "../EntityUsage/EntityUsage"; + +vi.mock("@/components/networking", () => ({ + projectDailyActivityCall: vi.fn(), +})); + +const PROJECT_LIST: EntityList[] = [ + { label: "Project Alpha", value: "project-alpha" }, + { label: "Project Beta", value: "project-beta" }, +]; + +const DATE_VALUE = { from: new Date("2026-09-01"), to: new Date("2026-09-08") }; + +describe("ProjectUsage", () => { + const mockProjectDailyActivityCall = vi.mocked(networking.projectDailyActivityCall); + + beforeEach(() => { + vi.clearAllMocks(); + testQueryClient.clear(); + }); + + it("shows an enterprise upsell and never calls the API when the caller is not a premium user", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Project Usage is an Enterprise feature")).toBeInTheDocument(); + expect(mockProjectDailyActivityCall).not.toHaveBeenCalled(); + }); + + it("prompts for a project before fetching anything", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Select at least one project above to view its usage.")).toBeInTheDocument(); + expect(mockProjectDailyActivityCall).not.toHaveBeenCalled(); + }); + + it("fetches and renders the picked project's daily spend", async () => { + mockProjectDailyActivityCall.mockResolvedValue({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [ + { + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 12.5, + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + api_requests: 4, + successful_requests: 3, + failed_requests: 1, + }, + ], + }); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + await waitFor(() => expect(mockProjectDailyActivityCall).toHaveBeenCalledTimes(1)); + expect(mockProjectDailyActivityCall).toHaveBeenCalledWith("test-token", DATE_VALUE.from, DATE_VALUE.to, [ + "project-alpha", + ]); + + await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0)); + expect(screen.getAllByText("4").length).toBeGreaterThan(0); + expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0); + }); + + it("shows an error instead of silently rendering zeros when the request fails", async () => { + mockProjectDailyActivityCall.mockRejectedValue(new Error("Project management is an enterprise feature")); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + expect(await screen.findByText("Could not load project usage")).toBeInTheDocument(); + expect(screen.getByText("Project management is an enterprise feature")).toBeInTheDocument(); + expect(screen.queryByText("No project usage data")).not.toBeInTheDocument(); + }); + + it("renders a backend not-found message verbatim, without mangling the comma-joined list", async () => { + mockProjectDailyActivityCall.mockRejectedValue(new Error("Project(s) not found: also-missing, does-not-exist")); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + expect(await screen.findByText("Project(s) not found: also-missing, does-not-exist")).toBeInTheDocument(); + }); + + it("shows a loading indicator while fetching data for a newly-added project", async () => { + let resolveSecondCall: (value: unknown) => void = () => {}; + mockProjectDailyActivityCall + .mockResolvedValueOnce({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [ + { + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 12.5, + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + api_requests: 4, + successful_requests: 3, + failed_requests: 1, + }, + ], + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondCall = resolve; + }), + ); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0)); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Beta" })); + + await waitFor(() => expect(screen.getAllByText("Loading chart data...").length).toBeGreaterThan(0)); + + resolveSecondCall({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [], + }); + + await waitFor(() => expect(screen.queryAllByText("Loading chart data...").length).toBe(0)); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx new file mode 100644 index 00000000000..5e48d05ecf6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx @@ -0,0 +1,178 @@ +import React, { useMemo, useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Info } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { BarChart } from "@/components/shared/charts"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; +import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { projectDailyActivityCall } from "@/components/networking"; +import { extractProxyErrorMessage } from "@/lib/http/client"; +import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; + +import { buildSummaryTiles } from "../EntityUsage/entityUsageSummary"; +import { SummaryTileCard } from "../EntityUsage/SummaryTileCard"; +import type { EntityList } from "../EntityUsage/EntityUsage"; +import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; +import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; + +interface ProjectUsageProps { + accessToken: string | null; + projectList: EntityList[] | null; + dateValue: DateRangePickerValue; + premiumUser: boolean; +} + +const ProjectUsage: React.FC = ({ accessToken, projectList, dateValue, premiumUser }) => { + const [selectedProjectIds, setSelectedProjectIds] = useState([]); + + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); + const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); + + const projectOptions = useMemo( + () => (projectList ?? []).map((project) => ({ label: project.label || project.value, value: project.value })), + [projectList], + ); + + const hasSelection = selectedProjectIds.length > 0; + const hasDateRange = !!startTime && !!endTime; + const hasRequestWindow = premiumUser && !!accessToken && hasDateRange; + const enabled = hasRequestWindow && hasSelection; + + const queryOptions = { + queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()], + queryFn: () => + projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds), + enabled, + placeholderData: keepPreviousData, + }; + const { data, isPending, isFetching, isPlaceholderData, isError, error } = useQuery(queryOptions); + + const rows = useMemo(() => data?.results ?? [], [data]); + const summary = useMemo(() => summarizeProjectUsage(rows), [rows]); + const dailySpend = useMemo(() => buildDailySpendSeries(rows), [rows]); + const projectBreakdown = useMemo(() => buildProjectSpendBreakdown(rows), [rows]); + + if (!premiumUser) { + return ( + + Project Usage is an Enterprise feature + + Filtering usage by project requires a LiteLLM Enterprise license. Get a 7 day trial at{" "} + + litellm.ai/enterprise + + . + + + ); + } + + const isLoadingRows = isPending || (isFetching && isPlaceholderData); + + const renderResultsPanel = () => { + if (!hasSelection) { + return ( +
+ + +

+ Select at least one project above to view its usage. +

+
+
+
+ ); + } + + if (isError) { + return ( +
+ + Could not load project usage + {extractProxyErrorMessage(error)} + +
+ ); + } + + return ( + <> +
+ + +

Project Spend Overview

+ {isLoadingRows ? ( + + ) : ( +
+ {buildSummaryTiles(summary, false).map((tile) => ( + + ))} +
+ )} +
+
+
+ +
+ + + Daily Spend + + + {isLoadingRows ? ( + + ) : ( + + )} + + +
+ +
+ +
+ + ); + }; + + return ( +
+
+ + +
+

Projects

+ + } /> + Select one or more projects to compare their usage + +
+ +
+
+
+ + {renderResultsPanel()} +
+ ); +}; + +export default ProjectUsage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts new file mode 100644 index 00000000000..db6f5ab63c7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import type { ProjectDailySpendRow } from "@/components/networking"; + +import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; + +const row = (overrides: Partial = {}): ProjectDailySpendRow => ({ + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 1, + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + api_requests: 3, + successful_requests: 2, + failed_requests: 1, + ...overrides, +}); + +describe("summarizeProjectUsage", () => { + it("returns all-zero totals for no rows", () => { + const zeroTotals = { + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + }; + + expect(summarizeProjectUsage([])).toEqual(zeroTotals); + }); + + it("sums spend, requests, and tokens across every row regardless of project or date", () => { + const projectBetaRow: Partial = { + project_id: "project-beta", + date: "2026-09-02", + spend: 2.25, + api_requests: 4, + successful_requests: 4, + failed_requests: 0, + total_tokens: 40, + }; + const rows = [row({ spend: 1.5 }), row(projectBetaRow)]; + const expectedTotals = { + total_spend: 3.75, + total_api_requests: 7, + total_successful_requests: 6, + total_failed_requests: 1, + total_tokens: 55, + }; + + expect(summarizeProjectUsage(rows)).toEqual(expectedTotals); + }); +}); + +describe("buildDailySpendSeries", () => { + it("sums same-day spend across projects into a single point", () => { + const rows = [row(), row({ project_id: "project-beta", spend: 2 })]; + + expect(buildDailySpendSeries(rows)).toEqual([{ date: "2026-09-01", spend: 3 }]); + }); + + it("sorts distinct dates oldest first regardless of input order", () => { + const rows = [row({ date: "2026-09-03", spend: 3 }), row(), row({ date: "2026-09-02", spend: 2 })]; + + expect(buildDailySpendSeries(rows).map((point) => point.date)).toEqual(["2026-09-01", "2026-09-02", "2026-09-03"]); + }); +}); + +describe("buildProjectSpendBreakdown", () => { + it("sums a project's spend and tokens across every day into a single row", () => { + const secondDayRow: Partial = { + date: "2026-09-02", + spend: 2, + total_tokens: 20, + successful_requests: 1, + failed_requests: 2, + }; + const rows = [row({ total_tokens: 10, api_requests: 2, failed_requests: 0 }), row(secondDayRow)]; + + expect(buildProjectSpendBreakdown(rows)).toEqual([ + { + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 3, + requests: 5, + successful_requests: 3, + failed_requests: 2, + tokens: 30, + }, + ]); + }); + + it("sorts projects by spend descending", () => { + const rows = [ + row({ project_id: "project-cheap", project_alias: "Cheap" }), + row({ project_id: "project-costly", project_alias: "Costly", spend: 9 }), + ]; + + expect(buildProjectSpendBreakdown(rows).map((r) => r.project_id)).toEqual(["project-costly", "project-cheap"]); + }); + + it("falls back to the project id when the project has no alias", () => { + const rows = [row({ project_id: "project-untitled", project_alias: null })]; + + expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("project-untitled"); + }); + + it("disambiguates two projects that share the same human-set alias", () => { + const rows = [ + row({ project_id: "project-one", project_alias: "Production" }), + row({ project_id: "project-two", project_alias: "Production" }), + ]; + + const aliases = buildProjectSpendBreakdown(rows).map((r) => r.project_alias); + expect(new Set(aliases).size).toBe(2); + expect(aliases.every((alias) => alias.includes("Production"))).toBe(true); + }); + + it("leaves a unique alias untouched", () => { + const rows = [row({ project_id: "project-alpha", project_alias: "Project Alpha" })]; + + expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts new file mode 100644 index 00000000000..5ddd9ee9545 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts @@ -0,0 +1,107 @@ +import type { ProjectDailySpendRow } from "@/components/networking"; + +export interface ProjectSpendRow extends Record { + project_id: string; + project_alias: string; + spend: number; + requests: number; + successful_requests: number; + failed_requests: number; + tokens: number; +} + +export interface DailyProjectSpendPoint extends Record { + date: string; + spend: number; +} + +export interface ProjectUsageSummary { + total_spend: number; + total_api_requests: number; + total_successful_requests: number; + total_failed_requests: number; + total_tokens: number; +} + +const EMPTY_SUMMARY: ProjectUsageSummary = { + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, +}; + +export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsageSummary => + rows.reduce( + (totals, row) => ({ + total_spend: totals.total_spend + row.spend, + total_api_requests: totals.total_api_requests + row.api_requests, + total_successful_requests: totals.total_successful_requests + row.successful_requests, + total_failed_requests: totals.total_failed_requests + row.failed_requests, + total_tokens: totals.total_tokens + row.total_tokens, + }), + EMPTY_SUMMARY, + ); + +export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => { + const spendByDate = new Map(); + for (const row of rows) { + spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend); + } + return [...spendByDate.entries()] + .map(([date, spend]) => ({ date, spend })) + .sort((a, b) => a.date.localeCompare(b.date)); +}; + +const groupByProjectId = (rows: ProjectDailySpendRow[]): ProjectDailySpendRow[][] => { + const groups = new Map(); + for (const row of rows) { + const existing = groups.get(row.project_id); + if (existing) { + existing.push(row); + } else { + groups.set(row.project_id, [row]); + } + } + return [...groups.values()]; +}; + +const EMPTY_PROJECT_GROUP_TOTALS = { + spend: 0, + requests: 0, + successful_requests: 0, + failed_requests: 0, + tokens: 0, +}; + +const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => { + const [{ project_id, project_alias }] = rows; + const totals = rows.reduce( + (acc, row) => ({ + spend: acc.spend + row.spend, + requests: acc.requests + row.api_requests, + successful_requests: acc.successful_requests + row.successful_requests, + failed_requests: acc.failed_requests + row.failed_requests, + tokens: acc.tokens + row.total_tokens, + }), + EMPTY_PROJECT_GROUP_TOTALS, + ); + return { project_id, project_alias: project_alias || project_id, ...totals }; +}; + +const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => { + const aliasCounts = new Map(); + for (const row of rows) { + aliasCounts.set(row.project_alias, (aliasCounts.get(row.project_alias) ?? 0) + 1); + } + return rows.map((row) => + (aliasCounts.get(row.project_alias) ?? 0) > 1 + ? { ...row, project_alias: `${row.project_alias} (${row.project_id})` } + : row, + ); +}; + +export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => { + const summarized = groupByProjectId(rows).map(summarizeProjectGroup); + return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx new file mode 100644 index 00000000000..33dd4d41095 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { SpendByCategoryPanel } from "./SpendByCategoryPanel"; + +vi.mock("@/components/shared/chart_loader", () => ({ + ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => ( +
{isDateChanging ? "Processing date selection..." : "Loading chart data..."}
+ ), +})); + +interface TestRow extends Record { + key: string; + spend: number; +} + +const columns: ColumnDef[] = [{ header: "Key", accessorKey: "key" }]; + +const rows: TestRow[] = [{ key: "row-1", spend: 5 }]; + +describe("SpendByCategoryPanel", () => { + it("displays the given title", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByText("Spend by Widget")).toBeInTheDocument(); + }); + + it("shows the loader instead of chart content while loading", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByTestId("chart-loader")).toBeInTheDocument(); + expect(screen.queryByText("row-1")).not.toBeInTheDocument(); + }); + + it("renders the table rows once loaded", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getAllByText("row-1").length).toBeGreaterThan(0); + }); + + it("shows the empty message when there is no data", () => { + render( + row.key} + noDataMessage="No data for this range" + />, + ); + expect(screen.getByText("No data for this range")).toBeInTheDocument(); + }); + + it("renders a header action only when one is given", () => { + const { rerender } = render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.queryByText("Toggle")).not.toBeInTheDocument(); + + rerender( + Toggle} + loading={false} + isDateChanging={false} + data={[]} + indexKey="key" + columns={columns} + getRowId={(row) => row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByText("Toggle")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx new file mode 100644 index 00000000000..9dbf1550e88 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { DonutChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +interface SpendByCategoryPanelProps> { + title: string; + headerAction?: React.ReactNode; + loading: boolean; + isDateChanging: boolean; + data: TRow[]; + indexKey: keyof TRow & string; + columns: ColumnDef[]; + getRowId: (row: TRow) => string; + noDataMessage: string; +} + +export function SpendByCategoryPanel>({ + title, + headerAction, + loading, + isDateChanging, + data, + indexKey, + columns, + getRowId, + noDataMessage, +}: SpendByCategoryPanelProps) { + return ( + + + {title} + {headerAction && {headerAction}} + + + {loading ? ( + + ) : ( +
+ `$${formatNumberWithCommas(value, 2)}`} + colors={["cyan"]} + showLabel + startAngle={90} + endAngle={-270} + /> + +
+ )} +
+
+ ); +} + +export default SpendByCategoryPanel; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index a92d1209567..85c5f4faf92 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -20,9 +20,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; +import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { hasCapability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; @@ -59,6 +61,7 @@ import { import EndpointUsage from "./EndpointUsage/EndpointUsage"; import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import ModelViewToggle, { ModelViewType } from "./ModelViewToggle"; +import ProjectUsage from "./ProjectUsage/ProjectUsage"; import SpendByProvider from "./EntityUsage/SpendByProvider"; import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView"; import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView"; @@ -102,12 +105,16 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // filter reads as loading rather than as a range with no customers. const { data: customers } = useCustomers(); const { data: agentsResponse } = useAgents(); + const { data: projectsResponse } = useProjects(); const { data: currentUser } = useCurrentUser(); const isAdmin = all_admin_roles.includes(userRole || ""); const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || ""); const isOrgAdmin = useIsOrgAdmin(); const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin); const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage"); + const { data: uiSettingsData } = useUISettings(); + const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); + const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage") && enableProjectsUI; // For admins: null means global view (all users), a string means filter by that user // For non-admins: always set to their own user ID @@ -117,12 +124,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); const [selectedUsageView, setUsageView] = useState("global"); - // Org-admin membership is read from the server, so unlike the other usage - // views this one can be revoked while the page is open. Derive the view in - // render rather than storing it, so the fallback lands on the same paint and - // the selector never holds a value it no longer offers. - const usageView: UsageOption = - selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView; + const hasOrganizationAccessIfSelected = selectedUsageView !== "organization" || canViewOrganizationUsage; + const hasProjectAccessIfSelected = selectedUsageView !== "project" || canViewProjectUsage; + const stillHasAccessToSelectedView = hasOrganizationAccessIfSelected && hasProjectAccessIfSelected; + const usageView: UsageOption = stillHasAccessToSelectedView ? selectedUsageView : "global"; const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); @@ -233,10 +238,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true; // Paginated fallback — only enabled when aggregated endpoint fails + const hasRequestWindow = !!accessToken && !!startTime && !!endTime; + const hasPaginatedFallbackRequestWindow = aggregatedFailed && hasRequestWindow; const paginatedResult = usePaginatedDailyActivity({ fetchFn: userDailyActivityCall, args: [accessToken, startTime, endTime, effectiveUserId], - enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime, + enabled: hasPaginatedFallbackRequestWindow, }); // Derive userSpendData from whichever source is active @@ -482,6 +489,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { userRole={userRole} canViewTagUsage={canViewTagUsage} isOrgAdmin={isOrgAdmin} + enableProjectsUI={enableProjectsUI} /> @@ -935,6 +943,20 @@ const UsagePage: React.FC = ({ teams, organizations }) => { /> )} + {usageView === "project" && canViewProjectUsage && ( + ({ + label: project.project_alias || project.project_id, + value: project.project_id, + })) || null + } + dateValue={dateValue} + premiumUser={premiumUser} + /> + )} + {/* Customer Usage Panel */} {usageView === "customer" && ( { expect(offers(container, "Tag Usage")).toBe(false); }); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => { - const user = userEvent.setup(); - const { container } = render(); + it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])( + "should show %s to an admin", + async (optionName) => { + const user = userEvent.setup(); + const { container } = render(); - await openMenu(user); - expect(offers(container, optionName)).toBe(true); - }); + await openMenu(user); + expect(offers(container, optionName)).toBe(true); + }, + ); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => { - const user = userEvent.setup(); - const { container } = render( - , - ); + it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])( + "should hide %s from an internal user", + async (optionName) => { + const user = userEvent.setup(); + const { container } = render( + , + ); - await openMenu(user); - expect(offers(container, optionName)).toBe(false); - }); + await openMenu(user); + expect(offers(container, optionName)).toBe(false); + }, + ); // An org admin's session role is "Internal User" — org-admin-ness lives in the // membership table — so the two rows above cannot tell them apart from a plain @@ -86,6 +92,7 @@ describe("UsageViewSelect", () => { it.each([ ["Organization Usage", true], ["Agent Usage (A2A)", false], + ["Project Usage", false], ] as const)("should offer %s to an org admin: %s", async (optionName, expected) => { const user = userEvent.setup(); const { container } = render( @@ -96,6 +103,16 @@ describe("UsageViewSelect", () => { expect(offers(container, optionName)).toBe(expected); }); + it("should hide Project Usage from an admin when enableProjectsUI is false", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + + await openMenu(user); + expect(offers(container, "Project Usage")).toBe(false); + }); + it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => { const user = userEvent.setup(); const { container } = render( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx index ea21a2155c1..6cf7cfb1140 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx @@ -1,4 +1,4 @@ -import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react"; +import { BarChart3, Bot, Building2, Folder, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react"; import React from "react"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -9,6 +9,7 @@ export type UsageOption = | "my-usage" | "organization" | "team" + | "project" | "customer" | "tag" | "agent" @@ -20,6 +21,7 @@ export interface UsageViewSelectProps { userRole: string | null; canViewTagUsage?: boolean; isOrgAdmin?: boolean; + enableProjectsUI?: boolean; title?: string; description?: string; "data-id"?: string; @@ -68,6 +70,13 @@ const OPTIONS: OptionConfig[] = [ description: "View usage by team", icon: , }, + { + value: "project", + label: "Project Usage", + description: "View usage by project", + icon: , + capability: "viewProjectUsage", + }, { value: "customer", label: "Customer Usage", @@ -110,6 +119,7 @@ export const UsageViewSelect: React.FC = ({ userRole, canViewTagUsage = false, isOrgAdmin = false, + enableProjectsUI = true, title = "Usage View", description = "Select the usage data you want to view", "data-id": dataId, @@ -117,6 +127,9 @@ export const UsageViewSelect: React.FC = ({ const isAdmin = all_admin_roles.includes(userRole ?? ""); const getFilteredOptions = () => { return OPTIONS.filter((option) => { + if (option.value === "project" && !enableProjectsUI) { + return false; + } if (option.capability) { return hasCapability(userRole, option.capability, isOrgAdmin); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts index e023feda2e3..49f83c8c9d3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts @@ -32,12 +32,12 @@ const SUMMABLE_METADATA_KEYS = [ "total_flat_cost", ] as const; -interface DailyActivityResponse { +export interface DailyActivityResponse { results: DailyData[]; metadata: Record; } -type FetchPageFn = (...args: any[]) => Promise; +export type FetchPageFn = (...args: any[]) => Promise; interface UsePaginatedDailyActivityParams { /** The API call function (e.g., userDailyActivityCall). */ diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 612ca05d083..538a770e1a3 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -45,6 +45,10 @@ function SettingRow({ ); } +function toStringArrayOrNull(value: unknown): string[] | null { + return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : null; +} + export default function UISettings() { const { accessToken } = useAuthorized(); const { data, isLoading, isError, error } = useUISettings(); @@ -434,7 +438,7 @@ export default function UISettings() { => { + try { + return await apiClient.get(`/project/daily/activity`, { + accessToken, + query: { + project_ids: projectIds.join(","), + start_date: formatDate(startTime), + end_date: formatDate(endTime), + }, + }); + } catch (error) { + console.error("Failed to fetch project daily activity:", error); + throw error; + } +}; + export const getOnboardingCredentials = async (inviteUUID: string) => { /** * Get all models on proxy @@ -2075,6 +2099,7 @@ export const userFilterUICall = async (accessToken: string, params: URLSearchPar interface UiSpendLogsParams { api_key?: string; team_id?: string; + project_id?: string; request_id?: string; session_id?: string; user_id?: string; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index 5a35c7ae16b..eb1ee087d85 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -24,10 +24,20 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ useInfiniteSpendLogEndUsers: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({ + useProjects: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: vi.fn(), +})); + import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; const emptyInfiniteQuery = { data: { pages: [], pageParams: [] }, @@ -77,6 +87,10 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); + vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType); + vi.mocked(useUISettings).mockReturnValue({ + data: { values: { enable_projects_ui: true } }, + } as unknown as ReturnType); }); it("renders every backend-supported filter field", async () => { @@ -84,6 +98,7 @@ describe("RequestLogsFilters", () => { for (const label of [ "Team ID", + "Project", "Status", "Cache", "Key Alias", @@ -130,6 +145,30 @@ describe("RequestLogsFilters", () => { expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "alice@example.com"); }); + it("selects a project value from the caller's visible projects", async () => { + vi.mocked(useProjects).mockReturnValue({ + data: [{ project_id: "project-1", project_alias: "Alpha" }], + isLoading: false, + } as unknown as ReturnType); + const user = userEvent.setup(); + const { set } = renderFilters(); + + await chooseSelectOption(user, await screen.findByPlaceholderText("Search or select a project"), /Alpha/); + + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.PROJECT_ID, "project-1"); + }); + + it("hides the Project filter when the projects UI setting is disabled", async () => { + vi.mocked(useUISettings).mockReturnValue({ + data: { values: { enable_projects_ui: false } }, + } as unknown as ReturnType); + + renderFilters(); + + expect(await screen.findByText("Team ID")).toBeInTheDocument(); + expect(screen.queryByText("Project")).not.toBeInTheDocument(); + }); + it("pushes the User ID picker query to the paginated user lookup", async () => { const user = userEvent.setup(); renderFilters(); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index e97552838da..2a38ce414b5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -6,6 +6,8 @@ import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/u import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { DataTableFilterField } from "@/components/shared/DataTable"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; @@ -76,6 +78,32 @@ function TeamFilterField({ ); } +function ProjectFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { + const { data: projects, isLoading } = useProjects(); + + const options = useMemo( + () => + (projects ?? []).map((project) => ({ + label: project.project_alias || project.project_id, + value: project.project_id, + sublabel: project.project_id, + })), + [projects], + ); + + return ( + + onChange(emptyToUndefined(next))} + placeholder="Search or select a project" + emptyText={isLoading ? "Loading projects…" : "No projects found"} + /> + + ); +} + function KeyAliasFilterField({ value, onChange, @@ -319,6 +347,8 @@ interface RequestLogsFiltersProps { export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) { const valueOf = (id: string): string => asString(get(id)); const setter = (id: string) => (next: string | undefined) => set(id, next); + const { data: uiSettingsData } = useUISettings(); + const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); return ( <> @@ -328,6 +358,10 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF teams={teams} /> + {enableProjectsUI && ( + + )} +