diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py
index ee90ffbee79..1c49ad51beb 100644
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -2254,6 +2254,10 @@ async def ui_view_spend_logs(
status_filter: str | None = fastapi.Query(
default=None, description="Filter logs by status (e.g., success, failure)"
),
+ cache_hit_filter: str | None = fastapi.Query(
+ default=None,
+ description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state",
+ ),
model: str | None = fastapi.Query(default=None, description="Filter logs by model"),
model_id: str | None = fastapi.Query(
default=None,
@@ -2330,6 +2334,13 @@ async def ui_view_spend_logs(
param="sort_order",
code=status.HTTP_400_BAD_REQUEST,
)
+ if isinstance(cache_hit_filter, str) and cache_hit_filter not in {"hit", "miss"}:
+ raise ProxyException(
+ message=f"Invalid cache_hit_filter: {cache_hit_filter}. Must be one of: hit, miss",
+ type="bad_request",
+ param="cache_hit_filter",
+ code=status.HTTP_400_BAD_REQUEST,
+ )
try:
is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
@@ -2570,6 +2581,11 @@ async def ui_view_spend_logs(
sql_params.append(status_filter)
p += 1
+ if cache_hit_filter == "hit":
+ sql_conditions.append("LOWER(cache_hit) = 'true'")
+ elif cache_hit_filter == "miss":
+ sql_conditions.append("(cache_hit IS NULL OR LOWER(cache_hit) != 'true')")
+
if exclude_internal_health_checks:
sql_conditions.append(f"api_key NOT IN (${p}, ${p + 1})")
sql_params.extend(_INTERNAL_HEALTH_CHECK_API_KEYS)
diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
index a378d99d049..19ceb3d3d1f 100644
--- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
@@ -106,6 +106,10 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
where["OR"] = where.get("OR", []) + [{"multi_team": True}]
elif "status = 'success'" in cond:
where["OR"] = where.get("OR", []) + [{"status": "success"}]
+ elif cond == "LOWER(cache_hit) = 'true'":
+ where["cache_hit"] = "hit"
+ elif cond == "(cache_hit IS NULL OR LOWER(cache_hit) != 'true')":
+ where["cache_hit"] = "miss"
elif sess:
where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")}
elif status:
@@ -2444,6 +2448,96 @@ async def test_ui_view_spend_logs_with_status(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
+@pytest.mark.asyncio
+async def test_ui_view_spend_logs_with_cache_hit_filter(client, monkeypatch):
+ base = {
+ "api_key": "sk-test-key",
+ "user": "test_user_1",
+ "team_id": "team1",
+ "spend": 0.05,
+ "startTime": datetime.datetime.now(timezone.utc).isoformat(),
+ "model": "gpt-4",
+ "status": "success",
+ }
+ mock_spend_logs = [
+ {**base, "id": "log1", "request_id": "req-hit", "cache_hit": "True"},
+ {**base, "id": "log2", "request_id": "req-miss", "cache_hit": "False"},
+ {**base, "id": "log3", "request_id": "req-legacy", "cache_hit": "None"},
+ {**base, "id": "log4", "request_id": "req-null", "cache_hit": None},
+ ]
+
+ def filter_by_cache(where):
+ cache_filter = where.get("cache_hit")
+ if cache_filter == "hit":
+ return [log for log in mock_spend_logs if str(log["cache_hit"]).lower() == "true"]
+ if cache_filter == "miss":
+ return [log for log in mock_spend_logs if str(log["cache_hit"]).lower() != "true"]
+ return mock_spend_logs
+
+ monkeypatch.setattr(
+ "litellm.proxy.proxy_server.prisma_client",
+ make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_cache),
+ )
+
+ start_date, end_date = _default_date_range()
+
+ app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN
+ )
+ try:
+ response = client.get(
+ "/spend/logs/ui",
+ params={
+ "cache_hit_filter": "hit",
+ "start_date": start_date,
+ "end_date": end_date,
+ },
+ headers={"Authorization": "Bearer sk-test"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] == 1
+ assert [row["request_id"] for row in data["data"]] == ["req-hit"]
+
+ response = client.get(
+ "/spend/logs/ui",
+ params={
+ "cache_hit_filter": "miss",
+ "start_date": start_date,
+ "end_date": end_date,
+ },
+ headers={"Authorization": "Bearer sk-test"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] == 3
+ assert [row["request_id"] for row in data["data"]] == ["req-miss", "req-legacy", "req-null"]
+
+ response = client.get(
+ "/spend/logs/ui",
+ params={
+ "start_date": start_date,
+ "end_date": end_date,
+ },
+ headers={"Authorization": "Bearer sk-test"},
+ )
+ assert response.status_code == 200
+ assert response.json()["total"] == 4
+
+ response = client.get(
+ "/spend/logs/ui",
+ params={
+ "cache_hit_filter": "invalid",
+ "start_date": start_date,
+ "end_date": end_date,
+ },
+ headers={"Authorization": "Bearer sk-test"},
+ )
+ assert response.status_code == 400
+ finally:
+ app.dependency_overrides.pop(ps.user_api_key_auth, None)
+
+
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_model(client, monkeypatch):
mock_spend_logs = [
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index c7868a5f039..032429ba8ed 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -2002,6 +2002,7 @@ interface UiSpendLogsParams {
user_id?: string;
end_user?: string;
status_filter?: string;
+ cache_hit_filter?: string;
/** Filter by model name (e.g. "gpt-4") */
model?: string;
/** Filter by model ID (litellm model deployment id) */
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx
index 893d6219e64..5d96f2637cd 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx
@@ -69,6 +69,7 @@ describe("RequestLogsFilters", () => {
for (const label of [
"Team ID",
"Status",
+ "Cache",
"Key Alias",
"User ID",
"End User",
@@ -259,4 +260,37 @@ describe("RequestLogsFilters", () => {
expect(await screen.findByText(label)).toBeInTheDocument();
});
+
+ it.each([
+ ["", "All Requests"],
+ ["hit", "Cache Hit"],
+ ["miss", "Cache Miss"],
+ ])("shows the human label on the Cache trigger for %s", async (cacheState, label) => {
+ renderFilters(cacheState === "" ? {} : { [LOG_FILTER_IDS.CACHE_STATUS]: cacheState });
+
+ expect(await screen.findByText(label)).toBeInTheDocument();
+ });
+
+ it.each([
+ ["Cache Hit", "hit"],
+ ["Cache Miss", "miss"],
+ ])("selecting %s sets the cache filter to %s", async (label, expected) => {
+ const user = userEvent.setup();
+ const { set } = renderFilters();
+
+ await user.click(await screen.findByText("All Requests"));
+ await user.click(await screen.findByRole("option", { name: label }));
+
+ expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, expected);
+ });
+
+ it("selecting All Requests clears the cache filter", async () => {
+ const user = userEvent.setup();
+ const { set } = renderFilters({ [LOG_FILTER_IDS.CACHE_STATUS]: "hit" });
+
+ await user.click(await screen.findByText("Cache Hit"));
+ await user.click(await screen.findByRole("option", { name: "All Requests" }));
+
+ expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, undefined);
+ });
});
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx
index af6a6d1f178..69257a6f52d 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx
@@ -31,6 +31,12 @@ const STATUS_FILTER_ITEMS = [
{ value: "success", label: "Success" },
{ value: "failure", label: "Failure" },
] as const;
+
+const CACHE_FILTER_ITEMS = [
+ { value: ALL_VALUE, label: "All Requests" },
+ { value: "hit", label: "Cache Hit" },
+ { value: "miss", label: "Cache Miss" },
+] as const;
const PAGE_SIZE = 50;
const asString = (value: unknown): string => (typeof value === "string" ? value : "");
@@ -328,6 +334,27 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
+
+
+
+
{
{ id: LOG_FILTER_IDS.SESSION_ID, value: "sess-1", param: "session_id" },
{ id: LOG_FILTER_IDS.END_USER, value: "end-user-1", param: "end_user" },
{ id: LOG_FILTER_IDS.STATUS, value: "failure", param: "status_filter" },
+ { id: LOG_FILTER_IDS.CACHE_STATUS, value: "hit", param: "cache_hit_filter" },
+ { id: LOG_FILTER_IDS.CACHE_STATUS, value: "miss", param: "cache_hit_filter" },
{ id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" },
{ id: LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" },
{ id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" },
diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
index 9b6666dc9ee..3b8d96596de 100644
--- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
@@ -20,6 +20,7 @@ export interface PaginatedResponse {
export const LOG_FILTER_IDS = {
TEAM_ID: "team_id",
STATUS: "status",
+ CACHE_STATUS: "cache_hit",
KEY_ALIAS: "key_alias",
END_USER: "end_user",
ERROR_CODE: "error_code",
@@ -35,6 +36,7 @@ export const LOG_FILTER_IDS = {
export const LOG_FILTER_LABELS: Record = {
[LOG_FILTER_IDS.TEAM_ID]: "Team ID",
[LOG_FILTER_IDS.STATUS]: "Status",
+ [LOG_FILTER_IDS.CACHE_STATUS]: "Cache",
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
[LOG_FILTER_IDS.USER_ID]: "User ID",
[LOG_FILTER_IDS.END_USER]: "End User",
@@ -170,6 +172,7 @@ export function useLogFilterLogic({
user_id: userIdFilter,
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
+ cache_hit_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.CACHE_STATUS),
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL),
key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS),
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 3afa111d65b..9ac49fa96e1 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -55030,6 +55030,8 @@ export interface operations {
page_size?: number;
/** @description Filter logs by status (e.g., success, failure) */
status_filter?: string | null;
+ /** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
+ cache_hit_filter?: string | null;
/** @description Filter logs by model */
model?: string | null;
/** @description Filter logs by model ID (litellm model deployment id) */
@@ -55140,6 +55142,8 @@ export interface operations {
page_size?: number;
/** @description Filter logs by status (e.g., success, failure) */
status_filter?: string | null;
+ /** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
+ cache_hit_filter?: string | null;
/** @description Filter logs by model */
model?: string | null;
/** @description Filter logs by model ID (litellm model deployment id) */