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) */