diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 55b50e7d9ff..0c525ee9466 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1695,13 +1695,10 @@ async def ui_view_spend_logs( code=status.HTTP_401_UNAUTHORIZED, ) - if start_date is None or end_date is None: - raise ProxyException( - message="Start date and end date are required", - type="bad_request", - param="None", - code=status.HTTP_400_BAD_REQUEST, - ) + # Inline import — auth_utils participates in a proxy import cycle. + from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 + + is_v2 = "/spend/logs/v2" in get_request_route(request) # Validate sort_by and sort_order valid_sort_fields = { @@ -1729,36 +1726,50 @@ async def ui_view_spend_logs( ) try: - # Inline import — auth_utils participates in a proxy import cycle. - from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 + is_admin_view = _is_admin_view_safe(user_api_key_dict=user_api_key_dict) + is_request_id_lookup = request_id is not None and not is_v2 - is_v2 = "/spend/logs/v2" in get_request_route(request) - formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"] + if is_request_id_lookup: + # request_id is the @id primary key: it identifies a single row, so a + # time window is meaningless. The dashboard always sends a default 24h + # window, which hid ids copied from an older page (LIT-3981). Drop the + # window for the id lookup so it resolves across all time; every other + # query, including the public v2 route, still requires one (below). + start_date_obj: datetime | None = None + end_date_obj: datetime | None = None + else: + if start_date is None or end_date is None: + raise ProxyException( + message="Start date and end date are required", + type="bad_request", + param="None", + code=status.HTTP_400_BAD_REQUEST, + ) + formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"] - def parse_date(date_str: str) -> datetime: - date_str = date_str.strip() - for fmt in formats: - try: - return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc) - except ValueError: - continue - expected = "'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'" if is_v2 else "'YYYY-MM-DD HH:MM:SS'" - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid date format: {date_str}. Expected: {expected}", - ) + def parse_date(date_str: str) -> datetime: + date_str = date_str.strip() + for fmt in formats: + try: + return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + expected = "'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'" if is_v2 else "'YYYY-MM-DD HH:MM:SS'" + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid date format: {date_str}. Expected: {expected}", + ) - start_date_obj = parse_date(start_date) - end_date_obj = parse_date(end_date) - - # Convert to ISO format strings for Prisma - start_date_iso = start_date_obj.isoformat() # Already in UTC, no need to add Z - end_date_iso = end_date_obj.isoformat() # Already in UTC, no need to add Z + start_date_obj = parse_date(start_date) + end_date_obj = parse_date(end_date) # Build where conditions - where_conditions: dict[str, Any] = { - "startTime": {"gte": start_date_iso, "lte": end_date_iso}, - } + where_conditions: dict[str, Any] = {} + if start_date_obj is not None and end_date_obj is not None: + where_conditions["startTime"] = { + "gte": start_date_obj.isoformat(), # Already in UTC, no need to add Z + "lte": end_date_obj.isoformat(), + } if team_id is not None: where_conditions["team_id"] = team_id @@ -1827,9 +1838,19 @@ async def ui_view_spend_logs( where_conditions["spend"]["gte"] = min_spend if max_spend is not None: where_conditions["spend"]["lte"] = max_spend - is_admin_view = _is_admin_view_safe(user_api_key_dict=user_api_key_dict) + # A request_id lookup drops the date window, so a non-admin could otherwise + # reach any single row by id; require they own it, mirroring the detail + # endpoint. That ownership check fully authorizes the one row, so the + # general scoping below is skipped for id lookups. Scoped to the UI route + # so the public v2 contract is unchanged. + if request_id is not None and not is_v2 and not is_admin_view: + await _assert_user_can_view_request_id( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + request_id=request_id, + ) permitted_team_ids: List[str] | None = None - if not is_admin_view: + if not is_request_id_lookup and not is_admin_view: if team_id is not None: can_view_team = await _can_team_member_view_log( prisma_client=prisma_client, @@ -1875,15 +1896,16 @@ async def ui_view_spend_logs( sql_params: List[Any] = [] p = 1 # parameter index counter - # Date range (always present). Wrap the param side with - # `AT TIME ZONE 'UTC'` so comparison against the plain `timestamp` - # column does not depend on the DB session timezone (see #22529). - sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')") - sql_params.append(start_date_obj) - p += 1 - sql_conditions.append(f"\"startTime\" <= (${p}::timestamptz AT TIME ZONE 'UTC')") - sql_params.append(end_date_obj) - p += 1 + # Date range. Wrap the param side with `AT TIME ZONE 'UTC'` so comparison + # against the plain `timestamp` column does not depend on the DB session + # timezone (see #22529). Absent for a request_id-only lookup (see above). + if start_date_obj is not None and end_date_obj is not None: + sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')") + sql_params.append(start_date_obj) + p += 1 + sql_conditions.append(f"\"startTime\" <= (${p}::timestamptz AT TIME ZONE 'UTC')") + sql_params.append(end_date_obj) + p += 1 # Equality filters - read effective values from where_conditions (post-authorization) for sql_col, wc_key in [ 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 db72a7fb38c..67945436987 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 @@ -1628,6 +1628,226 @@ async def test_ui_view_spend_logs_date_range_filter(client, monkeypatch): assert data["data"][0]["id"] == "log2" +@pytest.mark.asyncio +async def test_ui_view_spend_logs_request_id_lookup_ignores_date_window( + client, monkeypatch +): + """ + LIT-3981: a request_id lookup on the UI route resolves across all time even + when the caller sends a date window that excludes the log (the dashboard + always sends a window). The window is dropped and request_id alone scopes + the query. Pre-fix the window was always applied, so an id from an older + page returned nothing. + """ + today = datetime.datetime.now(timezone.utc) + mock_spend_logs = [ + { + "id": "log_old", + "request_id": "req-old", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": (today - datetime.timedelta(days=90)).isoformat(), + "model": "gpt-4", + }, + ] + + captured: dict = {} + + def filter_fn(where): + captured["where"] = where + rows = _filter_logs_by_date_range(mock_spend_logs, where) + if where.get("request_id"): + rows = [r for r in rows if r["request_id"] == where["request_id"]] + return rows + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn), + ) + + # A 5-day window that EXCLUDES the 90-day-old log, as the dashboard sends. + start_date = (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S") + end_date = today.strftime("%Y-%m-%d %H:%M:%S") + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get( + "/spend/logs/ui", + params={ + "request_id": "req-old", + "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"] == "req-old" + # Query dropped the time window and scoped solely by the primary key. + assert "startTime" not in captured["where"] + assert captured["where"]["request_id"] == "req-old" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_requires_dates_without_request_id( + client, monkeypatch +): + """The date window stays mandatory on the UI route when no request_id is set.""" + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma([], lambda where: []), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get( + "/spend/logs/ui", headers={"Authorization": "Bearer sk-test"} + ) + assert response.status_code == 400 + assert "date" in response.text.lower() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_spend_logs_v2_still_requires_dates_with_request_id(client, monkeypatch): + """The public /spend/logs/v2 contract is unchanged: dates remain required even + when request_id is supplied. Only the internal UI route relaxes the window.""" + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma([], lambda where: []), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get( + "/spend/logs/v2", + params={"request_id": "req-old"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 400 + assert "date" in response.text.lower() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_request_id_blocks_non_owner(client, monkeypatch): + """A non-admin looking up a request_id they do not own is rejected (403), so + the relaxed date window cannot read another tenant's log by id.""" + + class _ForeignRow: + user = "other_user" + team_id = None + + class _SpendLogs: + async def find_unique(self, where, include=None): + return _ForeignRow() + + class _DB: + def __init__(self): + self.litellm_spendlogs = _SpendLogs() + + class _Prisma: + def __init__(self): + self.db = _DB() + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _Prisma()) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1" + ) + try: + response = client.get( + "/spend/logs/ui", + params={"request_id": "foreign-req"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only( + client, monkeypatch +): + """A non-admin owner looking up their own request_id resolves across all time. + The ownership check authorizes the single row, so the query drops both the date + window and the general user/team scoping and filters by the primary key alone; + without that skip an internal user would have a `user`/`OR` clause added.""" + today = datetime.datetime.now(timezone.utc) + mock_spend_logs = [ + { + "id": "log_old", + "request_id": "req-old", + "api_key": "sk-test-key", + "user": "user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": (today - datetime.timedelta(days=90)).isoformat(), + "model": "gpt-4", + }, + ] + + captured: dict = {} + + def filter_fn(where): + captured["where"] = where + rows = _filter_logs_by_date_range(mock_spend_logs, where) + if where.get("request_id"): + rows = [r for r in rows if r["request_id"] == where["request_id"]] + return rows + + mock_prisma = make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn) + + class _OwnedRow: + user = "user_1" + team_id = "team1" + + async def _find_unique(where, include=None): + return _OwnedRow() + + mock_prisma.db.find_unique = _find_unique + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + # A 5-day window that EXCLUDES the 90-day-old log, as the dashboard sends. + start_date = (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S") + end_date = today.strftime("%Y-%m-%d %H:%M:%S") + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1" + ) + try: + response = client.get( + "/spend/logs/ui", + params={ + "request_id": "req-old", + "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"] == "req-old" + assert "startTime" not in captured["where"] + assert captured["where"]["request_id"] == "req-old" + assert "user" not in captured["where"] + assert "OR" not in captured["where"] + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_unauthorized(client): # Test without authorization header diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index 9469c273d20..e8e2fae3f4d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -121,23 +121,21 @@ describe("RequestLogsPanel", () => { }); }); - describe("client-side search", () => { - it("narrows the visible rows without refetching", async () => { + describe("search by request id (LIT-3981)", () => { + it("sends the typed request id to the server on the first page instead of filtering the loaded rows", async () => { const user = userEvent.setup(); - respondWith([ - logEntry({ request_id: "req-alpha", model: "gpt-4o" }), - logEntry({ request_id: "req-beta", model: "claude-opus" }), - ]); renderWithProviders(); - await waitFor(() => expect(row("req-alpha")).not.toBeNull()); - const callsBefore = vi.mocked(uiSpendLogsCall).mock.calls.length; + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); - await user.type(screen.getByTestId("datatable-search"), "alpha"); + await user.type(screen.getByTestId("datatable-search"), "req-on-another-page"); - await waitFor(() => expect(row("req-beta")).toBeNull()); - expect(row("req-alpha")).not.toBeNull(); - expect(vi.mocked(uiSpendLogsCall).mock.calls.length).toBe(callsBefore); + await waitFor(() => { + const call = lastCall(); + if (!call) throw new Error("uiSpendLogsCall was not called"); + expect(call.params?.request_id).toBe("req-on-another-page"); + expect(call.page).toBe(1); + }); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 05044dda791..b319b201dc7 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -11,7 +11,7 @@ import { keyInfoV1Call } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import type { LogEntry } from "./columns"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; -import { DEFAULT_LOGS_SORTING, useLogFilterLogic } from "./log_filter_logic"; +import { DEFAULT_LOGS_SORTING, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar"; import { RequestLogsTable } from "./RequestLogsTable"; @@ -34,7 +34,6 @@ interface SessionComposition { } export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) { - const [searchTerm, setSearchTerm] = useState(""); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); const [sorting, setSorting] = useState(DEFAULT_LOGS_SORTING); const [columnFilters, setColumnFilters] = useState([]); @@ -93,14 +92,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const { data: selectedKeyInfo } = useQuery(keyInfoQueryOptions); const rows = useMemo(() => { - const searchedLogs = filteredLogs.data.filter((log) => { - if (!searchTerm) return true; - return ( - log.request_id.includes(searchTerm) || - log.model.includes(searchTerm) || - (log.user !== undefined && log.user.includes(searchTerm)) - ); - }); + const searchedLogs = filteredLogs.data; const sessionCompositionById = searchedLogs.reduce>((acc, log) => { if (!log.session_id) return acc; @@ -141,7 +133,20 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, if (!log.session_id || (log.session_total_count || 1) <= 1) return true; return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id; }); - }, [filteredLogs.data, searchTerm]); + }, [filteredLogs.data]); + + const searchTerm = useMemo(() => { + const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.REQUEST_ID); + return typeof entry?.value === "string" ? entry.value : ""; + }, [columnFilters]); + + const handleSearchChange = useCallback((value: string) => { + setColumnFilters((previous) => { + const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.REQUEST_ID); + return value === "" ? others : [...others, { id: LOG_FILTER_IDS.REQUEST_ID, value }]; + }); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }, []); const handleSortingChange = useCallback>((updaterOrValue) => { setSorting(updaterOrValue); @@ -159,7 +164,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const handleResetFilters = useCallback(() => { setColumnFilters([]); - setSearchTerm(""); setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); setEndTime(moment().format("YYYY-MM-DDTHH:mm")); setIsCustomDate(false); @@ -221,7 +225,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, columnFilters={columnFilters} onColumnFiltersChange={handleColumnFiltersChange} searchValue={searchTerm} - onSearchChange={setSearchTerm} + onSearchChange={handleSearchChange} onRefresh={() => void logsQuery.refetch()} onRowClick={handleRowClick} onKeyHashClick={handleKeyHashClick}