mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
commit
a66bac3adf
12 changed files with 822 additions and 45 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
97
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
97
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue