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}