From cb78491482be002f3361d4d04165b5486a0ea743 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 25 Jul 2026 23:57:25 -0700 Subject: [PATCH 1/4] refactor(management): move the logs end-user filter onto /management/v1 `/customer/aliases` shipped two days ago and has not been in a release, so its wire contract is still free to change. This lands it on the control-plane contract before that stops being true, since after a release the path, the param names and the envelope would all need a permanent legacy adapter The endpoint becomes `GET /management/v1/spend_logs/end_users`. It is a facet, the distinct values one column takes over a filtered query on a resource, not an entity collection; naming it after `customers` implied it listed the end-user table when it actually reads spend logs, which is a different row set. Serving it under the parent resource means its filters are the parent's filters, so the dropdown offers exactly the values the logs table can show without two endpoints having to keep agreeing on that Contract changes: `size` becomes `page_size`, `search` becomes `q`, the window moves from flat `start_date` / `end_date` to `filter[startTime][gte]` / `[lte]`, and the body becomes `{data, meta, links}`. Unknown query params are now a 400 rather than being silently dropped, because an ignored filter over-returns data. Errors are RFC 9457 problem documents on this prefix only; every other route keeps the shape its callers already parse `links` is what makes the rest deferrable. The dashboard hook follows the server's `links.next` instead of computing `page + 1`, so moving this to cursor pagination later changes the links and nothing the client does. That matters because the inner scan is a sliding window, so offset paging can currently skip or repeat an end user across pages; the fix is a follow-up, and the hypermedia means it will not be a breaking one Cursor mode, `sort`, `include`, ETag / `If-None-Match` and the generic `ListSpec` framework are all deliberately out of scope here. They are additive or internal, so none of them needs to beat the release --- litellm/proxy/_types.py | 9 +- .../customer_endpoints.py | 177 +------- .../management_v1/__init__.py | 12 + .../management_v1/common.py | 74 +++ .../management_v1/spend_logs.py | 203 +++++++++ litellm/proxy/proxy_server.py | 30 ++ .../customer_endpoints.py | 19 - .../management_endpoints/management_v1.py | 39 ++ .../management_v1/test_spend_logs.py | 420 ++++++++++++++++++ .../test_customer_endpoints.py | 290 ------------ .../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 +++++---- 16 files changed, 1046 insertions(+), 626 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/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..f4b6ad1ac11 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/common.py @@ -0,0 +1,74 @@ +"""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" +PROBLEM_TYPE_BASE = "https://docs.litellm.ai/errors/" + + +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 a20b557e38b..b49e413ae7a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -391,6 +391,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, ) @@ -1437,8 +1447,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, @@ -16302,6 +16331,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/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..e6eb3d25a38 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py @@ -0,0 +1,420 @@ +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_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/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; From cf127e16e84d65de2587ddf47298bf83ecf43696 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sun, 26 Jul 2026 00:16:51 -0700 Subject: [PATCH 2/4] fix(management): stop emitting a dead docs link in problem documents The RFC 9457 `type` was `https://docs.litellm.ai/errors/`, copied from the standard's own error example. That path is a 404 and there is no docs section behind it, so every error body shipped a broken link RFC 9457 only requires `type` to identify the problem type; it encourages, but does not require, that dereferencing it yield documentation. An https URI makes a promise we are not keeping, so use `urn:litellm:error:` instead, which carries the same machine-readable identity with nothing to resolve. Switching to an https base later is a contract change for anyone matching on `type`, so that should wait for pages that actually exist A test pins the identifier against regressing to an https docs URL, since the existing assertion built the expected value from the same constant and would have stayed green whatever it held --- .../management_endpoints/management_v1/common.py | 5 ++++- .../management_v1/test_spend_logs.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/management_v1/common.py b/litellm/proxy/management_endpoints/management_v1/common.py index f4b6ad1ac11..c0e7f49f2e9 100644 --- a/litellm/proxy/management_endpoints/management_v1/common.py +++ b/litellm/proxy/management_endpoints/management_v1/common.py @@ -13,7 +13,10 @@ from litellm.types.proxy.management_endpoints.management_v1 import ( MANAGEMENT_V1_PREFIX = "/management/v1" PROBLEM_CONTENT_TYPE = "application/problem+json" -PROBLEM_TYPE_BASE = "https://docs.litellm.ai/errors/" +# 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): diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py index e6eb3d25a38..79f13a6f703 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_spend_logs.py @@ -224,6 +224,19 @@ def test_rejects_a_malformed_window_as_a_problem_document(mock_prisma_client, as 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, []) From b7a351623234e34f18cf2cd9e05b4b550a1e32dd Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 27 Jul 2026 09:28:32 -0700 Subject: [PATCH 3/4] fix(management): cover the new control plane route in CI's two guards Both failures are from this branch, not pre-existing The component allowlist test asserts the gateway and backend route sets union to the whole app, so any route on neither is a 404 on both pods. Allowlist the `/management/v1/` prefix on the backend, next to the other control plane entries, so every resource that moves under it later is covered without a per-resource edit The otel handler test builds its request as a SimpleNamespace carrying only `state`. The validation handler now reads `request.url.path` to decide whether the caller is on a surface with its own error contract, so the fake needs a url; a real Request always has one, which is why the handler does not guard for it The control plane branch returns early, and nothing covered that it still closes the dangling SERVER span first, so those requests would have leaked a span apiece. Added a case that pins it; removing the close call fails it --- backend/routes/allowlist.py | 4 ++ .../test_otel_exception_handler.py | 37 +++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) 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/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", From c3edf2402b53c60ebfe590e1630f09fd6d90d5c6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 27 Jul 2026 09:59:47 -0700 Subject: [PATCH 4/4] test(proxy): pin both branches of the validation exception handler Same cause as the otel handler test: this file builds its request as a SimpleNamespace carrying only `state`, and the validation handler now reads `request.url.path` to pick an error contract, so the fake needs a url While here, cover what the two existing tests do not. They only exercise the proxy-wide 422, and the control plane's 400 problem document was reachable only through the route test, which registers its own copy of the handler in a local app rather than the real one. Two cases now pin the real handler directly: a `/management/v1` path returns problem+json with a `detail` string, and paths that merely resemble the prefix (`/management`, `/v1/management/foo`) keep the 422 shape their callers parse --- .../proxy_server/test_exception_handlers.py | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) 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 # ---------------------------------------------------------------------------