Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/prompt_caching_requests.py: 89%
61 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
1from collections.abc import Callable, Mapping
2from datetime import datetime, timezone
3from types import MappingProxyType
4from typing import TYPE_CHECKING, Annotated, Final
6from fastapi import APIRouter, Depends, HTTPException, Query
7from pydantic import BaseModel, Json, TypeAdapter
9from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth, user_api_key_has_admin_view
10from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
11from litellm.proxy.spend_tracking.savings import (
12 extract_cache_creation_tokens,
13 extract_cache_read_tokens,
14 marks_gateway_injection,
15 prompt_caching_savings_for_request,
16)
17from litellm.proxy.spend_tracking.spend_tracking_utils import (
18 _query_raw_rows, # pyright: ignore[reportPrivateUsage] # existing typed spend-query adapter; rows validated below
19)
20from litellm.types.integrations.anthropic_cache_control_hook import GATEWAY_INJECTED_CACHE_METADATA_KEY
21from litellm.types.management_endpoints.prompt_caching_requests import (
22 PromptCachingRequest,
23 PromptCachingRequestCursor,
24 PromptCachingRequestFilter,
25 PromptCachingRequestsResponse,
26)
28if TYPE_CHECKING: 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 from litellm.router import Router
31router: Final = APIRouter()
34def _numeric_token_sql(path: str) -> str:
35 value: Final = f"metadata #> '{{usage_object,{path}}}'"
36 return (
37 f"CASE WHEN jsonb_typeof({value}) = 'number' THEN ({value} #>> '{{}}')::numeric "
38 f"WHEN {value} = 'true'::jsonb THEN 1 WHEN {value} = 'false'::jsonb THEN 0 END"
39 )
42def _cache_tokens_sql(*paths: str) -> str:
43 candidates: Final = ", ".join(f"NULLIF(({_numeric_token_sql(path)}), 0)" for path in paths)
44 return f"TRUNC(COALESCE({candidates}, 0))"
47_CACHE_READ_SQL: Final = _cache_tokens_sql("cache_read_input_tokens", "prompt_tokens_details,cached_tokens")
48_CACHE_CREATION_SQL: Final = _cache_tokens_sql(
49 "cache_creation_input_tokens",
50 "prompt_tokens_details,cache_write_tokens",
51 "prompt_tokens_details,cache_creation_tokens",
52)
53_GATEWAY_INJECTED_SQL: Final = (
54 f"(jsonb_typeof(metadata->'{GATEWAY_INJECTED_CACHE_METADATA_KEY}') = 'string' "
55 f"AND (metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' = '' "
56 f"OR metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' = model_id))"
57)
58_FILTER_SQL: Final = MappingProxyType(
59 {
60 "all": f"({_GATEWAY_INJECTED_SQL} OR {_CACHE_READ_SQL} > 0 OR {_CACHE_CREATION_SQL} > 0)",
61 "injected": _GATEWAY_INJECTED_SQL,
62 "hits": f"{_CACHE_READ_SQL} > 0",
63 }
64)
67def prompt_caching_requests_sql(filter: PromptCachingRequestFilter) -> str:
68 return f"""
69 SELECT request_id, "startTime" AS start_time, "endTime" AS end_time,
70 model, model_id, custom_llm_provider, spend,
71 CASE WHEN jsonb_typeof(metadata->'usage_object') = 'object'
72 THEN metadata->'usage_object' END AS usage_object,
73 CASE WHEN jsonb_typeof(metadata->'cost_breakdown') = 'object'
74 THEN metadata->'cost_breakdown' END AS cost_breakdown,
75 CASE WHEN jsonb_typeof(metadata->'{GATEWAY_INJECTED_CACHE_METADATA_KEY}') = 'string'
76 THEN metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' END AS gateway_marker
77 FROM "LiteLLM_SpendLogs"
78 WHERE "startTime" >= ($1::text::timestamptz AT TIME ZONE 'UTC')
79 AND "startTime" <= ($2::text::timestamptz AT TIME ZONE 'UTC')
80 AND COALESCE(LOWER(cache_hit), 'false') != 'true'
81 AND {_FILTER_SQL[filter]}
82 AND ($4::text::timestamptz IS NULL OR
83 ("startTime", request_id) < (($4::text::timestamptz AT TIME ZONE 'UTC'), $5::text))
84 ORDER BY "startTime" DESC, request_id DESC
85 LIMIT $3::integer
86 """
89class _PromptCachingRow(BaseModel):
90 request_id: str
91 start_time: datetime
92 end_time: datetime
93 model: str
94 model_id: str | None
95 custom_llm_provider: str | None
96 spend: float
97 usage_object: Json[Mapping[str, object]] | Mapping[str, object] | None
98 cost_breakdown: Json[Mapping[str, object]] | Mapping[str, object] | None
99 gateway_marker: str | None
102_REQUEST_ROWS: Final = TypeAdapter(tuple[_PromptCachingRow, ...])
105def _request_result(row: _PromptCachingRow, llm_router: "Callable[[], Router | None]") -> PromptCachingRequest:
106 return PromptCachingRequest(
107 request_id=row.request_id,
108 start_time=row.start_time.replace(tzinfo=timezone.utc) if row.start_time.tzinfo is None else row.start_time,
109 model=row.model,
110 gateway_injected=marks_gateway_injection(
111 MappingProxyType({GATEWAY_INJECTED_CACHE_METADATA_KEY: row.gateway_marker}), row.model_id
112 ),
113 cache_read_tokens=extract_cache_read_tokens(row.usage_object),
114 cache_creation_tokens=extract_cache_creation_tokens(row.usage_object),
115 spend=row.spend,
116 net_savings=prompt_caching_savings_for_request(
117 model=row.model,
118 custom_llm_provider=row.custom_llm_provider,
119 usage_object=row.usage_object,
120 model_id=row.model_id,
121 llm_router=llm_router,
122 cost_breakdown=row.cost_breakdown,
123 billed_at=row.end_time,
124 ),
125 )
128@router.get(
129 "/cost_optimization/prompt_caching/requests",
130 tags=["Cost Optimization"], # mutable-ok: FastAPI's route API requires a list
131 response_model=PromptCachingRequestsResponse,
132)
133async def get_prompt_caching_requests(
134 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
135 start_date: datetime,
136 end_date: datetime,
137 page_size: Annotated[int, Query(ge=1, le=100)] = 50,
138 filter: PromptCachingRequestFilter = "all",
139 cursor_start_time: datetime | None = None,
140 cursor_request_id: Annotated[str | None, Query(min_length=1)] = None,
141) -> PromptCachingRequestsResponse:
142 from litellm.proxy.proxy_server import llm_router, prisma_client
144 if not user_api_key_has_admin_view(user_api_key_dict): 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 raise HTTPException(status_code=403, detail="Only proxy admin roles can view prompt caching requests")
146 if (cursor_start_time is None) != (cursor_request_id is None):
147 raise HTTPException(status_code=400, detail="cursor_start_time and cursor_request_id must be provided together")
148 if prisma_client is None: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
150 start: Final = start_date.replace(tzinfo=timezone.utc) if start_date.tzinfo is None else start_date
151 end: Final = end_date.replace(tzinfo=timezone.utc) if end_date.tzinfo is None else end_date
152 if end < start:
153 raise HTTPException(status_code=400, detail="end_date must not be earlier than start_date")
154 cursor_time: Final = (
155 cursor_start_time.replace(tzinfo=timezone.utc)
156 if cursor_start_time is not None and cursor_start_time.tzinfo is None
157 else cursor_start_time
158 )
159 rows: Final = _REQUEST_ROWS.validate_python(
160 await _query_raw_rows(
161 prisma_client,
162 prompt_caching_requests_sql(filter),
163 start.isoformat(),
164 end.isoformat(),
165 page_size + 1,
166 cursor_time.isoformat() if cursor_time is not None else None,
167 cursor_request_id,
168 )
169 or ()
170 )
172 def current_router() -> "Router | None":
173 return llm_router
175 requests: Final = tuple(_request_result(row, current_router) for row in rows[:page_size])
176 has_more: Final = len(rows) > page_size
177 return PromptCachingRequestsResponse(
178 requests=requests,
179 page_size=page_size,
180 has_more=has_more,
181 next_cursor=PromptCachingRequestCursor(start_time=requests[-1].start_time, request_id=requests[-1].request_id)
182 if has_more
183 else None,
184 )