diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 0c525ee9466..3f29efb4e80 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1660,6 +1660,10 @@ async def ui_view_spend_logs( model_group: str | None = fastapi.Query(default=None, description="Filter logs by model group"), key_alias: str | None = fastapi.Query(default=None, description="Filter logs by key alias"), end_user: str | None = fastapi.Query(default=None, description="Filter logs by end user"), + call_type: str | None = fastapi.Query(default=None, description="Filter logs by call type (e.g., acompletion)"), + cache_hit: bool | None = fastapi.Query( + default=None, description="Filter logs by response-cache outcome: true for cache hits, false for misses" + ), error_code: str | None = fastapi.Query(default=None, description="Filter logs by error code (e.g., '404', '500')"), error_message: str | None = fastapi.Query( default=None, description="Filter logs by error message (partial string match)" @@ -1796,6 +1800,9 @@ async def ui_view_spend_logs( if model_group is not None: where_conditions["model_group"] = model_group + if call_type is not None: + where_conditions["call_type"] = call_type + # Build metadata filters metadata_filters = [] if key_alias is not None: @@ -1917,6 +1924,7 @@ async def ui_view_spend_logs( ("model_id", "model_id"), ("model_group", "model_group"), ("end_user", "end_user"), + ("call_type", "call_type"), ]: val = where_conditions.get(wc_key) if val is not None and isinstance(val, str): @@ -1947,6 +1955,14 @@ async def ui_view_spend_logs( sql_params.append(status_filter) p += 1 + # Cache hit filter - only a literal 'True' is a hit; '', 'False', and + # NULL are all misses, so the miss side must be NULL-safe. + if cache_hit is not None: + if cache_hit: + sql_conditions.append("cache_hit = 'True'") + else: + sql_conditions.append("cache_hit IS DISTINCT FROM 'True'") + # Spend range if min_spend is not None: sql_conditions.append(f"spend >= ${p}") 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 67945436987..c853adeb318 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 @@ -90,6 +90,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): "model_id": "model_id", "model_group": "model_group", "end_user": "end_user", + "call_type": "call_type", } date_bounds: dict = {} metadata_conds: list = [] @@ -109,6 +110,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 == "cache_hit = 'True'": + where["cache_hit"] = {"is_hit": True} + elif cond == "cache_hit IS DISTINCT FROM 'True'": + where["cache_hit"] = {"is_hit": False} elif sess: where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")} elif status: @@ -613,6 +618,88 @@ async def test_ui_view_spend_logs_with_user_id(client, monkeypatch): assert data["data"][0]["user"] == "test_user_1" +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_call_type(client, monkeypatch): + now = datetime.datetime.now(timezone.utc).isoformat() + mock_spend_logs = [ + {"request_id": "req1", "call_type": "acompletion", "spend": 0.05, "startTime": now}, + {"request_id": "req2", "call_type": "anthropic_messages", "spend": 0.10, "startTime": now}, + ] + + def filter_by_call_type(where): + call_type = where.get("call_type") + if call_type is None: + return mock_spend_logs + return [log for log in mock_spend_logs if log["call_type"] == call_type] + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_call_type), + ) + + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={ + "call_type": "anthropic_messages", + "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 data["data"][0]["request_id"] == "req2" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "cache_hit_query,expected_request_ids", + [ + ("true", {"req-hit"}), + ("false", {"req-false", "req-empty", "req-null"}), + ], +) +async def test_ui_view_spend_logs_with_cache_hit(client, monkeypatch, cache_hit_query, expected_request_ids): + now = datetime.datetime.now(timezone.utc).isoformat() + mock_spend_logs = [ + {"request_id": "req-hit", "cache_hit": "True", "spend": 0.0, "startTime": now}, + {"request_id": "req-false", "cache_hit": "False", "spend": 0.05, "startTime": now}, + {"request_id": "req-empty", "cache_hit": "", "spend": 0.05, "startTime": now}, + {"request_id": "req-null", "cache_hit": None, "spend": 0.05, "startTime": now}, + ] + + def filter_by_cache_hit(where): + condition = where.get("cache_hit") + if condition is None: + return mock_spend_logs + if condition["is_hit"]: + return [log for log in mock_spend_logs if log["cache_hit"] == "True"] + return [log for log in mock_spend_logs if log["cache_hit"] != "True"] + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_cache_hit), + ) + + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={ + "cache_hit": cache_hit_query, + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert {log["request_id"] for log in data["data"]} == expected_request_ids + + @pytest.mark.asyncio @pytest.mark.parametrize( "session_id_query,expected_request_ids", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx index fd8dd011b05..cf6ee23311f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx @@ -1,8 +1,8 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { renderWithProviders } from "../../../../../tests/test-utils"; -import CacheDashboard from "./cache_dashboard"; +import CacheDashboard, { buildLogsDrilldownUrl } from "./cache_dashboard"; const { useCacheActivity, cachingHealthCheckCall } = vi.hoisted(() => ({ useCacheActivity: vi.fn(), @@ -11,6 +11,7 @@ const { useCacheActivity, cachingHealthCheckCall } = vi.hoisted(() => ({ vi.mock("@/components/networking", () => ({ cachingHealthCheckCall, + serverRootPath: "/", })); vi.mock("@/app/(dashboard)/hooks/caching/useCacheActivity", () => ({ @@ -198,3 +199,58 @@ describe("CacheDashboard cache analytics charts", () => { expect(compactTicks(tokensCard)).toContain("60K"); }); }); + +describe("cache chart drill-down into the Logs page", () => { + const range = { startDate: "2026-07-20", endDate: "2026-07-27" }; + + it("maps a failed-requests slice to a status=failure logs link scoped to the bar's call type", () => { + const url = buildLogsDrilldownUrl({ name: "anthropic_messages", categoryClicked: "Failed requests" }, range); + + expect(url).toBe( + "/ui/logs?call_type=anthropic_messages&status=failure&start_time=2026-07-20T00%3A00&end_time=2026-07-27T23%3A59", + ); + }); + + it("maps a cache-hit slice to cache_hit=true", () => { + const url = buildLogsDrilldownUrl({ name: "acompletion", categoryClicked: "Cache hit" }, range); + + expect(url).toContain("cache_hit=true"); + expect(url).not.toContain("status="); + }); + + it("maps an api-requests slice to successful cache misses", () => { + const url = buildLogsDrilldownUrl({ name: "acompletion", categoryClicked: "LLM API requests" }, range); + + expect(url).toContain("status=success"); + expect(url).toContain("cache_hit=false"); + }); + + it("omits call_type for the Unknown bucket since those rows have an empty call_type", () => { + const url = buildLogsDrilldownUrl({ name: "Unknown", categoryClicked: "Failed requests" }, range); + + expect(url).not.toContain("call_type"); + expect(url).toContain("status=failure"); + }); + + it("navigates to the logs page when a requests-chart bar is clicked", async () => { + useCacheActivity.mockReturnValue({ data: cacheActivity, refetch: vi.fn() }); + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign, search: "" }); + + renderWithProviders( + , + ); + await screen.findByText("Cache Hits vs API Requests"); + await waitFor(() => { + expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0); + }); + + const requestsCard = screen.getByText("Cache Hits vs API Requests").closest('[data-slot="card"]') as HTMLElement; + fireEvent.click(requestsCard.querySelector("path.recharts-rectangle")!); + + expect(assign).toHaveBeenCalledTimes(1); + expect(String(assign.mock.calls[0][0])).toContain("/logs?call_type=acompletion"); + + vi.unstubAllGlobals(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index 47c266ceac0..a49b1fdc33c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -21,6 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { RefreshCw } from "lucide-react"; import { cachingHealthCheckCall } from "@/components/networking"; import { useCacheActivity, type CacheActivityGroup } from "@/app/(dashboard)/hooks/caching/useCacheActivity"; +import { migratedHref } from "@/utils/migratedPages"; // Import the new component import { CacheHealthTab } from "./cache_health"; @@ -42,6 +43,31 @@ const toChartDatum = (group: CacheActivityGroup) => ({ "Generated Completion Tokens": group.generated_completion_tokens, }); +const UNKNOWN_CALL_TYPE = "Unknown"; + +export const buildLogsDrilldownUrl = ( + clicked: { name: string; categoryClicked: string }, + range: { startDate: string | undefined; endDate: string | undefined }, +): string => { + const params = new URLSearchParams(); + if (clicked.name !== UNKNOWN_CALL_TYPE) { + params.set("call_type", clicked.name); + } + if (clicked.categoryClicked === REQUEST_SERIES.failed) { + params.set("status", "failure"); + } + if (clicked.categoryClicked === REQUEST_SERIES.cacheHits) { + params.set("cache_hit", "true"); + } + if (clicked.categoryClicked === REQUEST_SERIES.apiRequests) { + params.set("status", "success"); + params.set("cache_hit", "false"); + } + if (range.startDate) params.set("start_time", `${range.startDate}T00:00`); + if (range.endDate) params.set("end_time", `${range.endDate}T23:59`); + return `${migratedHref("logs")}?${params.toString()}`; +}; + const formatDateWithoutTZ = (date: Date | undefined) => { if (!date) return undefined; return date.toISOString().split("T")[0]; @@ -307,6 +333,15 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole categories={[REQUEST_SERIES.apiRequests, REQUEST_SERIES.cacheHits, REQUEST_SERIES.failed]} colors={["sky", "teal", "red"]} yAxisWidth={48} + className="cursor-pointer" + onValueChange={(clicked) => + window.location.assign( + buildLogsDrilldownUrl(clicked, { + startDate: formatDateWithoutTZ(dateValue.from), + endDate: formatDateWithoutTZ(dateValue.to), + }), + ) + } /> diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 4abe3a6a154..69b76239787 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1924,6 +1924,8 @@ interface UiSpendLogsParams { key_alias?: string; error_code?: string; error_message?: string; + call_type?: string; + cache_hit?: "true" | "false"; sort_by?: string; sort_order?: "asc" | "desc"; min_spend?: number; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index 2005b868cd6..5533654fb79 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -320,6 +320,32 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF placeholder="Enter public model or search tool…" /> + + + set(LOG_FILTER_IDS.CALL_TYPE, emptyToUndefined(event.target.value))} + placeholder="e.g. acompletion…" + /> + + + + + ); } diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index b3b1e8c0640..460d9649f81 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -17,6 +17,7 @@ import { formatLogsWindow, getLogsWindowEndBound, LOG_FILTER_IDS, + readLogsUrlSeed, useLogFilterLogic, } from "./log_filter_logic"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; @@ -41,13 +42,18 @@ interface SessionComposition { } export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) { + const [urlSeed] = useState(() => readLogsUrlSeed(typeof window === "undefined" ? "" : window.location.search)); + const seededWindow = urlSeed.startTime !== null && urlSeed.endTime !== null; + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); const [sorting, setSorting] = useState(DEFAULT_LOGS_SORTING); - const [columnFilters, setColumnFilters] = useState([]); + const [columnFilters, setColumnFilters] = useState(urlSeed.columnFilters); - const [startTime, setStartTime] = useState(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); - const [endTime, setEndTime] = useState(moment().format("YYYY-MM-DDTHH:mm")); - const [isCustomDate, setIsCustomDate] = useState(false); + const [startTime, setStartTime] = useState( + urlSeed.startTime ?? moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"), + ); + const [endTime, setEndTime] = useState(urlSeed.endTime ?? moment().format("YYYY-MM-DDTHH:mm")); + const [isCustomDate, setIsCustomDate] = useState(seededWindow); const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>(DEFAULT_INTERVAL); const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null); @@ -56,6 +62,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const [selectedSessionId, setSelectedSessionId] = useState(null); const [isLiveTail, setIsLiveTail] = useState(() => { + if (seededWindow) return false; const storedValue = sessionStorage.getItem("isLiveTail"); return storedValue !== null ? JSON.parse(storedValue) : true; }); diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 080bf6b380a..c110e37c86c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -13,6 +13,7 @@ import { LIVE_TAIL_INTERVAL_MS, LOG_FILTER_IDS, LOGS_WINDOW_TICK_MS, + readLogsUrlSeed, useLogFilterLogic, type PaginatedResponse, } from "./log_filter_logic"; @@ -87,6 +88,8 @@ describe("useLogFilterLogic", () => { { id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" }, { id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" }, { id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" }, + { id: LOG_FILTER_IDS.CALL_TYPE, value: "anthropic_messages", param: "call_type" }, + { id: LOG_FILTER_IDS.CACHE_HIT, value: "true", param: "cache_hit" }, ]; it.each(cases)("sends $id as $param", async ({ id, value, param }) => { @@ -110,6 +113,13 @@ describe("useLogFilterLogic", () => { expect(params?.api_key).toBeUndefined(); expect(params?.error_code).toBeUndefined(); }); + + it("drops a cache_hit filter value that is not true or false", async () => { + renderFilterHook({ columnFilters: [{ id: LOG_FILTER_IDS.CACHE_HIT, value: "maybe" }] }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + expect(lastCallParams()?.params?.cache_hit).toBeUndefined(); + }); }); describe("paging, dates, and sort", () => { @@ -294,3 +304,33 @@ describe("formatLogsWindow preset end bound", () => { ); }); }); + +describe("readLogsUrlSeed", () => { + it("seeds column filters from known filter params and ignores unrelated ones", () => { + const seed = readLogsUrlSeed("?status=failure&call_type=anthropic_messages&cache_hit=true&page_size=50&foo=bar"); + + expect(seed.columnFilters).toEqual( + expect.arrayContaining([ + { id: LOG_FILTER_IDS.STATUS, value: "failure" }, + { id: LOG_FILTER_IDS.CALL_TYPE, value: "anthropic_messages" }, + { id: LOG_FILTER_IDS.CACHE_HIT, value: "true" }, + ]), + ); + expect(seed.columnFilters).toHaveLength(3); + }); + + it("captures the seeded time window", () => { + const seed = readLogsUrlSeed("?start_time=2026-07-20T00:00&end_time=2026-07-27T23:59"); + + expect(seed.startTime).toBe("2026-07-20T00:00"); + expect(seed.endTime).toBe("2026-07-27T23:59"); + }); + + it("returns an empty seed for an empty query string", () => { + expect(readLogsUrlSeed("")).toEqual({ columnFilters: [], startTime: null, endTime: null }); + }); + + it("skips blank filter values", () => { + expect(readLogsUrlSeed("?status=&call_type=%20%20").columnFilters).toEqual([]); + }); +}); 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 8244066cadb..d0a964b1298 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 @@ -29,6 +29,8 @@ export const LOG_FILTER_IDS = { PUBLIC_MODEL_OR_SEARCH_TOOL: "model", REQUEST_ID: "request_id", USER_ID: "user_id", + CALL_TYPE: "call_type", + CACHE_HIT: "cache_hit", } as const; export const LOG_FILTER_LABELS: Record = { @@ -42,6 +44,32 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.SESSION_ID]: "Session ID", [LOG_FILTER_IDS.MODEL_ID]: "Model", [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", + [LOG_FILTER_IDS.CALL_TYPE]: "Call Type", + [LOG_FILTER_IDS.CACHE_HIT]: "Cache Hit", +}; + +export interface LogsUrlSeed { + columnFilters: ColumnFiltersState; + startTime: string | null; + endTime: string | null; +} + +export const readLogsUrlSeed = (search: string): LogsUrlSeed => { + const params = new URLSearchParams(search); + const columnFilters = Object.values(LOG_FILTER_IDS).flatMap((id) => { + const value = params.get(id)?.trim(); + return value ? [{ id, value }] : []; + }); + return { + columnFilters, + startTime: params.get("start_time"), + endTime: params.get("end_time"), + }; +}; + +const parseCacheHitFilter = (value: string | undefined): "true" | "false" | undefined => { + if (value === "true" || value === "false") return value; + return undefined; }; export interface LogsWindow { @@ -173,6 +201,8 @@ export function useLogFilterLogic({ key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS), error_code: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_CODE), error_message: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_MESSAGE), + call_type: getFilterValue(columnFilters, LOG_FILTER_IDS.CALL_TYPE), + cache_hit: parseCacheHitFilter(getFilterValue(columnFilters, LOG_FILTER_IDS.CACHE_HIT)), sort_by: sortBy, sort_order: sortOrder, }, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 80ea068cb5e..ceab5a3e9af 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -49546,6 +49546,10 @@ export interface operations { key_alias?: string | null; /** @description Filter logs by end user */ end_user?: string | null; + /** @description Filter logs by call type (e.g., acompletion) */ + call_type?: string | null; + /** @description Filter logs by response-cache outcome: true for cache hits, false for misses */ + cache_hit?: boolean | null; /** @description Filter logs by error code (e.g., '404', '500') */ error_code?: string | null; /** @description Filter logs by error message (partial string match) */ @@ -49654,6 +49658,10 @@ export interface operations { key_alias?: string | null; /** @description Filter logs by end user */ end_user?: string | null; + /** @description Filter logs by call type (e.g., acompletion) */ + call_type?: string | null; + /** @description Filter logs by response-cache outcome: true for cache hits, false for misses */ + cache_hit?: boolean | null; /** @description Filter logs by error code (e.g., '404', '500') */ error_code?: string | null; /** @description Filter logs by error message (partial string match) */