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

1"""`/management/v1/spend_logs` facets.""" 

2 

3from datetime import datetime, timezone 

4from typing import Annotated, Final, Literal 

5 

6from fastapi import APIRouter, Depends, Query, Request 

7 

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) 

25 

26router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX) 

27 

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 

32 

33 

34def _as_utc(value: datetime) -> datetime: 

35 return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) 

36 

37 

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. 

44 

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 ) 

53 

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

56 

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 = [] 

64 

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 

78 

79 

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 

92 

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 ) 

102 

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

107 

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 ) 

113 

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 ) 

124 

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 

149 

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 ) 

170 

171 

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. 

196 

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

200 

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. 

204 

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 ) 

221 

222 

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 )