Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/routes/allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@
"/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/",
Expand Down
9 changes: 5 additions & 4 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down Expand Up @@ -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).
Expand Down
177 changes: 4 additions & 173 deletions litellm/proxy/management_endpoints/customer_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@
"""

#### 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
Expand All @@ -28,27 +27,21 @@
_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 (
SpendAnalyticsPaginatedResponse,
)
from litellm.types.proxy.management_endpoints.customer_endpoints import (
BlockUsersResponse,
CustomerAliasesResponse,
CustomerResponse,
DeleteCustomersResponse,
UnblockUsersResponse,
)

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.
Expand Down Expand Up @@ -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"],
Expand Down
12 changes: 12 additions & 0 deletions litellm/proxy/management_endpoints/management_v1/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
77 changes: 77 additions & 0 deletions litellm/proxy/management_endpoints/management_v1/common.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading