diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index bb330d00756..d628d956e73 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -679,6 +679,7 @@ class LiteLLMRoutes(enum.Enum): # permitted teams exactly like /spend/logs/ui — it belongs to the same # access tier, not to customer management. "/management/v1/spend_logs/end_users", + "/management/v1/spend_logs/users", "/cost/estimate", ] @@ -871,12 +872,13 @@ class LiteLLMRoutes(enum.Enum): # PROXY_ADMIN_VIEW_ONLY — the route gate must match). "/customer/list", "/customer/info", - # UI Logs page detail drawer (single + session) and the end-user filter - # facet. The list endpoint `/spend/logs/ui` is covered via + # UI Logs page detail drawer (single + session) and the filter facets. + # The list endpoint `/spend/logs/ui` is covered via # spend_tracking_routes below. "/spend/logs/ui/{logId}", "/spend/logs/session/ui", "/management/v1/spend_logs/end_users", + "/management/v1/spend_logs/users", # Settings / observability read endpoints exposed in admin-only # sidebar groups (Logging & Alerts, Admin Settings, Budgets, # Invitations). diff --git a/litellm/proxy/management_endpoints/management_v1/spend_logs.py b/litellm/proxy/management_endpoints/management_v1/spend_logs.py index 96e60fcfdfc..5fee8eaede3 100644 --- a/litellm/proxy/management_endpoints/management_v1/spend_logs.py +++ b/litellm/proxy/management_endpoints/management_v1/spend_logs.py @@ -1,7 +1,7 @@ """`/management/v1/spend_logs` facets.""" from datetime import datetime, timezone -from typing import Annotated, Any, Final +from typing import Annotated, Any, Final, Literal from fastapi import APIRouter, Depends, Query, Request @@ -35,7 +35,7 @@ def _as_utc(value: datetime) -> datetime: return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) -async def _end_user_scope_clause( +async def _spend_log_scope_clause( user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient, next_param_index: int, @@ -43,8 +43,8 @@ async def _end_user_scope_clause( """SQL predicate restricting the facet to spend logs this caller may read. Returns ``(None, ())`` for a proxy admin. Mirrors the scoping ``/spend/logs/ui`` - applies, so the dropdown can never offer an end user whose rows the caller - could not open. + applies, so a dropdown can never offer a value from a row the caller could + not open. """ from litellm.proxy.spend_tracking.spend_management_endpoints import ( _get_permitted_team_ids_for_spend_logs, @@ -77,6 +77,98 @@ async def _end_user_scope_clause( return f"({' OR '.join(clauses)})", params +async def _list_spend_log_facet( + request: Request, + user_api_key_dict: UserAPIKeyAuth, + start_time: datetime, + end_time: datetime, + q: str | None, + page: int, + page_size: int, + column: Literal["end_user", "user"], +) -> FacetListResponse: + try: + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}database-not-connected", + title="Database not connected", + status=503, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + ) + + column_sql: Final = "end_user" if column == "end_user" else '"user"' + window_params: Final[tuple[Any, ...]] = (_as_utc(start_time), _as_utc(end_time)) + search_params: Final[tuple[Any, ...]] = (f"%{escape_like(q)}%",) if q else () + search_clause: Final = (f"{column_sql} ILIKE ${len(window_params) + 1} ESCAPE '\\'",) if q else () + + scope_clause, scope_params = await _spend_log_scope_clause( + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + next_param_index=len(window_params) + len(search_params) + 1, + ) + + where_parts: Final = ( + ( + "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')", + "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')", + f"{column_sql} IS NOT NULL", + f"{column_sql} != ''", + ) + + search_clause + + ((scope_clause,) if scope_clause is not None else ()) + ) + + # The inner LIMIT walks the startTime index newest first and bounds the + # rows DISTINCT can inspect. request_id makes the cut-off deterministic, + # and page_size + 1 reveals has_more without a COUNT(*). + params: Final = ( + window_params + + search_params + + scope_params + + (SPEND_LOGS_FACET_SCAN_CAP, page_size + 1, (page - 1) * page_size) + ) + scan_idx: Final = len(params) - 2 + facet_sql: Final = ( + f"SELECT DISTINCT {column_sql} FROM (" + f" SELECT {column_sql}" + f' FROM "LiteLLM_SpendLogs"' + f" WHERE {' AND '.join(where_parts)}" + f' ORDER BY "startTime" DESC, request_id DESC' + f" LIMIT ${scan_idx}" + f") recent" + f" ORDER BY {column_sql} ASC" + f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}" + ) + rows: Final = await prisma_client.db.query_raw(facet_sql, *params) + values: Final[list[str]] = [row[column] for row in rows if row.get(column)] + has_more: Final = len(values) > page_size + + return FacetListResponse( + data=values[:page_size], + meta=PageMeta(page=page, page_size=page_size, has_more=has_more), + links=build_page_links(request=request, page=page, has_more=has_more), + ) + except ManagementProblem: + raise + except Exception as e: + verbose_proxy_logger.exception( + "litellm.proxy.management_endpoints.management_v1.spend_logs._list_spend_log_facet(): Exception occured - %s", + e, + ) + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}internal-server-error", + title="Internal server error", + status=500, + detail=f"Failed to list spend log {column.replace('_', ' ')}s.", + ) + ) + + @router.get( "/spend_logs/end_users", tags=["Budget & Spend Tracking"], @@ -116,85 +208,47 @@ async def list_spend_log_end_users( --header 'Authorization: Bearer sk-1234' ``` """ - try: - from litellm.proxy.proxy_server import prisma_client + return await _list_spend_log_facet( + request=request, + user_api_key_dict=user_api_key_dict, + start_time=start_time, + end_time=end_time, + q=q, + page=page, + page_size=page_size, + column="end_user", + ) - if prisma_client is None: - raise ManagementProblem( - ProblemDetail( - type=f"{PROBLEM_TYPE_BASE}database-not-connected", - title="Database not connected", - status=503, - detail=CommonProxyErrors.db_not_connected_error.value, - ) - ) - window_params: Final[tuple[Any, ...]] = (_as_utc(start_time), _as_utc(end_time)) - search_params: Final[tuple[Any, ...]] = (f"%{escape_like(q)}%",) if q else () - search_clause: Final = (f"end_user ILIKE ${len(window_params) + 1} ESCAPE '\\'",) if q else () - - scope_clause, scope_params = await _end_user_scope_clause( - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - next_param_index=len(window_params) + len(search_params) + 1, - ) - - where_parts: Final = ( - ( - "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')", - "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')", - "end_user IS NOT NULL", - "end_user != ''", - ) - + search_clause - + ((scope_clause,) if scope_clause is not None else ()) - ) - - # The inner LIMIT is the safety bound: it walks the startTime index newest - # first and stops, so DISTINCT never runs over an unbounded row set. - # request_id breaks startTime ties so the cut-off row is deterministic and - # successive OFFSET pages agree on the set they are paging through. - # page_size + 1: one row beyond the page reveals has_more without a COUNT(*). - params: Final = ( - window_params - + search_params - + scope_params - + (SPEND_LOGS_FACET_SCAN_CAP, page_size + 1, (page - 1) * page_size) - ) - scan_idx: Final = len(params) - 2 - facet_sql: Final = ( - f"SELECT DISTINCT end_user FROM (" - f" SELECT end_user" - f' FROM "LiteLLM_SpendLogs"' - f" WHERE {' AND '.join(where_parts)}" - f' ORDER BY "startTime" DESC, request_id DESC' - f" LIMIT ${scan_idx}" - f") recent" - f" ORDER BY end_user ASC" - f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}" - ) - rows: Final = await prisma_client.db.query_raw(facet_sql, *params) - end_users: Final[list[str]] = [row["end_user"] for row in rows if row.get("end_user")] - has_more: Final = len(end_users) > page_size - - return FacetListResponse( - data=end_users[:page_size], - meta=PageMeta(page=page, page_size=page_size, has_more=has_more), - links=build_page_links(request=request, page=page, has_more=has_more), - ) - - except ManagementProblem: - raise - except Exception as e: - verbose_proxy_logger.exception( - "litellm.proxy.management_endpoints.management_v1.spend_logs.list_spend_log_end_users(): Exception occured - %s", - e, - ) - raise ManagementProblem( - ProblemDetail( - type=f"{PROBLEM_TYPE_BASE}internal-server-error", - title="Internal server error", - status=500, - detail="Failed to list spend log end users.", - ) - ) +@router.get( + "/spend_logs/users", + tags=["Budget & Spend Tracking"], + dependencies=[Depends(user_api_key_auth), Depends(reject_unknown_query_params)], + response_model=FacetListResponse, +) +async def list_spend_log_users( + request: Request, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + start_time: Annotated[ + datetime, + Query(alias="filter[startTime][gte]", description="Window start (UTC when no offset is given)"), + ], + end_time: Annotated[ + datetime, + Query(alias="filter[startTime][lte]", description="Window end (UTC when no offset is given)"), + ], + q: Annotated[str | None, Query(description="Case-insensitive partial match on the internal user id")] = None, + page: Annotated[int, Query(ge=1, description="Page number")] = 1, + page_size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50, +) -> FacetListResponse: + """The distinct internal users appearing in spend logs the caller can read.""" + return await _list_spend_log_facet( + request=request, + user_api_key_dict=user_api_key_dict, + start_time=start_time, + end_time=end_time, + q=q, + page=page, + page_size=page_size, + column="user", + ) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 8fb5570965b..99d870f5ad4 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2427,6 +2427,7 @@ async def ui_view_spend_logs( request_id=request_id, ) permitted_team_ids: list[str] | None = None + scope_to_caller_user = False if not is_request_id_lookup and not is_admin_view: if team_id is not None: can_view_team: Final = await _can_team_member_view_log( @@ -2440,7 +2441,6 @@ async def ui_view_spend_logs( detail={"error": f"Not authorized to view team spend for team_id={team_id}"}, ) where_conditions["team_id"] = team_id - where_conditions.pop("user", None) else: if _can_user_view_spend_log(user_api_key_dict=user_api_key_dict): try: @@ -2451,13 +2451,20 @@ async def ui_view_spend_logs( except Exception: permitted_team_ids = [] if permitted_team_ids: - where_conditions.pop("user", None) + if user_id is None: + where_conditions.pop("user", None) where_conditions["OR"] = [ {"user": user_api_key_dict.user_id}, {"team_id": {"in": permitted_team_ids}}, ] else: - where_conditions["user"] = user_api_key_dict.user_id + if user_id is None: + where_conditions["user"] = user_api_key_dict.user_id + else: + where_conditions["AND"] = where_conditions.get("AND", []) + [ + {"user": user_api_key_dict.user_id} + ] + scope_to_caller_user = True where_conditions.pop("team_id", None) # Calculate skip value for pagination skip: Final = (page - 1) * page_size @@ -2508,6 +2515,10 @@ async def ui_view_spend_logs( sql_params.append(permitted_team_ids) p += 2 sql_conditions.append(or_clause) + elif scope_to_caller_user: + sql_conditions.append(f'"user" = ${p}') + sql_params.append(user_api_key_dict.user_id) + p += 1 if session_id is not None and isinstance(session_id, str): like_escaped_session_id: Final = session_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py index 79f13a6f703..35fcd3b6cd7 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py @@ -1,5 +1,4 @@ from datetime import datetime, timezone -from typing import List from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -45,6 +44,7 @@ app.include_router(router) client = TestClient(app) END_USERS_PATH = f"{MANAGEMENT_V1_PREFIX}/spend_logs/end_users" +USERS_PATH = f"{MANAGEMENT_V1_PREFIX}/spend_logs/users" WINDOW = "filter[startTime][gte]=2026-07-23T00:00:00Z&filter[startTime][lte]=2026-07-24T00:00:00Z" @@ -65,7 +65,7 @@ def as_proxy_admin(): app.dependency_overrides.clear() -def _mock_rows(mock_prisma_client, end_users: List[str]) -> AsyncMock: +def _mock_rows(mock_prisma_client, end_users: list[str]) -> AsyncMock: query_raw = AsyncMock(return_value=[{"end_user": eu} for eu in end_users]) mock_prisma_client.db.query_raw = query_raw return query_raw @@ -82,6 +82,11 @@ def _get(query: str = WINDOW): return client.get(f"{END_USERS_PATH}{suffix}", headers={"Authorization": "Bearer k"}) +def _get_users(query: str = WINDOW): + suffix = f"?{query}" if query else "" + return client.get(f"{USERS_PATH}{suffix}", headers={"Authorization": "Bearer k"}) + + def test_returns_the_control_plane_envelope(mock_prisma_client, as_proxy_admin): """`{data, meta, links}` is the contract; a bare list or a legacy `aliases` key is not.""" _mock_rows(mock_prisma_client, ["a", "b"]) @@ -213,7 +218,7 @@ def test_requires_a_time_window(mock_prisma_client, as_proxy_admin, query): def test_rejects_a_malformed_window_as_a_problem_document(mock_prisma_client, as_proxy_admin): _mock_rows(mock_prisma_client, []) - response = _get(f"filter[startTime][gte]=yesterday&filter[startTime][lte]=2026-07-24T00:00:00Z") + response = _get("filter[startTime][gte]=yesterday&filter[startTime][lte]=2026-07-24T00:00:00Z") assert response.status_code == 400 assert response.headers["content-type"].startswith("application/problem+json") @@ -400,6 +405,49 @@ def test_q_placeholder_precedes_the_scan_limit_and_offset(mock_prisma_client, as assert query_raw.call_args.args[5:] == (11, 0) +def test_user_facet_reads_internal_users_from_spend_logs(mock_prisma_client, as_proxy_admin): + query_raw = AsyncMock(return_value=[{"user": "alice@example.com"}, {"user": "user-42"}]) + mock_prisma_client.db.query_raw = query_raw + + response = _get_users() + + assert response.status_code == 200 + assert response.json()["data"] == ["alice@example.com", "user-42"] + sql = query_raw.call_args.args[0] + assert 'SELECT DISTINCT "user"' in sql + assert '"user" IS NOT NULL' in sql + assert "end_user IS NOT NULL" not in sql + + +def test_user_facet_uses_the_same_team_scope_as_request_logs(mock_prisma_client): + query_raw = AsyncMock(return_value=[{"user": "member@example.com"}]) + mock_prisma_client.db.query_raw = query_raw + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="team-admin-1") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=["team-a"]), + ): + response = _get_users() + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + assert '("user" = $3 OR team_id = ANY($4::text[]))' in query_raw.call_args.args[0] + assert query_raw.call_args.args[3] == "team-admin-1" + assert query_raw.call_args.args[4] == ["team-a"] + + +def test_user_facet_searches_the_internal_user_value(mock_prisma_client, as_proxy_admin): + query_raw = AsyncMock(return_value=[]) + mock_prisma_client.db.query_raw = query_raw + + _get_users(f"{WINDOW}&q=alice%40example.com") + + assert '"user" ILIKE $3 ESCAPE' in query_raw.call_args.args[0] + assert query_raw.call_args.args[3] == "%alice@example.com%" + + @pytest.mark.parametrize( "role", [ @@ -416,18 +464,19 @@ def test_is_reachable_by_every_role_that_can_open_the_logs_page(role): """ from litellm.proxy.auth.route_checks import RouteChecks - for allowed in ( - LiteLLMRoutes.internal_user_routes.value, - LiteLLMRoutes.internal_user_view_only_routes.value, - ): - assert ("/spend/logs/ui" in allowed) == (END_USERS_PATH in allowed) + for facet_path in (END_USERS_PATH, USERS_PATH): + for allowed in ( + LiteLLMRoutes.internal_user_routes.value, + LiteLLMRoutes.internal_user_view_only_routes.value, + ): + assert ("/spend/logs/ui" in allowed) == (facet_path in allowed) - if role in (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY): - allowed_routes = ( - LiteLLMRoutes.internal_user_routes.value - if role == LitellmUserRoles.INTERNAL_USER - else LiteLLMRoutes.internal_user_view_only_routes.value - ) - assert RouteChecks.check_route_access(route=END_USERS_PATH, allowed_routes=allowed_routes) - else: - assert END_USERS_PATH in LiteLLMRoutes.admin_viewer_routes.value + if role in (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY): + allowed_routes = ( + LiteLLMRoutes.internal_user_routes.value + if role == LitellmUserRoles.INTERNAL_USER + else LiteLLMRoutes.internal_user_view_only_routes.value + ) + assert RouteChecks.check_route_access(route=facet_path, allowed_routes=allowed_routes) + else: + assert facet_path in LiteLLMRoutes.admin_viewer_routes.value 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 057193a69db..81512cd8e66 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 @@ -1321,7 +1321,7 @@ async def test_ui_view_spend_logs_internal_user_scoped_without_user_id( @pytest.mark.asyncio -async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeypatch): +async def test_ui_view_spend_logs_team_admin_can_filter_team_spend_by_user(client, monkeypatch): """ Team admins should be able to view team-wide spend when team_id is provided. """ @@ -1346,11 +1346,23 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp "startTime": datetime.datetime.now(timezone.utc).isoformat(), "model": "gpt-4", }, + { + "id": "log3", + "request_id": "req3", + "api_key": "sk-test-key", + "user": "member3", + "team_id": "team_admin_team", + "spend": 0.15, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, ] def filter_by_team(where): - if "team_id" in where and where["team_id"] == "team_admin_team": + if where.get("team_id") == "team_admin_team" and where.get("user") == "member1": return [mock_spend_logs[0]] + if where.get("team_id") == "team_admin_team": + return [mock_spend_logs[0], mock_spend_logs[2]] return mock_spend_logs class TeamTable: @@ -1383,6 +1395,7 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp "/spend/logs/ui", params={ "team_id": "team_admin_team", + "user_id": "member1", "start_date": start_date, "end_date": end_date, }, @@ -1398,6 +1411,66 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_ui_view_spend_logs_user_filter_intersects_permitted_team_scope(client, monkeypatch): + member_log = { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "member@example.com", + "team_id": "team-9", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + } + other_team_log = { + **member_log, + "id": "log2", + "request_id": "req2", + "team_id": "team-outside-scope", + } + seen_where = [] + + def filter_by_user_and_scope(where): + seen_where.append(where) + if where.get("user") == "member@example.com" and {"multi_team": True} in where.get("OR", []): + return [member_log] + return [member_log, other_team_log] + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma([member_log, other_team_log], filter_by_user_and_scope), + ) + monkeypatch.setattr( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + AsyncMock(return_value=["team-9"]), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="team-admin" + ) + + try: + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={ + "user_id": "member@example.com", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + assert [row["request_id"] for row in response.json()["data"]] == ["req1"] + assert any( + where.get("user") == "member@example.com" and {"multi_team": True} in where.get("OR", []) + for where in seen_where + ) + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_pagination(client, monkeypatch): mock_spend_logs = [ @@ -1578,6 +1651,7 @@ async def test_ui_view_session_spend_logs_pagination(client, monkeypatch): assert data["total_pages"] == 2 assert len(data["data"]) == 1 assert data["data"][0]["request_id"] == "req1" + assert data["data"][0]["user"] == "member1" finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogUsers.test.ts new file mode 100644 index 00000000000..5a79bf74ce3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogUsers.test.ts @@ -0,0 +1,40 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const useInfiniteQuery = vi.fn(); +vi.mock("@/lib/http/api", () => ({ $api: { useInfiniteQuery: (...args: unknown[]) => useInfiniteQuery(...args) } })); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +import { useInfiniteSpendLogUsers } from "./useSpendLogUsers"; + +const WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" }; + +describe("useInfiniteSpendLogUsers", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token" }); + }); + + it("calls the scoped spend-log user facet with the visible window", () => { + renderHook(() => useInfiniteSpendLogUsers(WINDOW, 25, "alice")); + + const expectedQuery = { + "filter[startTime][gte]": "2026-07-23 00:00:00", + "filter[startTime][lte]": "2026-07-24 00:00:00", + page_size: 25, + q: "alice", + }; + expect(useInfiniteQuery.mock.calls[0][1]).toBe("/management/v1/spend_logs/users"); + expect(useInfiniteQuery.mock.calls[0][2].params.query).toEqual(expectedQuery); + }); + + it("omits q when the search box is empty", () => { + renderHook(() => useInfiniteSpendLogUsers(WINDOW, 50, "")); + + expect(useInfiniteQuery.mock.calls[0][2].params.query).not.toHaveProperty("q"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogUsers.ts new file mode 100644 index 00000000000..3a82c9e9d91 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogUsers.ts @@ -0,0 +1,21 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { $api } from "@/lib/http/api"; + +import { nextPageFromLinks, type SpendLogsWindow } from "./useSpendLogEndUsers"; + +export const useInfiniteSpendLogUsers = (window: SpendLogsWindow, pageSize: number = 50, q?: string) => { + const { accessToken } = useAuthorized(); + const query = { + "filter[startTime][gte]": window.start_date, + "filter[startTime][lte]": window.end_date, + page_size: pageSize, + ...(q !== undefined && q !== "" ? { q } : {}), + }; + const options = { + pageParamName: "page", + initialPageParam: 1, + getNextPageParam: nextPageFromLinks, + enabled: Boolean(accessToken), + }; + return $api.useInfiniteQuery("get", "/management/v1/spend_logs/users", { params: { query } }, options); +}; 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 fd53949c87a..1c94e6418a0 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -14,8 +14,8 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useInfiniteModelInfo: vi.fn(), })); -vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ - useInfiniteUsers: vi.fn(), +vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers", () => ({ + useInfiniteSpendLogUsers: vi.fn(), })); vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ @@ -23,9 +23,9 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ })); 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 { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; const emptyInfiniteQuery = { data: { pages: [], pageParams: [] }, @@ -37,16 +37,10 @@ const emptyInfiniteQuery = { const LOGS_WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" }; -function renderFilters(filters: Record = {}, showUserIdFilter = true) { +function renderFilters(filters: Record = {}) { const set = vi.fn(); renderWithProviders( - filters[id]} - set={set} - teams={[]} - logsWindow={LOGS_WINDOW} - showUserIdFilter={showUserIdFilter} - />, + filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />, ); return { set }; } @@ -61,8 +55,8 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteModelInfo).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); - vi.mocked(useInfiniteUsers).mockReturnValue( - emptyInfiniteQuery as unknown as ReturnType, + vi.mocked(useInfiniteSpendLogUsers).mockReturnValue( + emptyInfiniteQuery as unknown as ReturnType, ); vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, @@ -97,31 +91,27 @@ describe("RequestLogsFilters", () => { expect(labels[1].compareDocumentPosition(labels[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); - it("selects a user by display name while storing the user ID filter", async () => { - vi.mocked(useInfiniteUsers).mockReturnValue({ + it("selects an internal user value from the caller's visible spend logs", async () => { + vi.mocked(useInfiniteSpendLogUsers).mockReturnValue({ ...emptyInfiniteQuery, data: { pages: [ { - users: [{ user_id: "user-1", user_alias: "Alice", user_email: "alice@example.com" }], - page: 1, - page_size: 50, - total: 1, - total_pages: 1, + data: ["alice@example.com"], + meta: { page: 1, page_size: 50, has_more: false }, + links: { self: "", next: null }, }, ], pageParams: [1], }, - } as unknown as ReturnType); + } as unknown as ReturnType); const user = userEvent.setup(); const { set } = renderFilters(); await user.click(await screen.findByPlaceholderText("Search an internal user")); - expect(await screen.findByText("Alice")).toBeInTheDocument(); - expect(screen.getByText("alice@example.com | User ID: user-1")).toBeInTheDocument(); - await user.click(screen.getByText("Alice")); + await user.click(await screen.findByText("alice@example.com")); - expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "user-1"); + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "alice@example.com"); }); it("pushes the User ID picker query to the paginated user lookup", async () => { @@ -132,28 +122,26 @@ describe("RequestLogsFilters", () => { await user.click(input); await user.type(input, "alice@example.com"); - await waitFor(() => expect(useInfiniteUsers).toHaveBeenCalledWith(50, "alice@example.com")); + await waitFor(() => expect(useInfiniteSpendLogUsers).toHaveBeenCalledWith(LOGS_WINDOW, 50, "alice@example.com")); }); it("loads the next page when the User ID list is scrolled near the end", async () => { const fetchNextPage = vi.fn(); - vi.mocked(useInfiniteUsers).mockReturnValue({ + vi.mocked(useInfiniteSpendLogUsers).mockReturnValue({ ...emptyInfiniteQuery, fetchNextPage, hasNextPage: true, data: { pages: [ { - users: [{ user_id: "user-1", user_alias: "Alice", user_email: "alice@example.com" }], - page: 1, - page_size: 50, - total: 51, - total_pages: 2, + data: ["alice@example.com"], + meta: { page: 1, page_size: 50, has_more: true }, + links: { self: "", next: "?page=2" }, }, ], pageParams: [1], }, - } as unknown as ReturnType); + } as unknown as ReturnType); const user = userEvent.setup(); renderFilters(); @@ -167,13 +155,6 @@ describe("RequestLogsFilters", () => { await waitFor(() => expect(fetchNextPage).toHaveBeenCalled()); }); - it("does not show or query the User ID filter for non-admin request logs", () => { - renderFilters({}, false); - - expect(screen.queryByText("User ID")).not.toBeInTheDocument(); - expect(useInfiniteUsers).not.toHaveBeenCalled(); - }); - it("scopes the Key Alias lookup to the selected team", async () => { renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" }); @@ -264,15 +245,7 @@ describe("RequestLogsFilters", () => { it("scopes the End User lookup to the window the logs table is showing", async () => { const otherWindow = { start_date: "2026-01-01 00:00:00", end_date: "2026-01-02 00:00:00" }; - renderWithProviders( - undefined} - set={vi.fn()} - teams={[]} - logsWindow={otherWindow} - showUserIdFilter - />, - ); + renderWithProviders( undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />); await waitFor(() => expect(useInfiniteSpendLogEndUsers).toHaveBeenCalledWith(otherWindow, 50, undefined)); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index 47e0bad6f62..017260230dd 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -3,9 +3,9 @@ import { useMemo, useState } from "react"; 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 { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { DataTableFilterField } from "@/components/shared/DataTable"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; @@ -145,9 +145,18 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value ); } -function UserIdFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { +function UserIdFilterField({ + value, + onChange, + logsWindow, +}: { + value: string; + onChange: (value: string | undefined) => void; + logsWindow: LogsWindow; +}) { const [search, setSearch] = useState(""); - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteUsers( + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteSpendLogUsers( + logsWindow, PAGE_SIZE, emptyToUndefined(search), ); @@ -155,14 +164,10 @@ function UserIdFilterField({ value, onChange }: { value: string; onChange: (valu const options = useMemo(() => { const seen = new Set(); return (data?.pages ?? []).flatMap((page) => - page.users.flatMap((user) => { - if (!user.user_id || seen.has(user.user_id)) return []; - seen.add(user.user_id); - const label = user.user_alias || user.user_email || user.user_id; - const email = user.user_email && user.user_email !== label ? user.user_email : ""; - const sublabel = - user.user_id === label ? email : [email, `User ID: ${user.user_id}`].filter(Boolean).join(" | "); - return [{ label, value: user.user_id, sublabel }]; + page.data.flatMap((userId) => { + if (!userId || seen.has(userId)) return []; + seen.add(userId); + return [{ label: userId, value: userId }]; }), ); }, [data]); @@ -284,10 +289,9 @@ interface RequestLogsFiltersProps { set: (columnId: string, value: unknown) => void; teams: Team[]; logsWindow: LogsWindow; - showUserIdFilter: boolean; } -export function RequestLogsFilters({ get, set, teams, logsWindow, showUserIdFilter }: 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); @@ -321,12 +325,11 @@ export function RequestLogsFilters({ get, set, teams, logsWindow, showUserIdFilt teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)} /> - {showUserIdFilter && ( - - )} + void; teams: Team[]; logsWindow: LogsWindow; - showUserIdFilter: boolean; toolbarChildren?: ReactNode; } @@ -70,7 +69,6 @@ export function RequestLogsTable({ onSessionClick, teams, logsWindow, - showUserIdFilter, toolbarChildren, }: RequestLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); @@ -124,15 +122,7 @@ export function RequestLogsTable({ title="Filters" description="Narrow down request logs" > - {({ get, set }) => ( - - )} + {({ get, set }) => } )} diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 17d26dc00f3..26c5bda1593 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -45,7 +45,6 @@ const defaultProps = { userRole: "Admin" as string | null, userID: "user-1" as string | null, columnFilters: [] as ColumnFiltersState, - filterByCurrentUser: false, activeTab: "request logs", isLiveTail: false, startTime: "2025-01-01T00:00:00", @@ -181,17 +180,16 @@ describe("useLogFilterLogic", () => { }); }); - describe("filterByCurrentUser", () => { - it("scopes to the current user when no explicit user filter is set", async () => { - renderFilterHook({ filterByCurrentUser: true }); + describe("user scope", () => { + it("leaves an empty user filter for the backend to authorize", async () => { + renderFilterHook(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); - expect(lastCallParams()?.params).toMatchObject({ user_id: "user-1" }); + expect(lastCallParams()?.params?.user_id).toBeUndefined(); }); - it("lets an explicit user filter win over the current-user scope", async () => { + it("sends an explicit user filter for the backend to intersect with authorization", async () => { renderFilterHook({ - filterByCurrentUser: true, columnFilters: [{ id: LOG_FILTER_IDS.USER_ID, value: "someone-else" }], }); diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 78ecb52c184..474f51e93b3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -99,7 +99,6 @@ export function useLogFilterLogic({ userRole, userID, columnFilters, - filterByCurrentUser, activeTab, isLiveTail, startTime, @@ -113,7 +112,6 @@ export function useLogFilterLogic({ userRole: string | null; userID: string | null; columnFilters: ColumnFiltersState; - filterByCurrentUser: boolean | null; activeTab: string; isLiveTail: boolean; startTime: string; @@ -137,7 +135,6 @@ export function useLogFilterLogic({ endTime, isCustomDate, columnFilters, - filterByCurrentUser ? userID : null, sortBy, sortOrder, ], @@ -167,7 +164,7 @@ export function useLogFilterLogic({ team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID), request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID), session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID), - user_id: userIdFilter ?? (filterByCurrentUser ? userID ?? undefined : undefined), + user_id: userIdFilter, end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER), status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS), model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID), diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 1e46ae9c577..75e222f7472 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7529,6 +7529,26 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/spend_logs/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Spend Log Users + * @description The distinct internal users appearing in spend logs the caller can read. + */ + get: operations["list_spend_log_users_management_v1_spend_logs_users_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/mcp-rest/test/connection": { parameters: { query?: never; @@ -45400,6 +45420,46 @@ export interface operations { }; }; }; + list_spend_log_users_management_v1_spend_logs_users_get: { + parameters: { + query: { + /** @description Window start (UTC when no offset is given) */ + "filter[startTime][gte]": string; + /** @description Window end (UTC when no offset is given) */ + "filter[startTime][lte]": string; + /** @description Case-insensitive partial match on the internal user id */ + q?: string | null; + /** @description Page number */ + page?: number; + /** @description Page size */ + page_size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FacetListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; test_connection_mcp_rest_test_connection_post: { parameters: { query?: never;