mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
12b039242c
commit
8f83fb7764
19 changed files with 1133 additions and 636 deletions
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
12
litellm/proxy/management_endpoints/management_v1/__init__.py
Normal file
12
litellm/proxy/management_endpoints/management_v1/__init__.py
Normal 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"]
|
||||
77
litellm/proxy/management_endpoints/management_v1/common.py
Normal file
77
litellm/proxy/management_endpoints/management_v1/common.py
Normal 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,
|
||||
)
|
||||
203
litellm/proxy/management_endpoints/management_v1/spend_logs.py
Normal file
203
litellm/proxy/management_endpoints/management_v1/spend_logs.py
Normal 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.",
|
||||
)
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
39
litellm/types/proxy/management_endpoints/management_v1.py
Normal file
39
litellm/types/proxy/management_endpoints/management_v1.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
205
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
205
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue