diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 98efadc10a8..7575091be54 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -629,6 +629,10 @@ class LiteLLMRoutes(enum.Enum): "/spend/logs/v2", "/spend/logs/ui", "/spend/logs/session/ui", + # Reads end users out of spend logs, scoped to the caller's own rows and + # permitted teams exactly like /spend/logs/ui — it belongs to the same + # access tier, not to customer management. + "/customer/aliases", "/cost/estimate", ] @@ -818,6 +822,7 @@ class LiteLLMRoutes(enum.Enum): # Customer / end-user listing (handlers already gate on # PROXY_ADMIN_VIEW_ONLY — the route gate must match). "/customer/list", + "/customer/aliases", "/customer/info", # UI Logs page detail drawer (single + session). The list endpoint # `/spend/logs/ui` is covered via spend_tracking_routes below. diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 84f67bdc3bc..a46481d5bb7 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -10,11 +10,12 @@ All /customer management endpoints """ #### END-USER/CUSTOMER MANAGEMENT #### -from datetime import datetime, timedelta -from typing import List, Optional +from collections.abc import MutableSequence +from datetime import datetime, timedelta, timezone +from typing import Annotated, Any, List, Optional import fastapi -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel import litellm @@ -27,7 +28,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, handle_update_object_permission_common, ) -from litellm.proxy.utils import handle_exception_on_proxy +from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy from litellm.repositories.budget_repository import BudgetRepository from litellm.repositories.table_repositories import EndUserRepository from litellm.types.proxy.management_endpoints.common_daily_activity import ( @@ -35,6 +36,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( ) from litellm.types.proxy.management_endpoints.customer_endpoints import ( BlockUsersResponse, + CustomerAliasesResponse, CustomerResponse, DeleteCustomersResponse, UnblockUsersResponse, @@ -42,6 +44,11 @@ from litellm.types.proxy.management_endpoints.customer_endpoints import ( router = APIRouter() +# Rows the end-user filter query may read out of LiteLLM_SpendLogs before DISTINCT. +# Matches SPEND_LOGS_PAGINATION_COUNT_CAP, the equivalent bound ui_view_spend_logs +# puts on its count query, so both reads of the same table stop at the same depth. +SPEND_LOGS_FILTER_SCAN_CAP = 10000 + def _to_customer_response(record: BaseModel) -> CustomerResponse: """Validate a raw end-user DB row into the typed customer response. @@ -785,6 +792,168 @@ async def list_end_user( raise handle_exception_on_proxy(e) +def _parse_spend_log_window_bound(value: str, param: str) -> datetime: + try: + return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + except ValueError: + raise HTTPException( + status_code=400, + detail={"error": f"Invalid {param}: {value}. Expected 'YYYY-MM-DD HH:MM:SS'"}, + ) + + +async def _build_end_user_scope_condition( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, + query_params: MutableSequence[Any], +) -> str | None: + """SQL predicate restricting end users to the logs this caller may read. + + Returns None when the caller is a proxy admin (no restriction). Mirrors the + scoping ``/spend/logs/ui`` applies, so the dropdown can never offer an + end user whose rows the caller could not open. + """ + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _get_permitted_team_ids_for_spend_logs, + _is_admin_view_safe, + ) + + if _is_admin_view_safe(user_api_key_dict=user_api_key_dict): + return None + + try: + permitted_team_ids = await _get_permitted_team_ids_for_spend_logs( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) + except Exception: + permitted_team_ids = [] + + caller_user_id = user_api_key_dict.user_id + user_clause: tuple[str, ...] = () + if caller_user_id is not None: + query_params.append(caller_user_id) + user_clause = (f'"user" = ${len(query_params)}',) + + team_clause: tuple[str, ...] = () + if permitted_team_ids: + # = ANY(::text[]) rather than an expanded IN list, matching the clause + # ui_view_spend_logs builds: one parameter whatever the team count. + query_params.append(permitted_team_ids) + team_clause = (f"team_id = ANY(${len(query_params)}::text[])",) + + scope_parts = user_clause + team_clause + if not scope_parts: + return "FALSE" + return f"({' OR '.join(scope_parts)})" + + +@router.get( + "/customer/aliases", + tags=["Customer Management"], + dependencies=[Depends(user_api_key_auth)], + response_model=CustomerAliasesResponse, +) +async def list_customer_aliases( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + start_date: Annotated[str, Query(description="Window start, 'YYYY-MM-DD HH:MM:SS' (UTC)")], + end_date: Annotated[str, Query(description="Window end, 'YYYY-MM-DD HH:MM:SS' (UTC)")], + page: Annotated[int, Query(ge=1, description="Page number")] = 1, + size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50, + search: Annotated[ + str | None, + Query(description="Case-insensitive partial match on the customer id"), + ] = None, +) -> CustomerAliasesResponse: + """ + List the end users seen in spend logs over a time window, for UI filter dropdowns. + + Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window, + anyone else sees only end users from their own requests or from teams they + administer (or hold the `/spend/logs` permission on). + + Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry + the team attribution this scoping needs. The window is required and the inner + scan is capped at SPEND_LOGS_FILTER_SCAN_CAP rows, so the query + cannot degrade into a full-table scan the way `/global/all_end_users` does. + + Example curl: + ``` + curl --location 'http://0.0.0.0:4000/customer/aliases?start_date=2026-07-23%2000:00:00&end_date=2026-07-24%2000:00:00&size=50&search=acme' \ + --header 'Authorization: Bearer sk-1234' + ``` + """ + try: + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=400, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + start_dt = _parse_spend_log_window_bound(start_date, "start_date") + end_dt = _parse_spend_log_window_bound(end_date, "end_date") + + query_params: List[Any] = [start_dt, end_dt] + where_parts = [ + "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')", + "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')", + "end_user IS NOT NULL", + "end_user != ''", + ] + + if search: + # Escape LIKE metacharacters so a literal '_' or '%' matches itself. + escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + query_params.append(f"%{escaped}%") + where_parts.append(f"end_user ILIKE ${len(query_params)} ESCAPE '\\'") + + scope_condition = await _build_end_user_scope_condition( + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + query_params=query_params, + ) + if scope_condition is not None: + where_parts.append(scope_condition) + + # 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; the + # (startTime, request_id) index means the tiebreaker costs nothing. + # size + 1: one row beyond the page reveals has_more without a COUNT(*). + params = query_params + [SPEND_LOGS_FILTER_SCAN_CAP, size + 1, (page - 1) * size] + scan_idx = len(params) - 2 + aliases_sql = ( + 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 = await prisma_client.db.query_raw(aliases_sql, *params) + aliases: List[str] = [row["end_user"] for row in rows if row.get("end_user")] + + return CustomerAliasesResponse( + aliases=aliases[:size], + current_page=page, + size=size, + has_more=len(aliases) > size, + ) + + except Exception as e: + verbose_proxy_logger.exception( + "litellm.proxy.management_endpoints.customer_endpoints.list_customer_aliases(): " + "Exception occured - {}".format(str(e)) + ) + raise handle_exception_on_proxy(e) + + @router.get( "/customer/daily/activity", tags=["Customer Management"], diff --git a/litellm/types/proxy/management_endpoints/customer_endpoints.py b/litellm/types/proxy/management_endpoints/customer_endpoints.py index e7653360d63..93d042fcea1 100644 --- a/litellm/types/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/types/proxy/management_endpoints/customer_endpoints.py @@ -17,6 +17,25 @@ class CustomerResponse(LiteLLM_EndUserTable): litellm_budget_table: Optional[LiteLLM_BudgetTableFull] = None # pyright: ignore +class CustomerAliasesResponse(BaseModel): + """Paginated, id-only customer listing used by UI filter dropdowns. + + Deliberately excludes budget/object-permission relations so a proxy with a + large LiteLLM_EndUserTable can back a search-as-you-type control without + materializing every row (see /customer/list for the full objects). + + Reports ``has_more`` rather than a total count on purpose: a total requires + COUNT(*) over the whole match set on every keystroke, which is the exact + cost this endpoint exists to avoid. Fetching one row beyond the page is + enough to drive an infinite-scroll dropdown. + """ + + aliases: List[str] + current_page: int + size: int + has_more: bool + + class BlockUsersResponse(BaseModel): blocked_users: List[LiteLLM_EndUserTable] diff --git a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py index 5fbc3c4869b..98e93eea5f9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import List from unittest.mock import AsyncMock, MagicMock, patch @@ -9,6 +10,7 @@ from fastapi.testclient import TestClient from litellm.proxy._types import ( LiteLLM_EndUserTable, + LiteLLMRoutes, LitellmUserRoles, ProxyException, ) @@ -782,3 +784,291 @@ def test_char_delete_body(mock_prisma_client, mock_user_api_key_auth): "deleted_customers": 2, "message": "Successfully deleted customers with ids: ['c1', 'c2']", } + + +WINDOW = "start_date=2026-07-23+00%3A00%3A00&end_date=2026-07-24+00%3A00%3A00" + + +def _mock_alias_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 + + +def _as_role(role: LitellmUserRoles, user_id: str = "u1"): + """Override auth for one request; returns a context-manager-free setter/teardown pair.""" + original = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id=user_id, user_role=role) + return original + + +def test_customer_aliases_reads_spend_logs_not_the_end_user_table(mock_prisma_client, mock_user_api_key_auth): + """Team scoping only exists in spend logs, so that is the source of truth.""" + query_raw = _mock_alias_rows(mock_prisma_client, ["a", "b"]) + + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 200 + assert response.json() == {"aliases": ["a", "b"], "current_page": 1, "size": 50, "has_more": False} + sql = query_raw.call_args.args[0] + assert '"LiteLLM_SpendLogs"' in sql + assert "LiteLLM_EndUserTable" not in sql + mock_prisma_client.db.litellm_endusertable.find_many.assert_not_called() + + +def test_customer_aliases_caps_the_rows_it_scans(mock_prisma_client, mock_user_api_key_auth): + """The inner LIMIT is the crash guard: DISTINCT must never see an unbounded set.""" + from litellm.proxy.management_endpoints.customer_endpoints import SPEND_LOGS_FILTER_SCAN_CAP + + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + inner = sql[sql.index("FROM (") : sql.index(") recent")] + assert "LIMIT $3" in inner + assert query_raw.call_args.args[3] == SPEND_LOGS_FILTER_SCAN_CAP + assert 'ORDER BY "startTime" DESC' in inner + + +def test_spend_logs_filter_scan_cap_matches_the_logs_page_bound(): + """Pin the cap's value, not just that it is passed through. + + Asserting the param equals the constant is tautological: raising the constant + to a billion keeps that assertion green while removing the bound entirely. + The documented rationale is that both reads of LiteLLM_SpendLogs stop at the + same depth, so tie it to the count cap ui_view_spend_logs already uses. + """ + from litellm.proxy.management_endpoints.customer_endpoints import SPEND_LOGS_FILTER_SCAN_CAP + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + SPEND_LOGS_PAGINATION_COUNT_CAP, + ) + + assert SPEND_LOGS_FILTER_SCAN_CAP == SPEND_LOGS_PAGINATION_COUNT_CAP + + +def test_customer_aliases_breaks_start_time_ties_deterministically(mock_prisma_client, mock_user_api_key_auth): + """Without a unique tiebreaker the capped scan can cut differently per request, + so OFFSET page 2 would page through a different set than page 1 did.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert 'ORDER BY "startTime" DESC, request_id DESC' in sql + + +def test_customer_aliases_requires_a_time_window(mock_prisma_client, mock_user_api_key_auth): + """No window means no index bound, which is the unbounded scan we must not allow.""" + _mock_alias_rows(mock_prisma_client, []) + + assert client.get("/customer/aliases", headers={"Authorization": "Bearer k"}).status_code == 422 + assert ( + client.get( + "/customer/aliases?start_date=2026-07-23+00%3A00%3A00", headers={"Authorization": "Bearer k"} + ).status_code + == 422 + ) + + +def test_customer_aliases_bounds_the_window_on_the_indexed_start_time(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')" in sql + assert "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')" in sql + assert query_raw.call_args.args[1] == datetime(2026, 7, 23, tzinfo=timezone.utc) + assert query_raw.call_args.args[2] == datetime(2026, 7, 24, tzinfo=timezone.utc) + + +def test_customer_aliases_rejects_a_malformed_window(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, []) + + response = client.get( + f"/customer/aliases?start_date=yesterday&end_date=2026-07-24+00%3A00%3A00", + headers={"Authorization": "Bearer k"}, + ) + + assert response.status_code == 400 + + +def test_customer_aliases_applies_no_scope_for_a_proxy_admin(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert '"user" =' not in sql + assert "team_id" not in sql + + +@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) +def test_customer_aliases_scopes_a_team_admin_to_their_own_rows_and_teams(mock_prisma_client, role): + """A team admin must not see end users belonging to teams they cannot read.""" + query_raw = _mock_alias_rows(mock_prisma_client, ["cust-a"]) + original = _as_role(role, 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", "team-b"]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + sql = query_raw.call_args.args[0] + # Same clause shape ui_view_spend_logs builds, so the two cannot diverge. + assert '("user" = $3 OR team_id = ANY($4::text[]))' in sql + assert query_raw.call_args.args[3] == "team-admin-1" + assert query_raw.call_args.args[4] == ["team-a", "team-b"] + + +def test_customer_aliases_scopes_a_teamless_user_to_their_own_rows(mock_prisma_client): + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="solo") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=[]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + sql = query_raw.call_args.args[0] + assert '("user" = $3)' in sql + assert "team_id" not in sql + assert query_raw.call_args.args[3] == "solo" + + +def test_customer_aliases_returns_nothing_when_the_caller_owns_no_scope(mock_prisma_client): + """Unidentifiable caller must match no rows, never fall through to unscoped.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id=None) + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=[]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + assert "FALSE" in query_raw.call_args.args[0] + + +def test_customer_aliases_scopes_when_permitted_team_lookup_fails(mock_prisma_client): + """A failed team lookup must degrade to own-rows-only, never to unscoped.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="solo") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(side_effect=RuntimeError("db down")), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + sql = query_raw.call_args.args[0] + assert '("user" = $3)' in sql + assert "team_id" not in sql + + +def test_customer_aliases_fetches_one_extra_row_and_trims_it(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, [f"u{i}" for i in range(4)]) + + response = client.get(f"/customer/aliases?{WINDOW}&size=3", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 200 + assert response.json()["aliases"] == ["u0", "u1", "u2"] + assert response.json()["has_more"] is True + assert query_raw.call_args.args[4:] == (4, 0) + + +def test_customer_aliases_reports_no_more_pages_on_an_exactly_full_page(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, ["u0", "u1", "u2"]) + + response = client.get(f"/customer/aliases?{WINDOW}&size=3", headers={"Authorization": "Bearer k"}) + + assert response.json()["aliases"] == ["u0", "u1", "u2"] + assert response.json()["has_more"] is False + + +def test_customer_aliases_offsets_by_page(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + response = client.get(f"/customer/aliases?{WINDOW}&page=3&size=25", headers={"Authorization": "Bearer k"}) + + assert response.json()["current_page"] == 3 + assert query_raw.call_args.args[4:] == (26, 50) + + +def test_customer_aliases_search_escapes_like_metacharacters(mock_prisma_client, mock_user_api_key_auth): + """End-user ids routinely contain '_'; unescaped it is a wildcard.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}&search=device_id%25", headers={"Authorization": "Bearer k"}) + + assert "end_user ILIKE $3 ESCAPE" in query_raw.call_args.args[0] + assert query_raw.call_args.args[3] == r"%device\_id\%%" + + +def test_customer_aliases_search_placeholder_precedes_scan_limit_and_offset(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}&search=acme&size=10", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert "LIMIT $4" in sql + assert "LIMIT $5 OFFSET $6" in sql + assert query_raw.call_args.args[3] == "%acme%" + assert query_raw.call_args.args[5:] == (11, 0) + + +def test_customer_aliases_caps_page_size(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, []) + + response = client.get(f"/customer/aliases?{WINDOW}&size=100000", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ], +) +def test_customer_aliases_is_reachable_by_every_role_that_can_open_the_logs_page(role): + """Route-level auth gate, which the dependency_overrides in the other tests bypass. + + Handler-side team scoping is dead code if RouteChecks rejects the role first, + so pin that /customer/aliases travels in the same access tier as /spend/logs/ui. + """ + 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) == ("/customer/aliases" 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="/customer/aliases", allowed_routes=allowed_routes) + else: + assert "/customer/aliases" in LiteLLMRoutes.admin_viewer_routes.value diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts new file mode 100644 index 00000000000..2625361231f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts @@ -0,0 +1,22 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { $api } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; + +type EndUserAliasesPage = components["schemas"]["CustomerAliasesResponse"]; + +export interface EndUserAliasesWindow { + start_date: string; + end_date: string; +} + +export const useInfiniteEndUserAliases = (window: EndUserAliasesWindow, size: number = 50, search?: string) => { + const { accessToken } = useAuthorized(); + const query = { ...window, size, ...(search !== undefined && search !== "" ? { search } : {}) }; + const options = { + pageParamName: "page", + initialPageParam: 1, + getNextPageParam: (lastPage: EndUserAliasesPage) => (lastPage.has_more ? lastPage.current_page + 1 : undefined), + enabled: Boolean(accessToken), + }; + return $api.useInfiniteQuery("get", "/customer/aliases", { 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 0e6e60ad05d..acfd7c63c64 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -1,4 +1,5 @@ import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; @@ -13,11 +14,11 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useInfiniteModelInfo: vi.fn(), })); -vi.mock("../networking", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, allEndUsersCall: vi.fn().mockResolvedValue([]) }; -}); +vi.mock("@/app/(dashboard)/hooks/customers/useEndUserAliases", () => ({ + useInfiniteEndUserAliases: vi.fn(), +})); +import { useInfiniteEndUserAliases } from "@/app/(dashboard)/hooks/customers/useEndUserAliases"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; @@ -29,10 +30,12 @@ const emptyInfiniteQuery = { isLoading: false, }; +const LOGS_WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" }; + function renderFilters(filters: Record = {}) { const set = vi.fn(); renderWithProviders( - filters[id]} set={set} teams={[]} accessToken="test-token" />, + filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />, ); return { set }; } @@ -47,6 +50,9 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteModelInfo).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); + vi.mocked(useInfiniteEndUserAliases).mockReturnValue( + emptyInfiniteQuery as unknown as ReturnType, + ); }); it("renders every backend-supported filter field", async () => { @@ -88,4 +94,64 @@ describe("RequestLogsFilters", () => { await waitFor(() => expect(useInfiniteModelInfo).toHaveBeenCalled()); expect(useInfiniteModelInfo).toHaveBeenCalledWith(50, undefined); }); + + it("asks the server for a bounded page of end users scoped to the visible time window", async () => { + renderFilters(); + + await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalled()); + expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, undefined); + }); + + it("pushes the End User query to the server rather than filtering a preloaded list", async () => { + const user = userEvent.setup(); + renderFilters(); + + const input = await screen.findByPlaceholderText("Search an end user"); + await user.click(input); + await user.type(input, "acme"); + + await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, "acme")); + }); + + it("renders only the end users the current page returned", async () => { + vi.mocked(useInfiniteEndUserAliases).mockReturnValue({ + ...emptyInfiniteQuery, + data: { pages: [{ aliases: ["cust-a", "cust-b"], current_page: 1, size: 50, has_more: true }], pageParams: [1] }, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderFilters(); + + await user.click(await screen.findByPlaceholderText("Search an end user")); + + expect(await screen.findByText("cust-a")).toBeInTheDocument(); + expect(screen.getByText("cust-b")).toBeInTheDocument(); + }); + + it("loads the next page when the End User list is scrolled near the end", async () => { + const fetchNextPage = vi.fn(); + vi.mocked(useInfiniteEndUserAliases).mockReturnValue({ + ...emptyInfiniteQuery, + fetchNextPage, + hasNextPage: true, + data: { pages: [{ aliases: ["cust-a"], current_page: 1, size: 50, has_more: true }], pageParams: [1] }, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderFilters(); + + await user.click(await screen.findByPlaceholderText("Search an end user")); + const list = await screen.findByTestId("paginated-search-select-list"); + Object.defineProperty(list, "scrollTop", { value: 90, configurable: true }); + Object.defineProperty(list, "clientHeight", { value: 10, configurable: true }); + Object.defineProperty(list, "scrollHeight", { value: 100, configurable: true }); + list.dispatchEvent(new Event("scroll", { bubbles: true })); + + await waitFor(() => expect(fetchNextPage).toHaveBeenCalled()); + }); + + 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} />); + + await waitFor(() => expect(useInfiniteEndUserAliases).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 ec20f4d0e47..054caf46943 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -1,8 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; +import { useInfiniteEndUserAliases } from "@/app/(dashboard)/hooks/customers/useEndUserAliases"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { DataTableFilterField } from "@/components/shared/DataTable"; @@ -20,9 +20,8 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import type { Team } from "../key_team_helpers/key_list"; -import { allEndUsersCall } from "../networking"; import { ERROR_CODE_OPTIONS } from "./constants"; -import { LOG_FILTER_IDS } from "./log_filter_logic"; +import { LOG_FILTER_IDS, type LogsWindow } from "./log_filter_logic"; const ALL_VALUE = "all"; const PAGE_SIZE = 50; @@ -148,36 +147,43 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value function EndUserFilterField({ value, onChange, - accessToken, + logsWindow, }: { value: string; onChange: (value: string | undefined) => void; - accessToken: string; + logsWindow: LogsWindow; }) { - const { data } = useQuery({ - queryKey: ["logFilterEndUsers", accessToken], - queryFn: async () => { - const endUsers = await allEndUsersCall(accessToken); - return (endUsers ?? []).flatMap((endUser: { user_id?: string }) => - typeof endUser.user_id === "string" ? [endUser.user_id] : [], - ); - }, - enabled: accessToken !== "", - }); - - const options = useMemo( - () => (data ?? []).map((userId) => ({ label: userId, value: userId })), - [data], + const [search, setSearch] = useState(""); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteEndUserAliases( + logsWindow, + PAGE_SIZE, + emptyToUndefined(search), ); + const options = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []).flatMap((page) => + page.aliases.flatMap((alias) => { + if (!alias || seen.has(alias)) return []; + seen.add(alias); + return [{ label: alias, value: alias }]; + }), + ); + }, [data]); + return ( - onChange(emptyToUndefined(next))} + onSearchChange={setSearch} + onLoadMore={() => void fetchNextPage()} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} placeholder="Search an end user" - emptyText="No end users found" + emptyText="No end users in this time range" /> ); @@ -236,10 +242,10 @@ interface RequestLogsFiltersProps { get: (columnId: string) => unknown; set: (columnId: string, value: unknown) => void; teams: Team[]; - accessToken: string; + logsWindow: LogsWindow; } -export function RequestLogsFilters({ get, set, teams, accessToken }: 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); @@ -276,7 +282,7 @@ export function RequestLogsFilters({ get, set, teams, accessToken }: RequestLogs diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 1348c6cea56..b3b1e8c0640 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -12,7 +12,13 @@ import { keyInfoV1Call } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import type { LogEntry } from "./columns"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; -import { DEFAULT_LOGS_SORTING, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic"; +import { + DEFAULT_LOGS_SORTING, + formatLogsWindow, + getLogsWindowEndBound, + LOG_FILTER_IDS, + useLogFilterLogic, +} from "./log_filter_logic"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar"; import { RequestLogsTable } from "./RequestLogsTable"; @@ -76,6 +82,14 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, sorting, }); + // Follow the table's own last fetch so a live-tail refresh carries the filter + // window with it; before the first fetch, fall back to the stored end time. + const windowEndBound = getLogsWindowEndBound(logsQuery.dataUpdatedAt || Date.parse(endTime)); + const logsWindow = useMemo( + () => formatLogsWindow(startTime, endTime, isCustomDate, windowEndBound), + [startTime, endTime, isCustomDate, windowEndBound], + ); + const keyInfoQueryOptions: UseQueryOptions = { queryKey: ["requestLogsKeyInfo", selectedKeyIdInfoView, accessToken], queryFn: async () => { @@ -232,7 +246,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, onKeyHashClick={handleKeyHashClick} onSessionClick={handleSessionClick} teams={allTeams ?? []} - accessToken={accessToken} + logsWindow={logsWindow} toolbarChildren={ void; onSessionClick: (sessionId: string) => void; teams: Team[]; - accessToken: string; + logsWindow: LogsWindow; toolbarChildren?: ReactNode; } @@ -68,7 +68,7 @@ export function RequestLogsTable({ onKeyHashClick, onSessionClick, teams, - accessToken, + logsWindow, toolbarChildren, }: RequestLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); @@ -122,7 +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 9f8f2fbe542..080bf6b380a 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 @@ -6,10 +6,13 @@ import React, { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_LOGS_SORTING, + formatLogsWindow, getFilterValue, getLiveTailRefetchInterval, + getLogsWindowEndBound, LIVE_TAIL_INTERVAL_MS, LOG_FILTER_IDS, + LOGS_WINDOW_TICK_MS, useLogFilterLogic, type PaginatedResponse, } from "./log_filter_logic"; @@ -233,3 +236,61 @@ describe("getLiveTailRefetchInterval", () => { expect(getLiveTailRefetchInterval(true, 1)).toBe(false); }); }); + +describe("formatLogsWindow", () => { + it("pins the end bound for a custom range", () => { + const w = formatLogsWindow("2026-07-23T00:00", "2026-07-24T06:00", true); + + expect(w.start_date).toBe(moment("2026-07-23T00:00").utc().format("YYYY-MM-DD HH:mm:ss")); + expect(w.end_date).toBe(moment("2026-07-24T06:00").utc().format("YYYY-MM-DD HH:mm:ss")); + }); + + it("ends a preset range at now, not at the stored end time", () => { + const w = formatLogsWindow("2026-07-23T00:00", "1999-01-01T00:00", false); + + expect(w.end_date > "2020-01-01 00:00:00").toBe(true); + }); +}); + +describe("getLogsWindowEndBound", () => { + const BUCKET_START = 16666 * LOGS_WINDOW_TICK_MS; + + it("holds steady inside a bucket so a memoized window does not refetch per render", () => { + expect(getLogsWindowEndBound(BUCKET_START)).toBe(getLogsWindowEndBound(BUCKET_START + LOGS_WINDOW_TICK_MS - 1)); + }); + + it("advances once the bucket rolls over so a preset window follows the table", () => { + expect(getLogsWindowEndBound(BUCKET_START + LOGS_WINDOW_TICK_MS)).toBe( + getLogsWindowEndBound(BUCKET_START) + LOGS_WINDOW_TICK_MS, + ); + }); + + it("never trails the fetch it was derived from", () => { + // Trailing is the live-tail bug: the table shows rows the filter window excludes. + for (const offset of [0, 1, LOGS_WINDOW_TICK_MS - 1]) { + expect(getLogsWindowEndBound(BUCKET_START + offset)).toBeGreaterThan(BUCKET_START + offset); + } + }); + + it("advances at least once per live-tail refetch interval", () => { + expect(LOGS_WINDOW_TICK_MS).toBeLessThanOrEqual(4 * LIVE_TAIL_INTERVAL_MS); + }); +}); + +describe("formatLogsWindow preset end bound", () => { + it("uses the supplied bound for a preset range so callers can memoize it", () => { + const bound = Date.UTC(2026, 6, 24, 12, 0, 0); + + expect(formatLogsWindow("2026-07-23T00:00", "1999-01-01T00:00", false, bound).end_date).toBe( + moment(bound).utc().format("YYYY-MM-DD HH:mm:ss"), + ); + }); + + it("ignores the supplied bound for a custom range", () => { + const bound = Date.UTC(2026, 6, 24, 12, 0, 0); + + expect(formatLogsWindow("2026-07-23T00:00", "2026-07-24T06:00", true, bound).end_date).toBe( + moment("2026-07-24T06:00").utc().format("YYYY-MM-DD HH:mm:ss"), + ); + }); +}); 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 ae229056fa3..8244066cadb 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 @@ -44,6 +44,37 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", }; +export interface LogsWindow { + start_date: string; + end_date: string; +} + +export const formatLogsWindow = ( + startTime: string, + endTime: string, + isCustomDate: boolean, + presetEndMs: number = Date.now(), +): LogsWindow => ({ + start_date: moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"), + end_date: isCustomDate + ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") + : moment(presetEndMs).utc().format("YYYY-MM-DD HH:mm:ss"), +}); + +export const LOGS_WINDOW_TICK_MS = 60000; + +/** + * Stable end bound for anything that memoizes a preset (non-custom) window. + * + * The logs query re-reads "now" on every fetch, so a live-tail refresh keeps moving + * its end bound. A memoized window needs to follow, or it pins a bound the table has + * already passed and stops offering end users the table is showing. Rounding the + * last-fetch time UP to the next bucket keeps the value stable between ticks (so the + * query key does not churn per render) while never trailing behind the table. + */ +export const getLogsWindowEndBound = (lastFetchedAtMs: number): number => + (Math.floor(lastFetchedAtMs / LOGS_WINDOW_TICK_MS) + 1) * LOGS_WINDOW_TICK_MS; + export const LIVE_TAIL_INTERVAL_MS = 15000; export const getLiveTailRefetchInterval = (isLiveTail: boolean, pageIndex: number): number | false => @@ -119,17 +150,14 @@ export function useLogFilterLogic({ }; } - const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); - const formattedEndTime = isCustomDate - ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") - : moment().utc().format("YYYY-MM-DD HH:mm:ss"); + const window = formatLogsWindow(startTime, endTime, isCustomDate); const userIdFilter = getFilterValue(columnFilters, LOG_FILTER_IDS.USER_ID); return await uiSpendLogsCall({ accessToken, - start_date: formattedStartTime, - end_date: formattedEndTime, + start_date: window.start_date, + end_date: window.end_date, page: pagination.pageIndex + 1, page_size: pageSize, params: { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index c1141db7852..9a33a6bd758 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2772,6 +2772,40 @@ export interface paths { patch: operations["cursor_proxy_route_cursor__endpoint__patch"]; trace?: never; }; + "/customer/aliases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Customer Aliases + * @description List the end users seen in spend logs over a time window, for UI filter dropdowns. + * + * Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window, + * anyone else sees only end users from their own requests or from teams they + * administer (or hold the `/spend/logs` permission on). + * + * Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry + * the team attribution this scoping needs. The window is required and the inner + * scan is capped at SPEND_LOGS_FILTER_SCAN_CAP rows, so the query + * cannot degrade into a full-table scan the way `/global/all_end_users` does. + * + * Example curl: + * ``` + * curl --location 'http://0.0.0.0:4000/customer/aliases?start_date=2026-07-23%2000:00:00&end_date=2026-07-24%2000:00:00&size=50&search=acme' --header 'Authorization: Bearer sk-1234' + * ``` + */ + get: operations["list_customer_aliases_customer_aliases_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/customer/block": { parameters: { query?: never; @@ -23295,6 +23329,29 @@ export interface components { [key: string]: unknown; }; }; + /** + * CustomerAliasesResponse + * @description Paginated, id-only customer listing used by UI filter dropdowns. + * + * Deliberately excludes budget/object-permission relations so a proxy with a + * large LiteLLM_EndUserTable can back a search-as-you-type control without + * materializing every row (see /customer/list for the full objects). + * + * Reports ``has_more`` rather than a total count on purpose: a total requires + * COUNT(*) over the whole match set on every keystroke, which is the exact + * cost this endpoint exists to avoid. Fetching one row beyond the page is + * enough to drive an infinite-scroll dropdown. + */ + CustomerAliasesResponse: { + /** Aliases */ + aliases: string[]; + /** Current Page */ + current_page: number; + /** Has More */ + has_more: boolean; + /** Size */ + size: number; + }; /** * CustomerResponse * @description Customer object returned by the /customer read+write endpoints. @@ -38400,6 +38457,46 @@ export interface operations { }; }; }; + list_customer_aliases_customer_aliases_get: { + parameters: { + query: { + /** @description Window start, 'YYYY-MM-DD HH:MM:SS' (UTC) */ + start_date: string; + /** @description Window end, 'YYYY-MM-DD HH:MM:SS' (UTC) */ + end_date: string; + /** @description Page number */ + page?: number; + /** @description Page size */ + size?: number; + /** @description Case-insensitive partial match on the customer id */ + search?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomerAliasesResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; block_user_customer_block_post: { parameters: { query?: never;