Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/management_v1/spend_logs.py: 73%
58 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""`/management/v1/spend_logs` facets."""
3from datetime import datetime, timezone
4from typing import Annotated, Final, Literal
6from fastapi import APIRouter, Depends, Query, Request
8from litellm._logging import verbose_proxy_logger
9from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
10from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
11from litellm.proxy.list_api.common import (
12 PROBLEM_TYPE_BASE,
13 ManagementProblem,
14 build_page_links,
15 escape_like,
16 reject_unknown_query_params,
17)
18from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
19from litellm.proxy.utils import PrismaClient
20from litellm.types.proxy.management_endpoints.management_v1 import (
21 FacetListResponse,
22 PageMeta,
23 ProblemDetail,
24)
26router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
28# Rows the facet query may read out of LiteLLM_SpendLogs before DISTINCT. Matches
29# SPEND_LOGS_PAGINATION_COUNT_CAP, the bound ui_view_spend_logs puts on its count
30# query, so both reads of the same table stop at the same depth.
31SPEND_LOGS_FACET_SCAN_CAP: Final = 10000
34def _as_utc(value: datetime) -> datetime:
35 return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
38async def _spend_log_scope_clause(
39 user_api_key_dict: UserAPIKeyAuth,
40 prisma_client: PrismaClient,
41 next_param_index: int,
42) -> tuple[str | None, tuple[str | list[str], ...]]:
43 """SQL predicate restricting the facet to spend logs this caller may read.
45 Returns ``(None, ())`` for a proxy admin. Mirrors the scoping ``/spend/logs/ui``
46 applies, so a dropdown can never offer a value from a row the caller could
47 not open.
48 """
49 from litellm.proxy.spend_tracking.spend_management_endpoints import (
50 _get_permitted_team_ids_for_spend_logs,
51 _is_admin_view_safe,
52 )
54 if _is_admin_view_safe(user_api_key_dict=user_api_key_dict): 54 ↛ 57line 54 didn't jump to line 57 because the condition on line 54 was always true
55 return None, ()
57 try:
58 permitted_team_ids = await _get_permitted_team_ids_for_spend_logs(
59 prisma_client=prisma_client,
60 user_api_key_dict=user_api_key_dict,
61 )
62 except Exception:
63 permitted_team_ids = []
65 caller_user_id: Final = user_api_key_dict.user_id
66 # = ANY(::text[]) rather than an expanded IN list, matching the clause
67 # ui_view_spend_logs builds: one parameter whatever the team count.
68 templates: Final = (('"user" = ${}',) if caller_user_id is not None else ()) + (
69 ("team_id = ANY(${}::text[])",) if permitted_team_ids else ()
70 )
71 params: Final = ((caller_user_id,) if caller_user_id is not None else ()) + (
72 (permitted_team_ids,) if permitted_team_ids else ()
73 )
74 if not templates:
75 return "FALSE", ()
76 clauses: Final = tuple(template.format(next_param_index + offset) for offset, template in enumerate(templates))
77 return f"({' OR '.join(clauses)})", params
80async def _list_spend_log_facet(
81 request: Request,
82 user_api_key_dict: UserAPIKeyAuth,
83 start_time: datetime,
84 end_time: datetime,
85 q: str | None,
86 page: int,
87 page_size: int,
88 column: Literal["end_user", "user"],
89) -> FacetListResponse:
90 try:
91 from litellm.proxy.proxy_server import prisma_client
93 if prisma_client is None: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 raise ManagementProblem(
95 ProblemDetail(
96 type=f"{PROBLEM_TYPE_BASE}database-not-connected",
97 title="Database not connected",
98 status=503,
99 detail=CommonProxyErrors.db_not_connected_error.value,
100 )
101 )
103 column_sql: Final = "end_user" if column == "end_user" else '"user"'
104 window_params: Final[tuple[datetime, datetime]] = (_as_utc(start_time), _as_utc(end_time))
105 search_params: Final[tuple[str, ...]] = (f"%{escape_like(q)}%",) if q else ()
106 search_clause: Final = (f"{column_sql} ILIKE ${len(window_params) + 1} ESCAPE '\\'",) if q else ()
108 scope_clause, scope_params = await _spend_log_scope_clause(
109 user_api_key_dict=user_api_key_dict,
110 prisma_client=prisma_client,
111 next_param_index=len(window_params) + len(search_params) + 1,
112 )
114 where_parts: Final = (
115 (
116 "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')",
117 "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')",
118 f"{column_sql} IS NOT NULL",
119 f"{column_sql} != ''",
120 )
121 + search_clause
122 + ((scope_clause,) if scope_clause is not None else ())
123 )
125 # The inner LIMIT walks the startTime index newest first and bounds the
126 # rows DISTINCT can inspect. request_id makes the cut-off deterministic,
127 # and page_size + 1 reveals has_more without a COUNT(*).
128 params: Final = (
129 window_params
130 + search_params
131 + scope_params
132 + (SPEND_LOGS_FACET_SCAN_CAP, page_size + 1, (page - 1) * page_size)
133 )
134 scan_idx: Final = len(params) - 2
135 facet_sql: Final = (
136 f"SELECT DISTINCT {column_sql} FROM ("
137 f" SELECT {column_sql}"
138 f' FROM "LiteLLM_SpendLogs"'
139 f" WHERE {' AND '.join(where_parts)}"
140 f' ORDER BY "startTime" DESC, request_id DESC'
141 f" LIMIT ${scan_idx}"
142 f") recent"
143 f" ORDER BY {column_sql} ASC"
144 f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}"
145 )
146 rows: Final = await prisma_client.db.query_raw(facet_sql, *params)
147 values: Final[list[str]] = [row[column] for row in rows if row.get(column)]
148 has_more: Final = len(values) > page_size
150 return FacetListResponse(
151 data=values[:page_size],
152 meta=PageMeta(page=page, page_size=page_size, has_more=has_more),
153 links=build_page_links(request=request, page=page, has_more=has_more),
154 )
155 except ManagementProblem:
156 raise
157 except Exception as e:
158 verbose_proxy_logger.exception(
159 "litellm.proxy.management_endpoints.management_v1.spend_logs._list_spend_log_facet(): Exception occured - %s",
160 e,
161 )
162 raise ManagementProblem(
163 ProblemDetail(
164 type=f"{PROBLEM_TYPE_BASE}internal-server-error",
165 title="Internal server error",
166 status=500,
167 detail=f"Failed to list spend log {column.replace('_', ' ')}s.",
168 )
169 )
172@router.get(
173 "/spend_logs/end_users",
174 tags=["Budget & Spend Tracking"],
175 dependencies=[Depends(user_api_key_auth), Depends(reject_unknown_query_params)],
176 response_model=FacetListResponse,
177)
178async def list_spend_log_end_users(
179 request: Request,
180 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
181 start_time: Annotated[
182 datetime,
183 Query(alias="filter[startTime][gte]", description="Window start (UTC when no offset is given)"),
184 ],
185 end_time: Annotated[
186 datetime,
187 Query(alias="filter[startTime][lte]", description="Window end (UTC when no offset is given)"),
188 ],
189 q: Annotated[str | None, Query(description="Case-insensitive partial match on the end user id")] = None,
190 page: Annotated[int, Query(ge=1, description="Page number")] = 1,
191 page_size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50,
192) -> FacetListResponse:
193 """
194 The distinct end users appearing in spend logs over a time window, for the logs
195 page filter dropdown.
197 Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window,
198 anyone else sees only end users from their own requests or from teams they
199 administer (or hold the `/spend/logs` permission on).
201 The window is required and the inner scan is capped at SPEND_LOGS_FACET_SCAN_CAP
202 rows, so the query cannot degrade into a full-table scan the way
203 `/global/all_end_users` does.
205 Example curl:
206 ```
207 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' \
208 --header 'Authorization: Bearer sk-1234'
209 ```
210 """
211 return await _list_spend_log_facet(
212 request=request,
213 user_api_key_dict=user_api_key_dict,
214 start_time=start_time,
215 end_time=end_time,
216 q=q,
217 page=page,
218 page_size=page_size,
219 column="end_user",
220 )
223@router.get(
224 "/spend_logs/users",
225 tags=["Budget & Spend Tracking"],
226 dependencies=[Depends(user_api_key_auth), Depends(reject_unknown_query_params)],
227 response_model=FacetListResponse,
228)
229async def list_spend_log_users(
230 request: Request,
231 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
232 start_time: Annotated[
233 datetime,
234 Query(alias="filter[startTime][gte]", description="Window start (UTC when no offset is given)"),
235 ],
236 end_time: Annotated[
237 datetime,
238 Query(alias="filter[startTime][lte]", description="Window end (UTC when no offset is given)"),
239 ],
240 q: Annotated[str | None, Query(description="Case-insensitive partial match on the internal user id")] = None,
241 page: Annotated[int, Query(ge=1, description="Page number")] = 1,
242 page_size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50,
243) -> FacetListResponse:
244 """The distinct internal users appearing in spend logs the caller can read."""
245 return await _list_spend_log_facet(
246 request=request,
247 user_api_key_dict=user_api_key_dict,
248 start_time=start_time,
249 end_time=end_time,
250 q=q,
251 page=page,
252 page_size=page_size,
253 column="user",
254 )