From 8f83fb77640db0e8ca20a313fd4d65ca2fc15488 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 27 Jul 2026 11:30:51 -0700 Subject: [PATCH] Merge pull request #34691 from BerriAI/litellm_/management-endpoint-standards-b1cd57 refactor(management): move the logs end-user filter onto /management/v1 (cherry picked from commit 2b7e01bb7e41f82e732b6cfa79d029192d9689ac) --- backend/routes/allowlist.py | 4 + litellm/proxy/_types.py | 9 +- .../customer_endpoints.py | 177 +------ .../management_v1/__init__.py | 12 + .../management_v1/common.py | 77 ++++ .../management_v1/spend_logs.py | 203 ++++++++ litellm/proxy/proxy_server.py | 30 ++ .../customer_endpoints.py | 19 - .../management_endpoints/management_v1.py | 39 ++ .../test_otel_exception_handler.py | 37 +- .../management_v1/test_spend_logs.py | 433 ++++++++++++++++++ .../test_customer_endpoints.py | 290 ------------ .../proxy_server/test_exception_handlers.py | 40 +- .../hooks/customers/useEndUserAliases.ts | 22 - .../spendLogs/useSpendLogEndUsers.test.ts | 80 ++++ .../hooks/spendLogs/useSpendLogEndUsers.ts | 36 ++ .../view_logs/RequestLogsFilters.test.tsx | 44 +- .../view_logs/RequestLogsFilters.tsx | 12 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 205 +++++---- 19 files changed, 1133 insertions(+), 636 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/__init__.py create mode 100644 litellm/proxy/management_endpoints/management_v1/common.py create mode 100644 litellm/proxy/management_endpoints/management_v1/spend_logs.py create mode 100644 litellm/types/proxy/management_endpoints/management_v1.py create mode 100644 tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.ts diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index f3a028f5805..a0efa19f320 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -70,6 +70,10 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = ( "/project/", "/memory/", "/mcp/", + # Control plane (see the List Endpoints + Tables standard). Every resource + # eventually moves under this prefix, so allowlist it once rather than + # per-resource. + "/management/v1/", # Spend / analytics "/spend/", "/analytics/", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7575091be54..20d0d535b87 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -632,7 +632,7 @@ class LiteLLMRoutes(enum.Enum): # 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", + "/management/v1/spend_logs/end_users", "/cost/estimate", ] @@ -822,12 +822,13 @@ 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. + # UI Logs page detail drawer (single + session) and the end-user filter + # facet. 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", # Settings / observability read endpoints exposed in admin-only # sidebar groups (Logging & Alerts, Admin Settings, Budgets, # Invitations). diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index a46481d5bb7..84f67bdc3bc 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -10,12 +10,11 @@ All /customer management endpoints """ #### END-USER/CUSTOMER MANAGEMENT #### -from collections.abc import MutableSequence -from datetime import datetime, timedelta, timezone -from typing import Annotated, Any, List, Optional +from datetime import datetime, timedelta +from typing import List, Optional import fastapi -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel import litellm @@ -28,7 +27,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, handle_update_object_permission_common, ) -from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy +from litellm.proxy.utils import 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 ( @@ -36,7 +35,6 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( ) from litellm.types.proxy.management_endpoints.customer_endpoints import ( BlockUsersResponse, - CustomerAliasesResponse, CustomerResponse, DeleteCustomersResponse, UnblockUsersResponse, @@ -44,11 +42,6 @@ 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. @@ -792,168 +785,6 @@ 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/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py new file mode 100644 index 00000000000..257de66130b --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -0,0 +1,12 @@ +"""The `/management/v1` control-plane surface.""" + +from fastapi import APIRouter + +from litellm.proxy.management_endpoints.management_v1.spend_logs import ( + router as spend_logs_router, +) + +router = APIRouter() +router.include_router(spend_logs_router) + +__all__ = ["router"] diff --git a/litellm/proxy/management_endpoints/management_v1/common.py b/litellm/proxy/management_endpoints/management_v1/common.py new file mode 100644 index 00000000000..c0e7f49f2e9 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/common.py @@ -0,0 +1,77 @@ +"""Contract machinery shared by every `/management/v1` route.""" + +from urllib.parse import urlencode + +from fastapi import Request +from fastapi.dependencies.utils import get_flat_dependant +from fastapi.responses import JSONResponse + +from litellm.types.proxy.management_endpoints.management_v1 import ( + PageLinks, + ProblemDetail, +) + +MANAGEMENT_V1_PREFIX = "/management/v1" +PROBLEM_CONTENT_TYPE = "application/problem+json" +# A URN, not an https URL: RFC 9457 only asks that `type` identify the problem +# type, and an https URI promises documentation at that address. Switch to an +# https base only when pages actually exist to serve. +PROBLEM_TYPE_BASE = "urn:litellm:error:" + + +class ManagementProblem(Exception): + """Raised to return an RFC 9457 problem instead of the proxy's OpenAI error shape.""" + + def __init__(self, problem: ProblemDetail) -> None: + self.problem = problem + super().__init__(problem.detail) + + +def problem_response(problem: ProblemDetail) -> JSONResponse: + return JSONResponse( + status_code=problem.status, + content=problem.model_dump(exclude_none=True), + media_type=PROBLEM_CONTENT_TYPE, + ) + + +def _declared_query_params(request: Request) -> frozenset[str]: + route = request.scope.get("route") + dependant = getattr(route, "dependant", None) + if dependant is None: + return frozenset() + return frozenset(field.alias for field in get_flat_dependant(dependant, skip_repeats=True).query_params) + + +async def reject_unknown_query_params(request: Request) -> None: + """Reject any query param the route did not declare. + + A silently ignored filter over-returns data, which is worse than a rejected + request; a fresh surface is the only chance to be strict about it. + """ + declared = _declared_query_params(request) + unknown: tuple[str, ...] = tuple(sorted(name for name in request.query_params if name not in declared)) + if not unknown: + return + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}unknown-query-parameter", + title="Unknown query parameter", + status=400, + detail=f"Unrecognized query parameter(s): {', '.join(unknown)}.", + allowed=sorted(declared), + ) + ) + + +def _page_url(request: Request, page: int) -> str: + others = tuple((key, value) for key, value in request.query_params.multi_items() if key != "page") + return f"{request.url.path}?{urlencode((*others, ('page', page)))}" + + +def build_page_links(request: Request, page: int, has_more: bool) -> PageLinks: + return PageLinks( + self_link=_page_url(request, page), + prev=_page_url(request, page - 1) if page > 1 else None, + next=_page_url(request, page + 1) if has_more else None, + ) diff --git a/litellm/proxy/management_endpoints/management_v1/spend_logs.py b/litellm/proxy/management_endpoints/management_v1/spend_logs.py new file mode 100644 index 00000000000..c11a14bbfea --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/spend_logs.py @@ -0,0 +1,203 @@ +"""`/management/v1/spend_logs` facets.""" + +from datetime import datetime, timezone +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, Request + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, + build_page_links, + reject_unknown_query_params, +) +from litellm.proxy.utils import PrismaClient +from litellm.types.proxy.management_endpoints.management_v1 import ( + FacetListResponse, + PageMeta, + ProblemDetail, +) + +router = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + +# Rows the facet query may read out of LiteLLM_SpendLogs before DISTINCT. Matches +# SPEND_LOGS_PAGINATION_COUNT_CAP, the bound ui_view_spend_logs puts on its count +# query, so both reads of the same table stop at the same depth. +SPEND_LOGS_FACET_SCAN_CAP = 10000 + + +def _as_utc(value: datetime) -> datetime: + return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) + + +def _escape_like(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +async def _end_user_scope_clause( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, + next_param_index: int, +) -> tuple[str | None, tuple[Any, ...]]: + """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. + """ + 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 + # = ANY(::text[]) rather than an expanded IN list, matching the clause + # ui_view_spend_logs builds: one parameter whatever the team count. + templates = (('"user" = ${}',) if caller_user_id is not None else ()) + ( + ("team_id = ANY(${}::text[])",) if permitted_team_ids else () + ) + params = ((caller_user_id,) if caller_user_id is not None else ()) + ( + (permitted_team_ids,) if permitted_team_ids else () + ) + if not templates: + return "FALSE", () + clauses = tuple(template.format(next_param_index + offset) for offset, template in enumerate(templates)) + return f"({' OR '.join(clauses)})", params + + +@router.get( + "/spend_logs/end_users", + tags=["Budget & Spend Tracking"], + dependencies=[Depends(user_api_key_auth), Depends(reject_unknown_query_params)], + response_model=FacetListResponse, +) +async def list_spend_log_end_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 end 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 end users appearing in spend logs over a time window, for the logs + page filter dropdown. + + 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). + + The window is required and the inner scan is capped at SPEND_LOGS_FACET_SCAN_CAP + rows, so the query cannot degrade into a full-table scan the way + `/global/all_end_users` does. + + Example curl: + ``` + curl --location --globoff 'http://0.0.0.0:4000/management/v1/spend_logs/end_users?filter[startTime][gte]=2026-07-23T00:00:00Z&filter[startTime][lte]=2026-07-24T00:00:00Z&page_size=50&q=acme' \ + --header 'Authorization: Bearer sk-1234' + ``` + """ + 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, + ) + ) + + window_params: tuple[Any, ...] = (_as_utc(start_time), _as_utc(end_time)) + search_params: tuple[Any, ...] = (f"%{_escape_like(q)}%",) if q else () + search_clause = (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 = ( + ( + "\"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 = ( + window_params + + search_params + + scope_params + + (SPEND_LOGS_FACET_SCAN_CAP, page_size + 1, (page - 1) * page_size) + ) + scan_idx = len(params) - 2 + facet_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(facet_sql, *params) + end_users: list[str] = [row["end_user"] for row in rows if row.get("end_user")] + has_more = 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 - {}".format(str(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.", + ) + ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c44f5602c95..e0365b7d621 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -393,6 +393,16 @@ from litellm.proxy.management_endpoints.cost_tracking_settings import ( from litellm.proxy.management_endpoints.customer_endpoints import ( router as customer_router, ) +from litellm.proxy.management_endpoints.management_v1 import ( + router as management_v1_router, +) +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, + problem_response, +) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail from litellm.proxy.management_endpoints.fallback_management_endpoints import ( router as fallback_management_router, ) @@ -1438,8 +1448,27 @@ def _close_dangling_otel_server_span(request: Request, status_code: int, exc: Op request.state.parent_otel_span = None +@app.exception_handler(ManagementProblem) +async def management_problem_exception_handler(request: Request, exc: ManagementProblem): + _close_dangling_otel_server_span(request, exc.problem.status, exc=exc) + return problem_response(exc.problem) + + @app.exception_handler(RequestValidationError) async def otel_request_validation_exception_handler(request: Request, exc: RequestValidationError): + if request.url.path.startswith(MANAGEMENT_V1_PREFIX): + _close_dangling_otel_server_span(request, 400, exc=exc) + return problem_response( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", + title="Invalid query parameter", + status=400, + detail="; ".join( + f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in exc.errors() + ) + or "The request query parameters are invalid.", + ) + ) _close_dangling_otel_server_span(request, 422, exc=exc) return JSONResponse( status_code=422, @@ -16306,6 +16335,7 @@ app.include_router(team_router) app.include_router(ui_sso_router) app.include_router(organization_router) app.include_router(customer_router) +app.include_router(management_v1_router) app.include_router(spend_management_router) app.include_router(caching_router) app.include_router(analytics_router) diff --git a/litellm/types/proxy/management_endpoints/customer_endpoints.py b/litellm/types/proxy/management_endpoints/customer_endpoints.py index 93d042fcea1..e7653360d63 100644 --- a/litellm/types/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/types/proxy/management_endpoints/customer_endpoints.py @@ -17,25 +17,6 @@ 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/litellm/types/proxy/management_endpoints/management_v1.py b/litellm/types/proxy/management_endpoints/management_v1.py new file mode 100644 index 00000000000..2aecc54f114 --- /dev/null +++ b/litellm/types/proxy/management_endpoints/management_v1.py @@ -0,0 +1,39 @@ +"""Shared response shapes for the `/management/v1` control-plane surface.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class ProblemDetail(BaseModel): + """RFC 9457 problem details, served as `application/problem+json`.""" + + type: str + title: str + status: int + detail: str + allowed: list[str] | None = None + + +class PageLinks(BaseModel): + """Hypermedia for a paginated list. No `first`/`last`: without a total count the last page is unknown.""" + + model_config = ConfigDict(populate_by_name=True) + + self_link: str = Field(alias="self") + prev: str | None = None + next: str | None = None + + +class PageMeta(BaseModel): + """`has_more` rather than `total_count`, which would need a COUNT(*) over the whole match set per keystroke.""" + + page: int + page_size: int + has_more: bool + + +class FacetListResponse(BaseModel): + """The distinct values one column takes over a filtered query. `data` holds bare values, not entity rows.""" + + data: list[str] + meta: PageMeta + links: PageLinks diff --git a/tests/test_litellm/integrations/open_telemetry/test_otel_exception_handler.py b/tests/test_litellm/integrations/open_telemetry/test_otel_exception_handler.py index 348ef5082e7..dc99df24c50 100644 --- a/tests/test_litellm/integrations/open_telemetry/test_otel_exception_handler.py +++ b/tests/test_litellm/integrations/open_telemetry/test_otel_exception_handler.py @@ -23,11 +23,13 @@ from litellm.integrations._types.open_inference import ErrorAttributes from ._helpers import assert_server_span_attrs, get_server_span -def _fake_request(parent_otel_span=None): +def _fake_request(parent_otel_span=None, path="/key/generate"): + """A real Request always carries a url; the validation handler reads its path to + decide whether the caller is on a surface with its own error contract.""" state = types.SimpleNamespace() if parent_otel_span is not None: state.parent_otel_span = parent_otel_span - return types.SimpleNamespace(state=state) + return types.SimpleNamespace(state=state, url=types.SimpleNamespace(path=path)) @pytest.fixture @@ -41,7 +43,7 @@ def wired_otel(otel_with_exporter, monkeypatch): def test_close_dangling_span_stamps_status( wired_otel, server_span_factory, status, path ): - request = _fake_request(parent_otel_span=server_span_factory(path)) + request = _fake_request(parent_otel_span=server_span_factory(path), path=path) _close_dangling_otel_server_span(request, status) assert_server_span_attrs( wired_otel, @@ -59,7 +61,7 @@ def test_close_dangling_span_noop_when_no_span(wired_otel): def test_close_dangling_span_noop_when_otel_absent(server_span_factory, monkeypatch): monkeypatch.setattr(proxy_server_module, "open_telemetry_logger", None) - request = _fake_request(parent_otel_span=server_span_factory("/key/generate")) + request = _fake_request(parent_otel_span=server_span_factory("/key/generate"), path="/key/generate") _close_dangling_otel_server_span(request, 500) @@ -83,7 +85,7 @@ def test_close_dangling_span_noop_when_otel_absent(server_span_factory, monkeypa def test_exception_handler_closes_span( wired_otel, server_span_factory, handler, exc, status, path ): - request = _fake_request(parent_otel_span=server_span_factory(path)) + request = _fake_request(parent_otel_span=server_span_factory(path), path=path) response = asyncio.run(handler(request, exc)) assert response.status_code == status assert_server_span_attrs( @@ -94,6 +96,25 @@ def test_exception_handler_closes_span( ) +def test_validation_handler_closes_span_on_the_control_plane_too(wired_otel, server_span_factory): + """The control plane answers validation errors with a 400 problem document + instead of the proxy-wide 422, and that branch returns early. It must still + close the dangling SERVER span, or those requests leak a span apiece.""" + path = "/management/v1/spend_logs/end_users" + request = _fake_request(parent_otel_span=server_span_factory(path), path=path) + + response = asyncio.run(otel_request_validation_exception_handler(request, RequestValidationError(errors=[]))) + + assert response.status_code == 400 + assert response.media_type == "application/problem+json" + assert_server_span_attrs( + wired_otel, + expected_status=400, + expected_url_path=path, + where="otel_request_validation_exception_handler (control plane)", + ) + + @pytest.mark.parametrize("path", ["/team/list", "/organization/list"]) def test_openai_exception_handler_stamps_structured_error_on_span( wired_otel, server_span_factory, path @@ -103,7 +124,7 @@ def test_openai_exception_handler_stamps_structured_error_on_span( ProxyException stringified to "" so error.message was dropped — the span showed an error with no message.""" msg = "Authentication Error, Invalid proxy server token passed." - request = _fake_request(parent_otel_span=server_span_factory(path)) + request = _fake_request(parent_otel_span=server_span_factory(path), path=path) exc = ProxyException(message=msg, type="auth_error", param="key", code=401) response = asyncio.run(openai_exception_handler(request, exc)) @@ -123,7 +144,7 @@ def test_openai_exception_handler_stamps_structured_error_on_span( def test_unhandled_handler_reraises_known_exceptions(wired_otel, server_span_factory): """ProxyException / HTTPException / RequestValidationError have dedicated handlers.""" - request = _fake_request(parent_otel_span=server_span_factory("/key/generate")) + request = _fake_request(parent_otel_span=server_span_factory("/key/generate"), path="/key/generate") with pytest.raises(HTTPException): asyncio.run( otel_unhandled_exception_handler( @@ -147,7 +168,7 @@ def test_unhandled_handler_reraises_known_exceptions(wired_otel, server_span_fac def test_openai_exception_handler_closes_span( wired_otel, server_span_factory, code, path ): - request = _fake_request(parent_otel_span=server_span_factory(path)) + request = _fake_request(parent_otel_span=server_span_factory(path), path=path) exc = ProxyException( message="boom", type="invalid_request_error", 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 new file mode 100644 index 00000000000..79f13a6f703 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py @@ -0,0 +1,433 @@ +from datetime import datetime, timezone +from typing import List +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient + +from litellm.proxy._types import LiteLLMRoutes, LitellmUserRoles +from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth +from litellm.proxy.management_endpoints.management_v1 import router +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, + problem_response, +) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail + +app = FastAPI() + + +@app.exception_handler(ManagementProblem) +async def management_problem_exception_handler(request: Request, exc: ManagementProblem): + return problem_response(exc.problem) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return problem_response( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", + title="Invalid query parameter", + status=400, + detail="; ".join( + f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in exc.errors() + ) + or "The request query parameters are invalid.", + ) + ) + + +app.include_router(router) +client = TestClient(app) + +END_USERS_PATH = f"{MANAGEMENT_V1_PREFIX}/spend_logs/end_users" +WINDOW = "filter[startTime][gte]=2026-07-23T00:00:00Z&filter[startTime][lte]=2026-07-24T00:00:00Z" + + +@pytest.fixture +def mock_prisma_client(monkeypatch): + prisma_client = MagicMock() + prisma_client.db.query_raw = AsyncMock(return_value=[]) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client) + return prisma_client + + +@pytest.fixture +def as_proxy_admin(): + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + yield + app.dependency_overrides.clear() + + +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 + + +def _as_role(role: LitellmUserRoles, user_id): + original = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id=user_id, user_role=role) + return original + + +def _get(query: str = WINDOW): + suffix = f"?{query}" if query else "" + return client.get(f"{END_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"]) + + response = _get() + + assert response.status_code == 200 + body = response.json() + assert body["data"] == ["a", "b"] + assert body["meta"] == {"page": 1, "page_size": 50, "has_more": False} + assert set(body) == {"data", "meta", "links"} + assert "aliases" not in body + assert "total_count" not in body["meta"] + + +def test_links_let_a_client_page_without_building_urls(mock_prisma_client, as_proxy_admin): + """The UI follows links.next; if it is absent the client has to recompute page params, + which is what makes a later switch to cursor pagination a breaking change.""" + _mock_rows(mock_prisma_client, [f"u{i}" for i in range(4)]) + + links = _get(f"{WINDOW}&page=2&page_size=3").json()["links"] + + assert links["self"].startswith(f"{END_USERS_PATH}?") + assert "page=2" in links["self"] + assert "page=1" in links["prev"] and "page_size=3" in links["prev"] + assert "page=3" in links["next"] and "page_size=3" in links["next"] + + +def test_next_link_is_absent_on_the_last_page(mock_prisma_client, as_proxy_admin): + _mock_rows(mock_prisma_client, ["u0", "u1"]) + + body = _get(f"{WINDOW}&page_size=3").json() + + assert body["meta"]["has_more"] is False + assert body["links"]["next"] is None + assert body["links"]["prev"] is None + + +def test_reads_spend_logs_not_the_end_user_table(mock_prisma_client, as_proxy_admin): + """Team scoping only exists in spend logs, so that is the source of truth.""" + query_raw = _mock_rows(mock_prisma_client, ["a"]) + + _get() + + sql = query_raw.call_args.args[0] + assert '"LiteLLM_SpendLogs"' in sql + assert "LiteLLM_EndUserTable" not in sql + + +def test_caps_the_rows_it_scans(mock_prisma_client, as_proxy_admin): + """The inner LIMIT is the crash guard: DISTINCT must never see an unbounded set.""" + from litellm.proxy.management_endpoints.management_v1.spend_logs import ( + SPEND_LOGS_FACET_SCAN_CAP, + ) + + query_raw = _mock_rows(mock_prisma_client, []) + + _get() + + 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_FACET_SCAN_CAP + assert 'ORDER BY "startTime" DESC' in inner + + +def test_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. + """ + from litellm.proxy.management_endpoints.management_v1.spend_logs import ( + SPEND_LOGS_FACET_SCAN_CAP, + ) + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + SPEND_LOGS_PAGINATION_COUNT_CAP, + ) + + assert SPEND_LOGS_FACET_SCAN_CAP == SPEND_LOGS_PAGINATION_COUNT_CAP + + +def test_breaks_start_time_ties_deterministically(mock_prisma_client, as_proxy_admin): + query_raw = _mock_rows(mock_prisma_client, []) + + _get() + + assert 'ORDER BY "startTime" DESC, request_id DESC' in query_raw.call_args.args[0] + + +def test_bounds_the_window_on_the_indexed_start_time(mock_prisma_client, as_proxy_admin): + query_raw = _mock_rows(mock_prisma_client, []) + + _get() + + 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_a_naive_window_bound_is_read_as_utc(mock_prisma_client, as_proxy_admin): + """The dashboard sends 'YYYY-MM-DD HH:MM:SS' with no offset; reading it as + server-local time would shift the window off what the logs table is showing.""" + query_raw = _mock_rows(mock_prisma_client, []) + + _get("filter[startTime][gte]=2026-07-23 00:00:00&filter[startTime][lte]=2026-07-24 00:00:00") + + 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) + + +@pytest.mark.parametrize( + "query", + ["", "filter[startTime][gte]=2026-07-23T00:00:00Z"], + ids=["no-window", "half-window"], +) +def test_requires_a_time_window(mock_prisma_client, as_proxy_admin, query): + """No window means no index bound, which is the unbounded scan we must not allow.""" + _mock_rows(mock_prisma_client, []) + + response = _get(query) + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/problem+json") + + +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") + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["type"].startswith(PROBLEM_TYPE_BASE) + assert body["status"] == 400 + assert body["title"] and body["detail"] + assert "error" not in body + + +def test_problem_type_is_an_identifier_not_a_dead_docs_link(mock_prisma_client, as_proxy_admin): + """RFC 9457 only asks that `type` identify the problem type. An https URI promises + human-readable documentation at that address, and https://docs.litellm.ai/errors/ + is a 404, so emitting one would ship a broken link in every error body.""" + _mock_rows(mock_prisma_client, []) + + problem_type = _get("filter[startTime][gte]=yesterday&filter[startTime][lte]=2026-07-24T00:00:00Z").json()["type"] + + assert problem_type.startswith("urn:") + assert "docs.litellm.ai" not in problem_type + assert not problem_type.startswith("http") + + +def test_rejects_an_unknown_query_parameter(mock_prisma_client, as_proxy_admin): + """A silently ignored filter over-returns data, which is worse than a rejected request.""" + query_raw = _mock_rows(mock_prisma_client, []) + + response = _get(f"{WINDOW}&q_typo=acme") + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert "q_typo" in body["detail"] + assert "q" in body["allowed"] + query_raw.assert_not_called() + + +def test_accepts_every_declared_parameter(mock_prisma_client, as_proxy_admin): + """Guards the unknown-param check against rejecting the endpoint's own contract.""" + _mock_rows(mock_prisma_client, []) + + assert _get(f"{WINDOW}&q=acme&page=2&page_size=10").status_code == 200 + + +def test_caps_page_size(mock_prisma_client, as_proxy_admin): + _mock_rows(mock_prisma_client, []) + + assert _get(f"{WINDOW}&page_size=100000").status_code == 400 + + +def test_applies_no_scope_for_a_proxy_admin(mock_prisma_client, as_proxy_admin): + query_raw = _mock_rows(mock_prisma_client, []) + + _get() + + 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_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_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 = _get() + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + # Same clause shape ui_view_spend_logs builds, so the two cannot diverge. + 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", "team-b"] + + +def test_scopes_a_teamless_user_to_their_own_rows(mock_prisma_client): + query_raw = _mock_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 = _get() + 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_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_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 = _get() + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + assert "FALSE" in query_raw.call_args.args[0] + + +def test_scopes_when_the_permitted_team_lookup_fails(mock_prisma_client): + """A failed team lookup must degrade to own-rows-only, never to unscoped.""" + query_raw = _mock_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 = _get() + 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_fetches_one_extra_row_and_trims_it(mock_prisma_client, as_proxy_admin): + query_raw = _mock_rows(mock_prisma_client, [f"u{i}" for i in range(4)]) + + body = _get(f"{WINDOW}&page_size=3").json() + + assert body["data"] == ["u0", "u1", "u2"] + assert body["meta"]["has_more"] is True + assert query_raw.call_args.args[4:] == (4, 0) + + +def test_reports_no_more_pages_on_an_exactly_full_page(mock_prisma_client, as_proxy_admin): + _mock_rows(mock_prisma_client, ["u0", "u1", "u2"]) + + body = _get(f"{WINDOW}&page_size=3").json() + + assert body["data"] == ["u0", "u1", "u2"] + assert body["meta"]["has_more"] is False + + +def test_offsets_by_page(mock_prisma_client, as_proxy_admin): + query_raw = _mock_rows(mock_prisma_client, []) + + body = _get(f"{WINDOW}&page=3&page_size=25").json() + + assert body["meta"]["page"] == 3 + assert query_raw.call_args.args[4:] == (26, 50) + + +def test_q_escapes_like_metacharacters(mock_prisma_client, as_proxy_admin): + """End-user ids routinely contain '_'; unescaped it is a wildcard.""" + query_raw = _mock_rows(mock_prisma_client, []) + + _get(f"{WINDOW}&q=device_id%25") + + 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_q_placeholder_precedes_the_scan_limit_and_offset(mock_prisma_client, as_proxy_admin): + query_raw = _mock_rows(mock_prisma_client, []) + + _get(f"{WINDOW}&q=acme&page_size=10") + + 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) + + +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ], +) +def test_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. + """ + 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) + + 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 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 98e93eea5f9..5fbc3c4869b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -1,4 +1,3 @@ -from datetime import datetime, timezone from typing import List from unittest.mock import AsyncMock, MagicMock, patch @@ -10,7 +9,6 @@ from fastapi.testclient import TestClient from litellm.proxy._types import ( LiteLLM_EndUserTable, - LiteLLMRoutes, LitellmUserRoles, ProxyException, ) @@ -784,291 +782,3 @@ 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/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py b/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py index e4bf06991b4..4aea2e16364 100644 --- a/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py +++ b/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py @@ -28,9 +28,11 @@ from litellm.proxy.proxy_server import ( from .conftest import normalize -def _make_request(parent_otel_span=None): +def _make_request(parent_otel_span=None, path="/chat/completions"): + """A real Request always carries a url; the validation handler reads its path to + decide whether the caller is on a surface with its own error contract.""" state = SimpleNamespace(parent_otel_span=parent_otel_span) - return SimpleNamespace(state=state) + return SimpleNamespace(state=state, url=SimpleNamespace(path=path)) # --------------------------------------------------------------------------- @@ -221,6 +223,40 @@ async def test_otel_request_validation_exception_handler_empty_errors_invalid_pa assert body == {"detail": []} +@pytest.mark.asyncio +async def test_otel_request_validation_exception_handler_returns_a_problem_on_the_control_plane(): + """`/management/v1` answers validation errors as RFC 9457, so a caller there gets a + 400 problem document rather than the proxy-wide 422 `{"detail": [...]}` shape.""" + errors = [{"loc": ["query", "page_size"], "msg": "Input should be less than or equal to 100", "type": "less_than_equal"}] + exc = RequestValidationError(errors) + request = _make_request(path="/management/v1/spend_logs/end_users") + + response = await otel_request_validation_exception_handler(request=request, exc=exc) + body = json.loads(response.body) + + assert response.status_code == 400 + assert response.media_type == "application/problem+json" + assert body["type"].startswith("urn:") + assert body["status"] == 400 + assert "page_size" in body["detail"] + assert "detail" in body and not isinstance(body["detail"], list) + + +@pytest.mark.asyncio +async def test_otel_request_validation_exception_handler_leaves_other_routes_on_422(): + """The problem+json branch is scoped by path prefix. A route that merely contains + the word management, or sits above the prefix, keeps the shape its callers parse.""" + exc = RequestValidationError([]) + + for path in ("/management", "/v1/management/foo", "/customer/list"): + response = await otel_request_validation_exception_handler( + request=_make_request(path=path), exc=exc + ) + + assert response.status_code == 422, path + assert json.loads(response.body) == {"detail": []}, path + + # --------------------------------------------------------------------------- # otel_unhandled_exception_handler # --------------------------------------------------------------------------- diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts deleted file mode 100644 index 2625361231f..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.test.ts new file mode 100644 index 00000000000..32f65eca486 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.test.ts @@ -0,0 +1,80 @@ +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 { nextPageFromLinks, useInfiniteSpendLogEndUsers } from "./useSpendLogEndUsers"; + +const WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" }; + +const page = (next: string | null) => ({ + data: ["cust-a"], + meta: { page: 1, page_size: 50, has_more: next !== null }, + links: { self: "/management/v1/spend_logs/end_users?page=1", prev: null, next }, +}); + +describe("useInfiniteSpendLogEndUsers", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token" }); + }); + + it("calls the control plane path", () => { + renderHook(() => useInfiniteSpendLogEndUsers(WINDOW, 50)); + + expect(useInfiniteQuery.mock.calls[0][1]).toBe("/management/v1/spend_logs/end_users"); + }); + + it("sends the window as filter params and the page size as page_size", () => { + renderHook(() => useInfiniteSpendLogEndUsers(WINDOW, 25)); + + const query = useInfiniteQuery.mock.calls[0][2].params.query; + expect(query).toEqual({ + "filter[startTime][gte]": "2026-07-23 00:00:00", + "filter[startTime][lte]": "2026-07-24 00:00:00", + page_size: 25, + }); + expect(query).not.toHaveProperty("start_date"); + expect(query).not.toHaveProperty("end_date"); + expect(query).not.toHaveProperty("size"); + }); + + it("sends free text as q, not search", () => { + renderHook(() => useInfiniteSpendLogEndUsers(WINDOW, 50, "acme")); + + const query = useInfiniteQuery.mock.calls[0][2].params.query; + expect(query.q).toBe("acme"); + expect(query).not.toHaveProperty("search"); + }); + + it("omits q entirely when the search box is empty", () => { + renderHook(() => useInfiniteSpendLogEndUsers(WINDOW, 50, "")); + + expect(useInfiniteQuery.mock.calls[0][2].params.query).not.toHaveProperty("q"); + }); + + it("derives the next page from the server's links.next", () => { + renderHook(() => useInfiniteSpendLogEndUsers(WINDOW, 50)); + + const { getNextPageParam } = useInfiniteQuery.mock.calls[0][3]; + expect(getNextPageParam(page("/management/v1/spend_logs/end_users?page_size=50&page=7"))).toBe(7); + }); +}); + +describe("nextPageFromLinks", () => { + it("reads the page the server pointed at rather than incrementing", () => { + /* An endpoint that later switches to cursor pagination changes links.next and + nothing else; a client that computed page+1 would silently break. */ + expect(nextPageFromLinks(page("/management/v1/spend_logs/end_users?page_size=50&page=7"))).toBe(7); + }); + + it("stops paging when the server omits links.next", () => { + expect(nextPageFromLinks(page(null))).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.ts new file mode 100644 index 00000000000..59912f3e4d9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers.ts @@ -0,0 +1,36 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { $api } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; + +type EndUsersPage = components["schemas"]["FacetListResponse"]; + +export interface SpendLogsWindow { + start_date: string; + end_date: string; +} + +/** Reads the server's `links.next` instead of computing the next page, so the + * endpoint can move to cursor pagination without touching this hook. */ +export const nextPageFromLinks = (lastPage: EndUsersPage): number | undefined => { + const next = lastPage.links.next; + if (!next) return undefined; + const page = new URLSearchParams(next.slice(next.indexOf("?") + 1)).get("page"); + return page === null ? undefined : Number(page); +}; + +export const useInfiniteSpendLogEndUsers = (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/end_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 acfd7c63c64..82b179b2654 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -14,11 +14,11 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useInfiniteModelInfo: vi.fn(), })); -vi.mock("@/app/(dashboard)/hooks/customers/useEndUserAliases", () => ({ - useInfiniteEndUserAliases: vi.fn(), +vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ + useInfiniteSpendLogEndUsers: vi.fn(), })); -import { useInfiniteEndUserAliases } from "@/app/(dashboard)/hooks/customers/useEndUserAliases"; +import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; @@ -50,8 +50,8 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteModelInfo).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); - vi.mocked(useInfiniteEndUserAliases).mockReturnValue( - emptyInfiniteQuery as unknown as ReturnType, + vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue( + emptyInfiniteQuery as unknown as ReturnType, ); }); @@ -98,8 +98,8 @@ describe("RequestLogsFilters", () => { 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); + await waitFor(() => expect(useInfiniteSpendLogEndUsers).toHaveBeenCalled()); + expect(useInfiniteSpendLogEndUsers).toHaveBeenCalledWith(LOGS_WINDOW, 50, undefined); }); it("pushes the End User query to the server rather than filtering a preloaded list", async () => { @@ -110,14 +110,23 @@ describe("RequestLogsFilters", () => { await user.click(input); await user.type(input, "acme"); - await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, "acme")); + await waitFor(() => expect(useInfiniteSpendLogEndUsers).toHaveBeenCalledWith(LOGS_WINDOW, 50, "acme")); }); it("renders only the end users the current page returned", async () => { - vi.mocked(useInfiniteEndUserAliases).mockReturnValue({ + vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue({ ...emptyInfiniteQuery, - data: { pages: [{ aliases: ["cust-a", "cust-b"], current_page: 1, size: 50, has_more: true }], pageParams: [1] }, - } as unknown as ReturnType); + data: { + pages: [ + { + data: ["cust-a", "cust-b"], + meta: { page: 1, page_size: 50, has_more: true }, + links: { self: "", next: "?page=2" }, + }, + ], + pageParams: [1], + }, + } as unknown as ReturnType); const user = userEvent.setup(); renderFilters(); @@ -129,12 +138,17 @@ describe("RequestLogsFilters", () => { it("loads the next page when the End User list is scrolled near the end", async () => { const fetchNextPage = vi.fn(); - vi.mocked(useInfiniteEndUserAliases).mockReturnValue({ + vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue({ ...emptyInfiniteQuery, fetchNextPage, hasNextPage: true, - data: { pages: [{ aliases: ["cust-a"], current_page: 1, size: 50, has_more: true }], pageParams: [1] }, - } as unknown as ReturnType); + data: { + pages: [ + { data: ["cust-a"], meta: { page: 1, page_size: 50, has_more: true }, links: { self: "", next: "?page=2" } }, + ], + pageParams: [1], + }, + } as unknown as ReturnType); const user = userEvent.setup(); renderFilters(); @@ -152,6 +166,6 @@ describe("RequestLogsFilters", () => { 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)); + 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 054caf46943..2005b868cd6 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; -import { useInfiniteEndUserAliases } from "@/app/(dashboard)/hooks/customers/useEndUserAliases"; +import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { DataTableFilterField } from "@/components/shared/DataTable"; @@ -154,7 +154,7 @@ function EndUserFilterField({ logsWindow: LogsWindow; }) { const [search, setSearch] = useState(""); - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteEndUserAliases( + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteSpendLogEndUsers( logsWindow, PAGE_SIZE, emptyToUndefined(search), @@ -163,10 +163,10 @@ function EndUserFilterField({ 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 }]; + page.data.flatMap((endUser) => { + if (!endUser || seen.has(endUser)) return []; + seen.add(endUser); + return [{ label: endUser, value: endUser }]; }), ); }, [data]); diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 9a33a6bd758..fc08864f19f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2772,40 +2772,6 @@ 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; @@ -7219,6 +7185,40 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/spend_logs/end_users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Spend Log End Users + * @description The distinct end users appearing in spend logs over a time window, for the logs + * page filter dropdown. + * + * 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). + * + * The window is required and the inner scan is capped at SPEND_LOGS_FACET_SCAN_CAP + * rows, so the query cannot degrade into a full-table scan the way + * `/global/all_end_users` does. + * + * Example curl: + * ``` + * curl --location --globoff 'http://0.0.0.0:4000/management/v1/spend_logs/end_users?filter[startTime][gte]=2026-07-23T00:00:00Z&filter[startTime][lte]=2026-07-24T00:00:00Z&page_size=50&q=acme' --header 'Authorization: Bearer sk-1234' + * ``` + */ + get: operations["list_spend_log_end_users_management_v1_spend_logs_end_users_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/mcp-rest/test/connection": { parameters: { query?: never; @@ -23329,29 +23329,6 @@ 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. @@ -23893,6 +23870,16 @@ export interface components { /** Updated At */ updated_at?: number | null; }; + /** + * FacetListResponse + * @description The distinct values one column takes over a filtered query. `data` holds bare values, not entity rows. + */ + FacetListResponse: { + /** Data */ + data: string[]; + links: components["schemas"]["PageLinks"]; + meta: components["schemas"]["PageMeta"]; + }; /** * FailedKeyUpdate * @description Failed key update with reason @@ -28852,6 +28839,30 @@ export interface components { /** Tpm Limit */ tpm_limit?: number | null; }; + /** + * PageLinks + * @description Hypermedia for a paginated list. No `first`/`last`: without a total count the last page is unknown. + */ + PageLinks: { + /** Next */ + next?: string | null; + /** Prev */ + prev?: string | null; + /** Self */ + self: string; + }; + /** + * PageMeta + * @description `has_more` rather than `total_count`, which would need a COUNT(*) over the whole match set per keystroke. + */ + PageMeta: { + /** Has More */ + has_more: boolean; + /** Page */ + page: number; + /** Page Size */ + page_size: number; + }; /** * PaginatedAuditLogResponse * @description Response model for paginated audit logs @@ -38457,46 +38468,6 @@ 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; @@ -43424,6 +43395,46 @@ export interface operations { }; }; }; + list_spend_log_end_users_management_v1_spend_logs_end_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 end 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;