Merge pull request #34691 from BerriAI/litellm_/management-endpoint-standards-b1cd57

refactor(management): move the logs end-user filter onto /management/v1

(cherry picked from commit 2b7e01bb7e)
This commit is contained in:
yuneng-jiang 2026-07-27 11:30:51 -07:00 committed by Yuneng Jiang
parent 12b039242c
commit 8f83fb7764
No known key found for this signature in database
19 changed files with 1133 additions and 636 deletions

View file

@ -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/",

View file

@ -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).

View file

@ -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"],

View file

@ -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"]

View file

@ -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,
)

View file

@ -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.",
)
)

View file

@ -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)

View file

@ -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]

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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);
};

View file

@ -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();
});
});

View file

@ -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);
};

View file

@ -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<typeof useInfiniteModelInfo>,
);
vi.mocked(useInfiniteEndUserAliases).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteEndUserAliases>,
vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteSpendLogEndUsers>,
);
});
@ -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<typeof useInfiniteEndUserAliases>);
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<typeof useInfiniteSpendLogEndUsers>);
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<typeof useInfiniteEndUserAliases>);
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<typeof useInfiniteSpendLogEndUsers>);
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(<RequestLogsFilters get={() => undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />);
await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(otherWindow, 50, undefined));
await waitFor(() => expect(useInfiniteSpendLogEndUsers).toHaveBeenCalledWith(otherWindow, 50, undefined));
});
});

View file

@ -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<SearchSelectOption[]>(() => {
const seen = new Set<string>();
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]);

View file

@ -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;