Merge pull request #34579 from BerriAI/litellm_/litellm-logs-ui-lag-0ca4b8

fix(logs): scope and bound the End User filter on the logs page
This commit is contained in:
yuneng-jiang 2026-07-25 09:07:23 -07:00 • committed by GitHub
commit a66bac3adf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 822 additions and 45 deletions

View file

@ -629,6 +629,10 @@ class LiteLLMRoutes(enum.Enum):
"/spend/logs/v2",
"/spend/logs/ui",
"/spend/logs/session/ui",
# Reads end users out of spend logs, scoped to the caller's own rows and
# permitted teams exactly like /spend/logs/ui — it belongs to the same
# access tier, not to customer management.
"/customer/aliases",
"/cost/estimate",
]
@ -818,6 +822,7 @@ class LiteLLMRoutes(enum.Enum):
# Customer / end-user listing (handlers already gate on
# PROXY_ADMIN_VIEW_ONLY — the route gate must match).
"/customer/list",
"/customer/aliases",
"/customer/info",
# UI Logs page detail drawer (single + session). The list endpoint
# `/spend/logs/ui` is covered via spend_tracking_routes below.

View file

@ -10,11 +10,12 @@ All /customer management endpoints
"""
#### END-USER/CUSTOMER MANAGEMENT ####
from datetime import datetime, timedelta
from typing import List, Optional
from collections.abc import MutableSequence
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, List, Optional
import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
import litellm
@ -27,7 +28,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
_set_object_permission,
handle_update_object_permission_common,
)
from litellm.proxy.utils import handle_exception_on_proxy
from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.table_repositories import EndUserRepository
from litellm.types.proxy.management_endpoints.common_daily_activity import (
@ -35,6 +36,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
)
from litellm.types.proxy.management_endpoints.customer_endpoints import (
BlockUsersResponse,
CustomerAliasesResponse,
CustomerResponse,
DeleteCustomersResponse,
UnblockUsersResponse,
@ -42,6 +44,11 @@ from litellm.types.proxy.management_endpoints.customer_endpoints import (
router = APIRouter()
# Rows the end-user filter query may read out of LiteLLM_SpendLogs before DISTINCT.
# Matches SPEND_LOGS_PAGINATION_COUNT_CAP, the equivalent bound ui_view_spend_logs
# puts on its count query, so both reads of the same table stop at the same depth.
SPEND_LOGS_FILTER_SCAN_CAP = 10000
def _to_customer_response(record: BaseModel) -> CustomerResponse:
"""Validate a raw end-user DB row into the typed customer response.
@ -785,6 +792,168 @@ async def list_end_user(
raise handle_exception_on_proxy(e)
def _parse_spend_log_window_bound(value: str, param: str) -> datetime:
try:
return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": f"Invalid {param}: {value}. Expected 'YYYY-MM-DD HH:MM:SS'"},
)
async def _build_end_user_scope_condition(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
query_params: MutableSequence[Any],
) -> str | None:
"""SQL predicate restricting end users to the logs this caller may read.
Returns None when the caller is a proxy admin (no restriction). Mirrors the
scoping ``/spend/logs/ui`` applies, so the dropdown can never offer an
end user whose rows the caller could not open.
"""
from litellm.proxy.spend_tracking.spend_management_endpoints import (
_get_permitted_team_ids_for_spend_logs,
_is_admin_view_safe,
)
if _is_admin_view_safe(user_api_key_dict=user_api_key_dict):
return None
try:
permitted_team_ids = await _get_permitted_team_ids_for_spend_logs(
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
)
except Exception:
permitted_team_ids = []
caller_user_id = user_api_key_dict.user_id
user_clause: tuple[str, ...] = ()
if caller_user_id is not None:
query_params.append(caller_user_id)
user_clause = (f'"user" = ${len(query_params)}',)
team_clause: tuple[str, ...] = ()
if permitted_team_ids:
# = ANY(::text[]) rather than an expanded IN list, matching the clause
# ui_view_spend_logs builds: one parameter whatever the team count.
query_params.append(permitted_team_ids)
team_clause = (f"team_id = ANY(${len(query_params)}::text[])",)
scope_parts = user_clause + team_clause
if not scope_parts:
return "FALSE"
return f"({' OR '.join(scope_parts)})"
@router.get(
"/customer/aliases",
tags=["Customer Management"],
dependencies=[Depends(user_api_key_auth)],
response_model=CustomerAliasesResponse,
)
async def list_customer_aliases(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
start_date: Annotated[str, Query(description="Window start, 'YYYY-MM-DD HH:MM:SS' (UTC)")],
end_date: Annotated[str, Query(description="Window end, 'YYYY-MM-DD HH:MM:SS' (UTC)")],
page: Annotated[int, Query(ge=1, description="Page number")] = 1,
size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50,
search: Annotated[
str | None,
Query(description="Case-insensitive partial match on the customer id"),
] = None,
) -> CustomerAliasesResponse:
"""
List the end users seen in spend logs over a time window, for UI filter dropdowns.
Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window,
anyone else sees only end users from their own requests or from teams they
administer (or hold the `/spend/logs` permission on).
Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry
the team attribution this scoping needs. The window is required and the inner
scan is capped at SPEND_LOGS_FILTER_SCAN_CAP rows, so the query
cannot degrade into a full-table scan the way `/global/all_end_users` does.
Example curl:
```
curl --location 'http://0.0.0.0:4000/customer/aliases?start_date=2026-07-23%2000:00:00&end_date=2026-07-24%2000:00:00&size=50&search=acme' \
--header 'Authorization: Bearer sk-1234'
```
"""
try:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=400,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
start_dt = _parse_spend_log_window_bound(start_date, "start_date")
end_dt = _parse_spend_log_window_bound(end_date, "end_date")
query_params: List[Any] = [start_dt, end_dt]
where_parts = [
"\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')",
"\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')",
"end_user IS NOT NULL",
"end_user != ''",
]
if search:
# Escape LIKE metacharacters so a literal '_' or '%' matches itself.
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query_params.append(f"%{escaped}%")
where_parts.append(f"end_user ILIKE ${len(query_params)} ESCAPE '\\'")
scope_condition = await _build_end_user_scope_condition(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
query_params=query_params,
)
if scope_condition is not None:
where_parts.append(scope_condition)
# The inner LIMIT is the safety bound: it walks the startTime index newest
# first and stops, so DISTINCT never runs over an unbounded row set.
# request_id breaks startTime ties so the cut-off row is deterministic and
# successive OFFSET pages agree on the set they are paging through; the
# (startTime, request_id) index means the tiebreaker costs nothing.
# size + 1: one row beyond the page reveals has_more without a COUNT(*).
params = query_params + [SPEND_LOGS_FILTER_SCAN_CAP, size + 1, (page - 1) * size]
scan_idx = len(params) - 2
aliases_sql = (
f"SELECT DISTINCT end_user FROM ("
f" SELECT end_user"
f' FROM "LiteLLM_SpendLogs"'
f" WHERE {' AND '.join(where_parts)}"
f' ORDER BY "startTime" DESC, request_id DESC'
f" LIMIT ${scan_idx}"
f") recent"
f" ORDER BY end_user ASC"
f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}"
)
rows = await prisma_client.db.query_raw(aliases_sql, *params)
aliases: List[str] = [row["end_user"] for row in rows if row.get("end_user")]
return CustomerAliasesResponse(
aliases=aliases[:size],
current_page=page,
size=size,
has_more=len(aliases) > size,
)
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.customer_endpoints.list_customer_aliases(): "
"Exception occured - {}".format(str(e))
)
raise handle_exception_on_proxy(e)
@router.get(
"/customer/daily/activity",
tags=["Customer Management"],

View file

@ -17,6 +17,25 @@ class CustomerResponse(LiteLLM_EndUserTable):
litellm_budget_table: Optional[LiteLLM_BudgetTableFull] = None # pyright: ignore
class CustomerAliasesResponse(BaseModel):
"""Paginated, id-only customer listing used by UI filter dropdowns.
Deliberately excludes budget/object-permission relations so a proxy with a
large LiteLLM_EndUserTable can back a search-as-you-type control without
materializing every row (see /customer/list for the full objects).
Reports ``has_more`` rather than a total count on purpose: a total requires
COUNT(*) over the whole match set on every keystroke, which is the exact
cost this endpoint exists to avoid. Fetching one row beyond the page is
enough to drive an infinite-scroll dropdown.
"""
aliases: List[str]
current_page: int
size: int
has_more: bool
class BlockUsersResponse(BaseModel):
blocked_users: List[LiteLLM_EndUserTable]

View file

@ -1,3 +1,4 @@
from datetime import datetime, timezone
from typing import List
from unittest.mock import AsyncMock, MagicMock, patch
@ -9,6 +10,7 @@ from fastapi.testclient import TestClient
from litellm.proxy._types import (
LiteLLM_EndUserTable,
LiteLLMRoutes,
LitellmUserRoles,
ProxyException,
)
@ -782,3 +784,291 @@ def test_char_delete_body(mock_prisma_client, mock_user_api_key_auth):
"deleted_customers": 2,
"message": "Successfully deleted customers with ids: ['c1', 'c2']",
}
WINDOW = "start_date=2026-07-23+00%3A00%3A00&end_date=2026-07-24+00%3A00%3A00"
def _mock_alias_rows(mock_prisma_client, end_users: List[str]) -> AsyncMock:
query_raw = AsyncMock(return_value=[{"end_user": eu} for eu in end_users])
mock_prisma_client.db.query_raw = query_raw
return query_raw
def _as_role(role: LitellmUserRoles, user_id: str = "u1"):
"""Override auth for one request; returns a context-manager-free setter/teardown pair."""
original = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id=user_id, user_role=role)
return original
def test_customer_aliases_reads_spend_logs_not_the_end_user_table(mock_prisma_client, mock_user_api_key_auth):
"""Team scoping only exists in spend logs, so that is the source of truth."""
query_raw = _mock_alias_rows(mock_prisma_client, ["a", "b"])
response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
assert response.status_code == 200
assert response.json() == {"aliases": ["a", "b"], "current_page": 1, "size": 50, "has_more": False}
sql = query_raw.call_args.args[0]
assert '"LiteLLM_SpendLogs"' in sql
assert "LiteLLM_EndUserTable" not in sql
mock_prisma_client.db.litellm_endusertable.find_many.assert_not_called()
def test_customer_aliases_caps_the_rows_it_scans(mock_prisma_client, mock_user_api_key_auth):
"""The inner LIMIT is the crash guard: DISTINCT must never see an unbounded set."""
from litellm.proxy.management_endpoints.customer_endpoints import SPEND_LOGS_FILTER_SCAN_CAP
query_raw = _mock_alias_rows(mock_prisma_client, [])
client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
sql = query_raw.call_args.args[0]
inner = sql[sql.index("FROM (") : sql.index(") recent")]
assert "LIMIT $3" in inner
assert query_raw.call_args.args[3] == SPEND_LOGS_FILTER_SCAN_CAP
assert 'ORDER BY "startTime" DESC' in inner
def test_spend_logs_filter_scan_cap_matches_the_logs_page_bound():
"""Pin the cap's value, not just that it is passed through.
Asserting the param equals the constant is tautological: raising the constant
to a billion keeps that assertion green while removing the bound entirely.
The documented rationale is that both reads of LiteLLM_SpendLogs stop at the
same depth, so tie it to the count cap ui_view_spend_logs already uses.
"""
from litellm.proxy.management_endpoints.customer_endpoints import SPEND_LOGS_FILTER_SCAN_CAP
from litellm.proxy.spend_tracking.spend_management_endpoints import (
SPEND_LOGS_PAGINATION_COUNT_CAP,
)
assert SPEND_LOGS_FILTER_SCAN_CAP == SPEND_LOGS_PAGINATION_COUNT_CAP
def test_customer_aliases_breaks_start_time_ties_deterministically(mock_prisma_client, mock_user_api_key_auth):
"""Without a unique tiebreaker the capped scan can cut differently per request,
so OFFSET page 2 would page through a different set than page 1 did."""
query_raw = _mock_alias_rows(mock_prisma_client, [])
client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
sql = query_raw.call_args.args[0]
assert 'ORDER BY "startTime" DESC, request_id DESC' in sql
def test_customer_aliases_requires_a_time_window(mock_prisma_client, mock_user_api_key_auth):
"""No window means no index bound, which is the unbounded scan we must not allow."""
_mock_alias_rows(mock_prisma_client, [])
assert client.get("/customer/aliases", headers={"Authorization": "Bearer k"}).status_code == 422
assert (
client.get(
"/customer/aliases?start_date=2026-07-23+00%3A00%3A00", headers={"Authorization": "Bearer k"}
).status_code
== 422
)
def test_customer_aliases_bounds_the_window_on_the_indexed_start_time(mock_prisma_client, mock_user_api_key_auth):
query_raw = _mock_alias_rows(mock_prisma_client, [])
client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
sql = query_raw.call_args.args[0]
assert "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')" in sql
assert "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')" in sql
assert query_raw.call_args.args[1] == datetime(2026, 7, 23, tzinfo=timezone.utc)
assert query_raw.call_args.args[2] == datetime(2026, 7, 24, tzinfo=timezone.utc)
def test_customer_aliases_rejects_a_malformed_window(mock_prisma_client, mock_user_api_key_auth):
_mock_alias_rows(mock_prisma_client, [])
response = client.get(
f"/customer/aliases?start_date=yesterday&end_date=2026-07-24+00%3A00%3A00",
headers={"Authorization": "Bearer k"},
)
assert response.status_code == 400
def test_customer_aliases_applies_no_scope_for_a_proxy_admin(mock_prisma_client, mock_user_api_key_auth):
query_raw = _mock_alias_rows(mock_prisma_client, [])
client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
sql = query_raw.call_args.args[0]
assert '"user" =' not in sql
assert "team_id" not in sql
@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY])
def test_customer_aliases_scopes_a_team_admin_to_their_own_rows_and_teams(mock_prisma_client, role):
"""A team admin must not see end users belonging to teams they cannot read."""
query_raw = _mock_alias_rows(mock_prisma_client, ["cust-a"])
original = _as_role(role, user_id="team-admin-1")
try:
with patch(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
new=AsyncMock(return_value=["team-a", "team-b"]),
):
response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
finally:
app.dependency_overrides = original
assert response.status_code == 200
sql = query_raw.call_args.args[0]
# Same clause shape ui_view_spend_logs builds, so the two cannot diverge.
assert '("user" = $3 OR team_id = ANY($4::text[]))' in sql
assert query_raw.call_args.args[3] == "team-admin-1"
assert query_raw.call_args.args[4] == ["team-a", "team-b"]
def test_customer_aliases_scopes_a_teamless_user_to_their_own_rows(mock_prisma_client):
query_raw = _mock_alias_rows(mock_prisma_client, [])
original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="solo")
try:
with patch(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
new=AsyncMock(return_value=[]),
):
response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
finally:
app.dependency_overrides = original
assert response.status_code == 200
sql = query_raw.call_args.args[0]
assert '("user" = $3)' in sql
assert "team_id" not in sql
assert query_raw.call_args.args[3] == "solo"
def test_customer_aliases_returns_nothing_when_the_caller_owns_no_scope(mock_prisma_client):
"""Unidentifiable caller must match no rows, never fall through to unscoped."""
query_raw = _mock_alias_rows(mock_prisma_client, [])
original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id=None)
try:
with patch(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
new=AsyncMock(return_value=[]),
):
response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
finally:
app.dependency_overrides = original
assert response.status_code == 200
assert "FALSE" in query_raw.call_args.args[0]
def test_customer_aliases_scopes_when_permitted_team_lookup_fails(mock_prisma_client):
"""A failed team lookup must degrade to own-rows-only, never to unscoped."""
query_raw = _mock_alias_rows(mock_prisma_client, [])
original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="solo")
try:
with patch(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
new=AsyncMock(side_effect=RuntimeError("db down")),
):
response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"})
finally:
app.dependency_overrides = original
assert response.status_code == 200
sql = query_raw.call_args.args[0]
assert '("user" = $3)' in sql
assert "team_id" not in sql
def test_customer_aliases_fetches_one_extra_row_and_trims_it(mock_prisma_client, mock_user_api_key_auth):
query_raw = _mock_alias_rows(mock_prisma_client, [f"u{i}" for i in range(4)])
response = client.get(f"/customer/aliases?{WINDOW}&size=3", headers={"Authorization": "Bearer k"})
assert response.status_code == 200
assert response.json()["aliases"] == ["u0", "u1", "u2"]
assert response.json()["has_more"] is True
assert query_raw.call_args.args[4:] == (4, 0)
def test_customer_aliases_reports_no_more_pages_on_an_exactly_full_page(mock_prisma_client, mock_user_api_key_auth):
_mock_alias_rows(mock_prisma_client, ["u0", "u1", "u2"])
response = client.get(f"/customer/aliases?{WINDOW}&size=3", headers={"Authorization": "Bearer k"})
assert response.json()["aliases"] == ["u0", "u1", "u2"]
assert response.json()["has_more"] is False
def test_customer_aliases_offsets_by_page(mock_prisma_client, mock_user_api_key_auth):
query_raw = _mock_alias_rows(mock_prisma_client, [])
response = client.get(f"/customer/aliases?{WINDOW}&page=3&size=25", headers={"Authorization": "Bearer k"})
assert response.json()["current_page"] == 3
assert query_raw.call_args.args[4:] == (26, 50)
def test_customer_aliases_search_escapes_like_metacharacters(mock_prisma_client, mock_user_api_key_auth):
"""End-user ids routinely contain '_'; unescaped it is a wildcard."""
query_raw = _mock_alias_rows(mock_prisma_client, [])
client.get(f"/customer/aliases?{WINDOW}&search=device_id%25", headers={"Authorization": "Bearer k"})
assert "end_user ILIKE $3 ESCAPE" in query_raw.call_args.args[0]
assert query_raw.call_args.args[3] == r"%device\_id\%%"
def test_customer_aliases_search_placeholder_precedes_scan_limit_and_offset(mock_prisma_client, mock_user_api_key_auth):
query_raw = _mock_alias_rows(mock_prisma_client, [])
client.get(f"/customer/aliases?{WINDOW}&search=acme&size=10", headers={"Authorization": "Bearer k"})
sql = query_raw.call_args.args[0]
assert "LIMIT $4" in sql
assert "LIMIT $5 OFFSET $6" in sql
assert query_raw.call_args.args[3] == "%acme%"
assert query_raw.call_args.args[5:] == (11, 0)
def test_customer_aliases_caps_page_size(mock_prisma_client, mock_user_api_key_auth):
_mock_alias_rows(mock_prisma_client, [])
response = client.get(f"/customer/aliases?{WINDOW}&size=100000", headers={"Authorization": "Bearer k"})
assert response.status_code == 422
@pytest.mark.parametrize(
"role",
[
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
],
)
def test_customer_aliases_is_reachable_by_every_role_that_can_open_the_logs_page(role):
"""Route-level auth gate, which the dependency_overrides in the other tests bypass.
Handler-side team scoping is dead code if RouteChecks rejects the role first,
so pin that /customer/aliases travels in the same access tier as /spend/logs/ui.
"""
from litellm.proxy.auth.route_checks import RouteChecks
for allowed in (
LiteLLMRoutes.internal_user_routes.value,
LiteLLMRoutes.internal_user_view_only_routes.value,
):
assert ("/spend/logs/ui" in allowed) == ("/customer/aliases" in allowed)
if role in (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY):
allowed_routes = (
LiteLLMRoutes.internal_user_routes.value
if role == LitellmUserRoles.INTERNAL_USER
else LiteLLMRoutes.internal_user_view_only_routes.value
)
assert RouteChecks.check_route_access(route="/customer/aliases", allowed_routes=allowed_routes)
else:
assert "/customer/aliases" in LiteLLMRoutes.admin_viewer_routes.value

View file

@ -0,0 +1,22 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { $api } from "@/lib/http/api";
import type { components } from "@/lib/http/schema";
type EndUserAliasesPage = components["schemas"]["CustomerAliasesResponse"];
export interface EndUserAliasesWindow {
start_date: string;
end_date: string;
}
export const useInfiniteEndUserAliases = (window: EndUserAliasesWindow, size: number = 50, search?: string) => {
const { accessToken } = useAuthorized();
const query = { ...window, size, ...(search !== undefined && search !== "" ? { search } : {}) };
const options = {
pageParamName: "page",
initialPageParam: 1,
getNextPageParam: (lastPage: EndUserAliasesPage) => (lastPage.has_more ? lastPage.current_page + 1 : undefined),
enabled: Boolean(accessToken),
};
return $api.useInfiniteQuery("get", "/customer/aliases", { params: { query } }, options);
};

View file

@ -1,4 +1,5 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
@ -13,11 +14,11 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useInfiniteModelInfo: vi.fn(),
}));
vi.mock("../networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("../networking")>();
return { ...actual, allEndUsersCall: vi.fn().mockResolvedValue([]) };
});
vi.mock("@/app/(dashboard)/hooks/customers/useEndUserAliases", () => ({
useInfiniteEndUserAliases: vi.fn(),
}));
import { useInfiniteEndUserAliases } from "@/app/(dashboard)/hooks/customers/useEndUserAliases";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
@ -29,10 +30,12 @@ const emptyInfiniteQuery = {
isLoading: false,
};
const LOGS_WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" };
function renderFilters(filters: Record<string, string> = {}) {
const set = vi.fn();
renderWithProviders(
<RequestLogsFilters get={(id: string) => filters[id]} set={set} teams={[]} accessToken="test-token" />,
<RequestLogsFilters get={(id: string) => filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />,
);
return { set };
}
@ -47,6 +50,9 @@ describe("RequestLogsFilters", () => {
vi.mocked(useInfiniteModelInfo).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteModelInfo>,
);
vi.mocked(useInfiniteEndUserAliases).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteEndUserAliases>,
);
});
it("renders every backend-supported filter field", async () => {
@ -88,4 +94,64 @@ describe("RequestLogsFilters", () => {
await waitFor(() => expect(useInfiniteModelInfo).toHaveBeenCalled());
expect(useInfiniteModelInfo).toHaveBeenCalledWith(50, undefined);
});
it("asks the server for a bounded page of end users scoped to the visible time window", async () => {
renderFilters();
await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalled());
expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, undefined);
});
it("pushes the End User query to the server rather than filtering a preloaded list", async () => {
const user = userEvent.setup();
renderFilters();
const input = await screen.findByPlaceholderText("Search an end user");
await user.click(input);
await user.type(input, "acme");
await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, "acme"));
});
it("renders only the end users the current page returned", async () => {
vi.mocked(useInfiniteEndUserAliases).mockReturnValue({
...emptyInfiniteQuery,
data: { pages: [{ aliases: ["cust-a", "cust-b"], current_page: 1, size: 50, has_more: true }], pageParams: [1] },
} as unknown as ReturnType<typeof useInfiniteEndUserAliases>);
const user = userEvent.setup();
renderFilters();
await user.click(await screen.findByPlaceholderText("Search an end user"));
expect(await screen.findByText("cust-a")).toBeInTheDocument();
expect(screen.getByText("cust-b")).toBeInTheDocument();
});
it("loads the next page when the End User list is scrolled near the end", async () => {
const fetchNextPage = vi.fn();
vi.mocked(useInfiniteEndUserAliases).mockReturnValue({
...emptyInfiniteQuery,
fetchNextPage,
hasNextPage: true,
data: { pages: [{ aliases: ["cust-a"], current_page: 1, size: 50, has_more: true }], pageParams: [1] },
} as unknown as ReturnType<typeof useInfiniteEndUserAliases>);
const user = userEvent.setup();
renderFilters();
await user.click(await screen.findByPlaceholderText("Search an end user"));
const list = await screen.findByTestId("paginated-search-select-list");
Object.defineProperty(list, "scrollTop", { value: 90, configurable: true });
Object.defineProperty(list, "clientHeight", { value: 10, configurable: true });
Object.defineProperty(list, "scrollHeight", { value: 100, configurable: true });
list.dispatchEvent(new Event("scroll", { bubbles: true }));
await waitFor(() => expect(fetchNextPage).toHaveBeenCalled());
});
it("scopes the End User lookup to the window the logs table is showing", async () => {
const otherWindow = { start_date: "2026-01-01 00:00:00", end_date: "2026-01-02 00:00:00" };
renderWithProviders(<RequestLogsFilters get={() => undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />);
await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(otherWindow, 50, undefined));
});
});

View file

@ -1,8 +1,8 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useInfiniteEndUserAliases } from "@/app/(dashboard)/hooks/customers/useEndUserAliases";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { DataTableFilterField } from "@/components/shared/DataTable";
@ -20,9 +20,8 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { Team } from "../key_team_helpers/key_list";
import { allEndUsersCall } from "../networking";
import { ERROR_CODE_OPTIONS } from "./constants";
import { LOG_FILTER_IDS } from "./log_filter_logic";
import { LOG_FILTER_IDS, type LogsWindow } from "./log_filter_logic";
const ALL_VALUE = "all";
const PAGE_SIZE = 50;
@ -148,36 +147,43 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value
function EndUserFilterField({
value,
onChange,
accessToken,
logsWindow,
}: {
value: string;
onChange: (value: string | undefined) => void;
accessToken: string;
logsWindow: LogsWindow;
}) {
const { data } = useQuery<string[]>({
queryKey: ["logFilterEndUsers", accessToken],
queryFn: async () => {
const endUsers = await allEndUsersCall(accessToken);
return (endUsers ?? []).flatMap((endUser: { user_id?: string }) =>
typeof endUser.user_id === "string" ? [endUser.user_id] : [],
);
},
enabled: accessToken !== "",
});
const options = useMemo<SearchSelectOption[]>(
() => (data ?? []).map((userId) => ({ label: userId, value: userId })),
[data],
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteEndUserAliases(
logsWindow,
PAGE_SIZE,
emptyToUndefined(search),
);
const options = useMemo<SearchSelectOption[]>(() => {
const seen = new Set<string>();
return (data?.pages ?? []).flatMap((page) =>
page.aliases.flatMap((alias) => {
if (!alias || seen.has(alias)) return [];
seen.add(alias);
return [{ label: alias, value: alias }];
}),
);
}, [data]);
return (
<DataTableFilterField label="End User">
<SearchSelect
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder="Search an end user"
emptyText="No end users found"
emptyText="No end users in this time range"
/>
</DataTableFilterField>
);
@ -236,10 +242,10 @@ interface RequestLogsFiltersProps {
get: (columnId: string) => unknown;
set: (columnId: string, value: unknown) => void;
teams: Team[];
accessToken: string;
logsWindow: LogsWindow;
}
export function RequestLogsFilters({ get, set, teams, accessToken }: RequestLogsFiltersProps) {
export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) {
const valueOf = (id: string): string => asString(get(id));
const setter = (id: string) => (next: string | undefined) => set(id, next);
@ -276,7 +282,7 @@ export function RequestLogsFilters({ get, set, teams, accessToken }: RequestLogs
<EndUserFilterField
value={valueOf(LOG_FILTER_IDS.END_USER)}
onChange={setter(LOG_FILTER_IDS.END_USER)}
accessToken={accessToken}
logsWindow={logsWindow}
/>
<ErrorCodeFilterField value={valueOf(LOG_FILTER_IDS.ERROR_CODE)} onChange={setter(LOG_FILTER_IDS.ERROR_CODE)} />

View file

@ -12,7 +12,13 @@ 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, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic";
import {
DEFAULT_LOGS_SORTING,
formatLogsWindow,
getLogsWindowEndBound,
LOG_FILTER_IDS,
useLogFilterLogic,
} from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar";
import { RequestLogsTable } from "./RequestLogsTable";
@ -76,6 +82,14 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
sorting,
});
// Follow the table's own last fetch so a live-tail refresh carries the filter
// window with it; before the first fetch, fall back to the stored end time.
const windowEndBound = getLogsWindowEndBound(logsQuery.dataUpdatedAt || Date.parse(endTime));
const logsWindow = useMemo(
() => formatLogsWindow(startTime, endTime, isCustomDate, windowEndBound),
[startTime, endTime, isCustomDate, windowEndBound],
);
const keyInfoQueryOptions: UseQueryOptions<KeyResponse | null> = {
queryKey: ["requestLogsKeyInfo", selectedKeyIdInfoView, accessToken],
queryFn: async () => {
@ -232,7 +246,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
onKeyHashClick={handleKeyHashClick}
onSessionClick={handleSessionClick}
teams={allTeams ?? []}
accessToken={accessToken}
logsWindow={logsWindow}
toolbarChildren={
<LogsTableToolbar
startTime={startTime}

View file

@ -8,7 +8,7 @@ import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components
import type { Team } from "../key_team_helpers/key_list";
import type { LogEntry } from "./columns";
import { LOG_FILTER_LABELS } from "./log_filter_logic";
import { LOG_FILTER_LABELS, type LogsWindow } from "./log_filter_logic";
import { RequestLogsFilters } from "./RequestLogsFilters";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
@ -30,7 +30,7 @@ interface RequestLogsTableProps {
onKeyHashClick: (keyHash: string) => void;
onSessionClick: (sessionId: string) => void;
teams: Team[];
accessToken: string;
logsWindow: LogsWindow;
toolbarChildren?: ReactNode;
}
@ -68,7 +68,7 @@ export function RequestLogsTable({
onKeyHashClick,
onSessionClick,
teams,
accessToken,
logsWindow,
toolbarChildren,
}: RequestLogsTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
@ -122,7 +122,7 @@ export function RequestLogsTable({
title="Filters"
description="Narrow down request logs"
>
{({ get, set }) => <RequestLogsFilters get={get} set={set} teams={teams} accessToken={accessToken} />}
{({ get, set }) => <RequestLogsFilters get={get} set={set} teams={teams} logsWindow={logsWindow} />}
</DataTableFilterDrawer>
</>
)}

View file

@ -6,10 +6,13 @@ import React, { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_LOGS_SORTING,
formatLogsWindow,
getFilterValue,
getLiveTailRefetchInterval,
getLogsWindowEndBound,
LIVE_TAIL_INTERVAL_MS,
LOG_FILTER_IDS,
LOGS_WINDOW_TICK_MS,
useLogFilterLogic,
type PaginatedResponse,
} from "./log_filter_logic";
@ -233,3 +236,61 @@ describe("getLiveTailRefetchInterval", () => {
expect(getLiveTailRefetchInterval(true, 1)).toBe(false);
});
});
describe("formatLogsWindow", () => {
it("pins the end bound for a custom range", () => {
const w = formatLogsWindow("2026-07-23T00:00", "2026-07-24T06:00", true);
expect(w.start_date).toBe(moment("2026-07-23T00:00").utc().format("YYYY-MM-DD HH:mm:ss"));
expect(w.end_date).toBe(moment("2026-07-24T06:00").utc().format("YYYY-MM-DD HH:mm:ss"));
});
it("ends a preset range at now, not at the stored end time", () => {
const w = formatLogsWindow("2026-07-23T00:00", "1999-01-01T00:00", false);
expect(w.end_date > "2020-01-01 00:00:00").toBe(true);
});
});
describe("getLogsWindowEndBound", () => {
const BUCKET_START = 16666 * LOGS_WINDOW_TICK_MS;
it("holds steady inside a bucket so a memoized window does not refetch per render", () => {
expect(getLogsWindowEndBound(BUCKET_START)).toBe(getLogsWindowEndBound(BUCKET_START + LOGS_WINDOW_TICK_MS - 1));
});
it("advances once the bucket rolls over so a preset window follows the table", () => {
expect(getLogsWindowEndBound(BUCKET_START + LOGS_WINDOW_TICK_MS)).toBe(
getLogsWindowEndBound(BUCKET_START) + LOGS_WINDOW_TICK_MS,
);
});
it("never trails the fetch it was derived from", () => {
// Trailing is the live-tail bug: the table shows rows the filter window excludes.
for (const offset of [0, 1, LOGS_WINDOW_TICK_MS - 1]) {
expect(getLogsWindowEndBound(BUCKET_START + offset)).toBeGreaterThan(BUCKET_START + offset);
}
});
it("advances at least once per live-tail refetch interval", () => {
expect(LOGS_WINDOW_TICK_MS).toBeLessThanOrEqual(4 * LIVE_TAIL_INTERVAL_MS);
});
});
describe("formatLogsWindow preset end bound", () => {
it("uses the supplied bound for a preset range so callers can memoize it", () => {
const bound = Date.UTC(2026, 6, 24, 12, 0, 0);
expect(formatLogsWindow("2026-07-23T00:00", "1999-01-01T00:00", false, bound).end_date).toBe(
moment(bound).utc().format("YYYY-MM-DD HH:mm:ss"),
);
});
it("ignores the supplied bound for a custom range", () => {
const bound = Date.UTC(2026, 6, 24, 12, 0, 0);
expect(formatLogsWindow("2026-07-23T00:00", "2026-07-24T06:00", true, bound).end_date).toBe(
moment("2026-07-24T06:00").utc().format("YYYY-MM-DD HH:mm:ss"),
);
});
});

View file

@ -44,6 +44,37 @@ export const LOG_FILTER_LABELS: Record<string, string> = {
[LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool",
};
export interface LogsWindow {
start_date: string;
end_date: string;
}
export const formatLogsWindow = (
startTime: string,
endTime: string,
isCustomDate: boolean,
presetEndMs: number = Date.now(),
): LogsWindow => ({
start_date: moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"),
end_date: isCustomDate
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
: moment(presetEndMs).utc().format("YYYY-MM-DD HH:mm:ss"),
});
export const LOGS_WINDOW_TICK_MS = 60000;
/**
* Stable end bound for anything that memoizes a preset (non-custom) window.
*
* The logs query re-reads "now" on every fetch, so a live-tail refresh keeps moving
* its end bound. A memoized window needs to follow, or it pins a bound the table has
* already passed and stops offering end users the table is showing. Rounding the
* last-fetch time UP to the next bucket keeps the value stable between ticks (so the
* query key does not churn per render) while never trailing behind the table.
*/
export const getLogsWindowEndBound = (lastFetchedAtMs: number): number =>
(Math.floor(lastFetchedAtMs / LOGS_WINDOW_TICK_MS) + 1) * LOGS_WINDOW_TICK_MS;
export const LIVE_TAIL_INTERVAL_MS = 15000;
export const getLiveTailRefetchInterval = (isLiveTail: boolean, pageIndex: number): number | false =>
@ -119,17 +150,14 @@ export function useLogFilterLogic({
};
}
const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss");
const formattedEndTime = isCustomDate
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
const window = formatLogsWindow(startTime, endTime, isCustomDate);
const userIdFilter = getFilterValue(columnFilters, LOG_FILTER_IDS.USER_ID);
return await uiSpendLogsCall({
accessToken,
start_date: formattedStartTime,
end_date: formattedEndTime,
start_date: window.start_date,
end_date: window.end_date,
page: pagination.pageIndex + 1,
page_size: pageSize,
params: {

View file

@ -2772,6 +2772,40 @@ export interface paths {
patch: operations["cursor_proxy_route_cursor__endpoint__patch"];
trace?: never;
};
"/customer/aliases": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Customer Aliases
* @description List the end users seen in spend logs over a time window, for UI filter dropdowns.
*
* Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window,
* anyone else sees only end users from their own requests or from teams they
* administer (or hold the `/spend/logs` permission on).
*
* Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry
* the team attribution this scoping needs. The window is required and the inner
* scan is capped at SPEND_LOGS_FILTER_SCAN_CAP rows, so the query
* cannot degrade into a full-table scan the way `/global/all_end_users` does.
*
* Example curl:
* ```
* curl --location 'http://0.0.0.0:4000/customer/aliases?start_date=2026-07-23%2000:00:00&end_date=2026-07-24%2000:00:00&size=50&search=acme' --header 'Authorization: Bearer sk-1234'
* ```
*/
get: operations["list_customer_aliases_customer_aliases_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/customer/block": {
parameters: {
query?: never;
@ -23295,6 +23329,29 @@ export interface components {
[key: string]: unknown;
};
};
/**
* CustomerAliasesResponse
* @description Paginated, id-only customer listing used by UI filter dropdowns.
*
* Deliberately excludes budget/object-permission relations so a proxy with a
* large LiteLLM_EndUserTable can back a search-as-you-type control without
* materializing every row (see /customer/list for the full objects).
*
* Reports ``has_more`` rather than a total count on purpose: a total requires
* COUNT(*) over the whole match set on every keystroke, which is the exact
* cost this endpoint exists to avoid. Fetching one row beyond the page is
* enough to drive an infinite-scroll dropdown.
*/
CustomerAliasesResponse: {
/** Aliases */
aliases: string[];
/** Current Page */
current_page: number;
/** Has More */
has_more: boolean;
/** Size */
size: number;
};
/**
* CustomerResponse
* @description Customer object returned by the /customer read+write endpoints.
@ -38400,6 +38457,46 @@ export interface operations {
};
};
};
list_customer_aliases_customer_aliases_get: {
parameters: {
query: {
/** @description Window start, 'YYYY-MM-DD HH:MM:SS' (UTC) */
start_date: string;
/** @description Window end, 'YYYY-MM-DD HH:MM:SS' (UTC) */
end_date: string;
/** @description Page number */
page?: number;
/** @description Page size */
size?: number;
/** @description Case-insensitive partial match on the customer id */
search?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CustomerAliasesResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
block_user_customer_block_post: {
parameters: {
query?: never;