From 2ea7d86469aa3ca472c084c18d47a93d6629685b Mon Sep 17 00:00:00 2001 From: Jay Date: Wed, 9 Sep 2026 14:54:50 +0530 Subject: [PATCH] feat: Filter by project on logs and usage screens (BerriAI/litellm/issues/40386) --- .../management_endpoints/project_endpoints.py | 203 +++++++++++++++++- .../spend_management_endpoints.py | 8 + .../management_endpoints/project_endpoints.py | 20 ++ .../test_project_endpoints_prisma.py | 187 ++++++++++++++++ .../test_spend_management_endpoints.py | 76 +++++++ .../src/components/networking.tsx | 1 + .../view_logs/RequestLogsFilters.test.tsx | 22 ++ .../view_logs/RequestLogsFilters.tsx | 29 +++ .../view_logs/log_filter_logic.test.tsx | 1 + .../components/view_logs/log_filter_logic.tsx | 3 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 123 +++++++++++ 11 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 litellm/types/proxy/management_endpoints/project_endpoints.py diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index b2eda76f9ae..7e272a2c59c 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,196 @@ 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: {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 f8831ca4152..81298782fc7 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2349,6 +2349,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", @@ -2763,6 +2767,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/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..61a66d46e5c 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,189 @@ 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", + start_date="2026-09-01", + end_date="2026-09-02", + ) + assert exc_info.value.status_code == 404 + 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 329a33eb440..c5fdc3c6cb8 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( { @@ -4226,6 +4234,74 @@ 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): + """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" + ) + + try: + with patch.object( + ps, + "prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id), + ): + 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/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d7cf5b6110b..169b279bf79 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2075,6 +2075,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..fd7df329f33 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,15 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ useInfiniteSpendLogEndUsers: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({ + useProjects: 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"; const emptyInfiniteQuery = { data: { pages: [], pageParams: [] }, @@ -77,6 +82,9 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); + vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType< + typeof useProjects + >); }); it("renders every backend-supported filter field", async () => { @@ -84,6 +92,7 @@ describe("RequestLogsFilters", () => { for (const label of [ "Team ID", + "Project", "Status", "Cache", "Key Alias", @@ -130,6 +139,19 @@ 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("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 fc7e34bd5e1..9203edb8276 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -6,6 +6,7 @@ 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 { DataTableFilterField } from "@/components/shared/DataTable"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; @@ -76,6 +77,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, @@ -328,6 +355,8 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF teams={teams} /> + +