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/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx
index 4e2456ab7a4..f9a75813bda 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx
@@ -110,6 +110,28 @@ describe("MCPDiscovery", () => {
expect(await screen.findByText(/No servers found/)).toBeInTheDocument();
});
+ it("keeps the wide dialog width the antd modal had", async () => {
+ render();
+ await screen.findByText("GitHub");
+
+ const dialog = document.querySelector("[data-slot='dialog-content']");
+ const width = Array.from(dialog?.classList ?? []).filter((c) => c.includes("max-w-"));
+
+ expect(width).toContain("sm:max-w-[1000px]");
+ expect(width).not.toContain("sm:max-w-md");
+ });
+
+ // The close button is absolutely positioned, so it is out of flow and the header
+ // row lays out as if it were not there. Without a reserved margin the custom-server
+ // action sits underneath it. jsdom has no layout engine, so this pins the class.
+ it("keeps the custom-server action clear of the close button", async () => {
+ render();
+ await screen.findByText("GitHub");
+
+ expect(document.querySelector("[data-slot='dialog-close']")).toHaveClass("absolute");
+ expect(screen.getByRole("button", { name: "+ Custom Server" })).toHaveClass("mr-8");
+ });
+
it("does not fetch while hidden", () => {
render();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx
index b2c181a9112..5094f1a6761 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx
@@ -103,14 +103,14 @@ const MCPDiscovery: React.FC = ({
return (