feat: scope request log user filter

Add a bounded spend-log user facet for the Request Logs picker and
intersect explicit user filters with the caller's own and permitted-team
scope.

Co-Authored-By: Codex
This commit is contained in:
Daniel Meismer 2026-08-13 11:50:43 -04:00
parent 151bdbb2a9
commit 297fe272ec
14 changed files with 470 additions and 203 deletions

View file

@ -679,6 +679,7 @@ class LiteLLMRoutes(enum.Enum):
# permitted teams exactly like /spend/logs/ui — it belongs to the same
# access tier, not to customer management.
"/management/v1/spend_logs/end_users",
"/management/v1/spend_logs/users",
"/cost/estimate",
]
@ -871,12 +872,13 @@ class LiteLLMRoutes(enum.Enum):
# PROXY_ADMIN_VIEW_ONLY — the route gate must match).
"/customer/list",
"/customer/info",
# UI Logs page detail drawer (single + session) and the end-user filter
# facet. The list endpoint `/spend/logs/ui` is covered via
# UI Logs page detail drawer (single + session) and the filter facets.
# The list endpoint `/spend/logs/ui` is covered via
# spend_tracking_routes below.
"/spend/logs/ui/{logId}",
"/spend/logs/session/ui",
"/management/v1/spend_logs/end_users",
"/management/v1/spend_logs/users",
# Settings / observability read endpoints exposed in admin-only
# sidebar groups (Logging & Alerts, Admin Settings, Budgets,
# Invitations).

View file

@ -1,7 +1,7 @@
"""`/management/v1/spend_logs` facets."""
from datetime import datetime, timezone
from typing import Annotated, Any, Final
from typing import Annotated, Any, Final, Literal
from fastapi import APIRouter, Depends, Query, Request
@ -35,7 +35,7 @@ def _as_utc(value: datetime) -> datetime:
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
async def _end_user_scope_clause(
async def _spend_log_scope_clause(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
next_param_index: int,
@ -43,8 +43,8 @@ async def _end_user_scope_clause(
"""SQL predicate restricting the facet to spend logs this caller may read.
Returns ``(None, ())`` for a proxy admin. Mirrors the scoping ``/spend/logs/ui``
applies, so the dropdown can never offer an end user whose rows the caller
could not open.
applies, so a dropdown can never offer a value from a row the caller could
not open.
"""
from litellm.proxy.spend_tracking.spend_management_endpoints import (
_get_permitted_team_ids_for_spend_logs,
@ -77,6 +77,98 @@ async def _end_user_scope_clause(
return f"({' OR '.join(clauses)})", params
async def _list_spend_log_facet(
request: Request,
user_api_key_dict: UserAPIKeyAuth,
start_time: datetime,
end_time: datetime,
q: str | None,
page: int,
page_size: int,
column: Literal["end_user", "user"],
) -> FacetListResponse:
try:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
title="Database not connected",
status=503,
detail=CommonProxyErrors.db_not_connected_error.value,
)
)
column_sql: Final = "end_user" if column == "end_user" else '"user"'
window_params: Final[tuple[Any, ...]] = (_as_utc(start_time), _as_utc(end_time))
search_params: Final[tuple[Any, ...]] = (f"%{escape_like(q)}%",) if q else ()
search_clause: Final = (f"{column_sql} ILIKE ${len(window_params) + 1} ESCAPE '\\'",) if q else ()
scope_clause, scope_params = await _spend_log_scope_clause(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
next_param_index=len(window_params) + len(search_params) + 1,
)
where_parts: Final = (
(
"\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')",
"\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')",
f"{column_sql} IS NOT NULL",
f"{column_sql} != ''",
)
+ search_clause
+ ((scope_clause,) if scope_clause is not None else ())
)
# The inner LIMIT walks the startTime index newest first and bounds the
# rows DISTINCT can inspect. request_id makes the cut-off deterministic,
# and page_size + 1 reveals has_more without a COUNT(*).
params: Final = (
window_params
+ search_params
+ scope_params
+ (SPEND_LOGS_FACET_SCAN_CAP, page_size + 1, (page - 1) * page_size)
)
scan_idx: Final = len(params) - 2
facet_sql: Final = (
f"SELECT DISTINCT {column_sql} FROM ("
f" SELECT {column_sql}"
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 {column_sql} ASC"
f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}"
)
rows: Final = await prisma_client.db.query_raw(facet_sql, *params)
values: Final[list[str]] = [row[column] for row in rows if row.get(column)]
has_more: Final = len(values) > page_size
return FacetListResponse(
data=values[:page_size],
meta=PageMeta(page=page, page_size=page_size, has_more=has_more),
links=build_page_links(request=request, page=page, has_more=has_more),
)
except ManagementProblem:
raise
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.management_v1.spend_logs._list_spend_log_facet(): Exception occured - %s",
e,
)
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
title="Internal server error",
status=500,
detail=f"Failed to list spend log {column.replace('_', ' ')}s.",
)
)
@router.get(
"/spend_logs/end_users",
tags=["Budget & Spend Tracking"],
@ -116,85 +208,47 @@ async def list_spend_log_end_users(
--header 'Authorization: Bearer sk-1234'
```
"""
try:
from litellm.proxy.proxy_server import prisma_client
return await _list_spend_log_facet(
request=request,
user_api_key_dict=user_api_key_dict,
start_time=start_time,
end_time=end_time,
q=q,
page=page,
page_size=page_size,
column="end_user",
)
if prisma_client is None:
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
title="Database not connected",
status=503,
detail=CommonProxyErrors.db_not_connected_error.value,
)
)
window_params: Final[tuple[Any, ...]] = (_as_utc(start_time), _as_utc(end_time))
search_params: Final[tuple[Any, ...]] = (f"%{escape_like(q)}%",) if q else ()
search_clause: Final = (f"end_user ILIKE ${len(window_params) + 1} ESCAPE '\\'",) if q else ()
scope_clause, scope_params = await _end_user_scope_clause(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
next_param_index=len(window_params) + len(search_params) + 1,
)
where_parts: Final = (
(
"\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')",
"\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')",
"end_user IS NOT NULL",
"end_user != ''",
)
+ search_clause
+ ((scope_clause,) if scope_clause is not None else ())
)
# 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.
# page_size + 1: one row beyond the page reveals has_more without a COUNT(*).
params: Final = (
window_params
+ search_params
+ scope_params
+ (SPEND_LOGS_FACET_SCAN_CAP, page_size + 1, (page - 1) * page_size)
)
scan_idx: Final = len(params) - 2
facet_sql: Final = (
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: Final = await prisma_client.db.query_raw(facet_sql, *params)
end_users: Final[list[str]] = [row["end_user"] for row in rows if row.get("end_user")]
has_more: Final = len(end_users) > page_size
return FacetListResponse(
data=end_users[:page_size],
meta=PageMeta(page=page, page_size=page_size, has_more=has_more),
links=build_page_links(request=request, page=page, has_more=has_more),
)
except ManagementProblem:
raise
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.management_endpoints.management_v1.spend_logs.list_spend_log_end_users(): Exception occured - %s",
e,
)
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
title="Internal server error",
status=500,
detail="Failed to list spend log end users.",
)
)
@router.get(
"/spend_logs/users",
tags=["Budget & Spend Tracking"],
dependencies=[Depends(user_api_key_auth), Depends(reject_unknown_query_params)],
response_model=FacetListResponse,
)
async def list_spend_log_users(
request: Request,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
start_time: Annotated[
datetime,
Query(alias="filter[startTime][gte]", description="Window start (UTC when no offset is given)"),
],
end_time: Annotated[
datetime,
Query(alias="filter[startTime][lte]", description="Window end (UTC when no offset is given)"),
],
q: Annotated[str | None, Query(description="Case-insensitive partial match on the internal user id")] = None,
page: Annotated[int, Query(ge=1, description="Page number")] = 1,
page_size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50,
) -> FacetListResponse:
"""The distinct internal users appearing in spend logs the caller can read."""
return await _list_spend_log_facet(
request=request,
user_api_key_dict=user_api_key_dict,
start_time=start_time,
end_time=end_time,
q=q,
page=page,
page_size=page_size,
column="user",
)

View file

@ -2427,6 +2427,7 @@ async def ui_view_spend_logs(
request_id=request_id,
)
permitted_team_ids: list[str] | None = None
scope_to_caller_user = False
if not is_request_id_lookup and not is_admin_view:
if team_id is not None:
can_view_team: Final = await _can_team_member_view_log(
@ -2440,7 +2441,6 @@ async def ui_view_spend_logs(
detail={"error": f"Not authorized to view team spend for team_id={team_id}"},
)
where_conditions["team_id"] = team_id
where_conditions.pop("user", None)
else:
if _can_user_view_spend_log(user_api_key_dict=user_api_key_dict):
try:
@ -2451,13 +2451,20 @@ async def ui_view_spend_logs(
except Exception:
permitted_team_ids = []
if permitted_team_ids:
where_conditions.pop("user", None)
if user_id is None:
where_conditions.pop("user", None)
where_conditions["OR"] = [
{"user": user_api_key_dict.user_id},
{"team_id": {"in": permitted_team_ids}},
]
else:
where_conditions["user"] = user_api_key_dict.user_id
if user_id is None:
where_conditions["user"] = user_api_key_dict.user_id
else:
where_conditions["AND"] = where_conditions.get("AND", []) + [
{"user": user_api_key_dict.user_id}
]
scope_to_caller_user = True
where_conditions.pop("team_id", None)
# Calculate skip value for pagination
skip: Final = (page - 1) * page_size
@ -2508,6 +2515,10 @@ async def ui_view_spend_logs(
sql_params.append(permitted_team_ids)
p += 2
sql_conditions.append(or_clause)
elif scope_to_caller_user:
sql_conditions.append(f'"user" = ${p}')
sql_params.append(user_api_key_dict.user_id)
p += 1
if session_id is not None and isinstance(session_id, str):
like_escaped_session_id: Final = session_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")

View file

@ -1,5 +1,4 @@
from datetime import datetime, timezone
from typing import List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -45,6 +44,7 @@ app.include_router(router)
client = TestClient(app)
END_USERS_PATH = f"{MANAGEMENT_V1_PREFIX}/spend_logs/end_users"
USERS_PATH = f"{MANAGEMENT_V1_PREFIX}/spend_logs/users"
WINDOW = "filter[startTime][gte]=2026-07-23T00:00:00Z&filter[startTime][lte]=2026-07-24T00:00:00Z"
@ -65,7 +65,7 @@ def as_proxy_admin():
app.dependency_overrides.clear()
def _mock_rows(mock_prisma_client, end_users: List[str]) -> AsyncMock:
def _mock_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
@ -82,6 +82,11 @@ def _get(query: str = WINDOW):
return client.get(f"{END_USERS_PATH}{suffix}", headers={"Authorization": "Bearer k"})
def _get_users(query: str = WINDOW):
suffix = f"?{query}" if query else ""
return client.get(f"{USERS_PATH}{suffix}", headers={"Authorization": "Bearer k"})
def test_returns_the_control_plane_envelope(mock_prisma_client, as_proxy_admin):
"""`{data, meta, links}` is the contract; a bare list or a legacy `aliases` key is not."""
_mock_rows(mock_prisma_client, ["a", "b"])
@ -213,7 +218,7 @@ def test_requires_a_time_window(mock_prisma_client, as_proxy_admin, query):
def test_rejects_a_malformed_window_as_a_problem_document(mock_prisma_client, as_proxy_admin):
_mock_rows(mock_prisma_client, [])
response = _get(f"filter[startTime][gte]=yesterday&filter[startTime][lte]=2026-07-24T00:00:00Z")
response = _get("filter[startTime][gte]=yesterday&filter[startTime][lte]=2026-07-24T00:00:00Z")
assert response.status_code == 400
assert response.headers["content-type"].startswith("application/problem+json")
@ -400,6 +405,49 @@ def test_q_placeholder_precedes_the_scan_limit_and_offset(mock_prisma_client, as
assert query_raw.call_args.args[5:] == (11, 0)
def test_user_facet_reads_internal_users_from_spend_logs(mock_prisma_client, as_proxy_admin):
query_raw = AsyncMock(return_value=[{"user": "alice@example.com"}, {"user": "user-42"}])
mock_prisma_client.db.query_raw = query_raw
response = _get_users()
assert response.status_code == 200
assert response.json()["data"] == ["alice@example.com", "user-42"]
sql = query_raw.call_args.args[0]
assert 'SELECT DISTINCT "user"' in sql
assert '"user" IS NOT NULL' in sql
assert "end_user IS NOT NULL" not in sql
def test_user_facet_uses_the_same_team_scope_as_request_logs(mock_prisma_client):
query_raw = AsyncMock(return_value=[{"user": "member@example.com"}])
mock_prisma_client.db.query_raw = query_raw
original = _as_role(LitellmUserRoles.INTERNAL_USER, 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"]),
):
response = _get_users()
finally:
app.dependency_overrides = original
assert response.status_code == 200
assert '("user" = $3 OR team_id = ANY($4::text[]))' in query_raw.call_args.args[0]
assert query_raw.call_args.args[3] == "team-admin-1"
assert query_raw.call_args.args[4] == ["team-a"]
def test_user_facet_searches_the_internal_user_value(mock_prisma_client, as_proxy_admin):
query_raw = AsyncMock(return_value=[])
mock_prisma_client.db.query_raw = query_raw
_get_users(f"{WINDOW}&q=alice%40example.com")
assert '"user" ILIKE $3 ESCAPE' in query_raw.call_args.args[0]
assert query_raw.call_args.args[3] == "%alice@example.com%"
@pytest.mark.parametrize(
"role",
[
@ -416,18 +464,19 @@ def test_is_reachable_by_every_role_that_can_open_the_logs_page(role):
"""
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) == (END_USERS_PATH in allowed)
for facet_path in (END_USERS_PATH, USERS_PATH):
for allowed in (
LiteLLMRoutes.internal_user_routes.value,
LiteLLMRoutes.internal_user_view_only_routes.value,
):
assert ("/spend/logs/ui" in allowed) == (facet_path 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=END_USERS_PATH, allowed_routes=allowed_routes)
else:
assert END_USERS_PATH in LiteLLMRoutes.admin_viewer_routes.value
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=facet_path, allowed_routes=allowed_routes)
else:
assert facet_path in LiteLLMRoutes.admin_viewer_routes.value

View file

@ -1321,7 +1321,7 @@ async def test_ui_view_spend_logs_internal_user_scoped_without_user_id(
@pytest.mark.asyncio
async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeypatch):
async def test_ui_view_spend_logs_team_admin_can_filter_team_spend_by_user(client, monkeypatch):
"""
Team admins should be able to view team-wide spend when team_id is provided.
"""
@ -1346,11 +1346,23 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
},
{
"id": "log3",
"request_id": "req3",
"api_key": "sk-test-key",
"user": "member3",
"team_id": "team_admin_team",
"spend": 0.15,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
},
]
def filter_by_team(where):
if "team_id" in where and where["team_id"] == "team_admin_team":
if where.get("team_id") == "team_admin_team" and where.get("user") == "member1":
return [mock_spend_logs[0]]
if where.get("team_id") == "team_admin_team":
return [mock_spend_logs[0], mock_spend_logs[2]]
return mock_spend_logs
class TeamTable:
@ -1383,6 +1395,7 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp
"/spend/logs/ui",
params={
"team_id": "team_admin_team",
"user_id": "member1",
"start_date": start_date,
"end_date": end_date,
},
@ -1398,6 +1411,66 @@ async def test_ui_view_spend_logs_team_admin_can_view_team_spend(client, monkeyp
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_user_filter_intersects_permitted_team_scope(client, monkeypatch):
member_log = {
"id": "log1",
"request_id": "req1",
"api_key": "sk-test-key",
"user": "member@example.com",
"team_id": "team-9",
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
}
other_team_log = {
**member_log,
"id": "log2",
"request_id": "req2",
"team_id": "team-outside-scope",
}
seen_where = []
def filter_by_user_and_scope(where):
seen_where.append(where)
if where.get("user") == "member@example.com" and {"multi_team": True} in where.get("OR", []):
return [member_log]
return [member_log, other_team_log]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma([member_log, other_team_log], filter_by_user_and_scope),
)
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
AsyncMock(return_value=["team-9"]),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="team-admin"
)
try:
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={
"user_id": "member@example.com",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert [row["request_id"] for row in response.json()["data"]] == ["req1"]
assert any(
where.get("user") == "member@example.com" and {"multi_team": True} in where.get("OR", [])
for where in seen_where
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_pagination(client, monkeypatch):
mock_spend_logs = [
@ -1578,6 +1651,7 @@ async def test_ui_view_session_spend_logs_pagination(client, monkeypatch):
assert data["total_pages"] == 2
assert len(data["data"]) == 1
assert data["data"][0]["request_id"] == "req1"
assert data["data"][0]["user"] == "member1"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)

View file

@ -0,0 +1,40 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const useInfiniteQuery = vi.fn();
vi.mock("@/lib/http/api", () => ({ $api: { useInfiniteQuery: (...args: unknown[]) => useInfiniteQuery(...args) } }));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
import { useInfiniteSpendLogUsers } from "./useSpendLogUsers";
const WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" };
describe("useInfiniteSpendLogUsers", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
});
it("calls the scoped spend-log user facet with the visible window", () => {
renderHook(() => useInfiniteSpendLogUsers(WINDOW, 25, "alice"));
const expectedQuery = {
"filter[startTime][gte]": "2026-07-23 00:00:00",
"filter[startTime][lte]": "2026-07-24 00:00:00",
page_size: 25,
q: "alice",
};
expect(useInfiniteQuery.mock.calls[0][1]).toBe("/management/v1/spend_logs/users");
expect(useInfiniteQuery.mock.calls[0][2].params.query).toEqual(expectedQuery);
});
it("omits q when the search box is empty", () => {
renderHook(() => useInfiniteSpendLogUsers(WINDOW, 50, ""));
expect(useInfiniteQuery.mock.calls[0][2].params.query).not.toHaveProperty("q");
});
});

View file

@ -0,0 +1,21 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { $api } from "@/lib/http/api";
import { nextPageFromLinks, type SpendLogsWindow } from "./useSpendLogEndUsers";
export const useInfiniteSpendLogUsers = (window: SpendLogsWindow, pageSize: number = 50, q?: string) => {
const { accessToken } = useAuthorized();
const query = {
"filter[startTime][gte]": window.start_date,
"filter[startTime][lte]": window.end_date,
page_size: pageSize,
...(q !== undefined && q !== "" ? { q } : {}),
};
const options = {
pageParamName: "page",
initialPageParam: 1,
getNextPageParam: nextPageFromLinks,
enabled: Boolean(accessToken),
};
return $api.useInfiniteQuery("get", "/management/v1/spend_logs/users", { params: { query } }, options);
};

View file

@ -14,8 +14,8 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useInfiniteModelInfo: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
useInfiniteUsers: vi.fn(),
vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers", () => ({
useInfiniteSpendLogUsers: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({
@ -23,9 +23,9 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({
}));
import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers";
import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
const emptyInfiniteQuery = {
data: { pages: [], pageParams: [] },
@ -37,16 +37,10 @@ const emptyInfiniteQuery = {
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> = {}, showUserIdFilter = true) {
function renderFilters(filters: Record<string, string> = {}) {
const set = vi.fn();
renderWithProviders(
<RequestLogsFilters
get={(id: string) => filters[id]}
set={set}
teams={[]}
logsWindow={LOGS_WINDOW}
showUserIdFilter={showUserIdFilter}
/>,
<RequestLogsFilters get={(id: string) => filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />,
);
return { set };
}
@ -61,8 +55,8 @@ describe("RequestLogsFilters", () => {
vi.mocked(useInfiniteModelInfo).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteModelInfo>,
);
vi.mocked(useInfiniteUsers).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteUsers>,
vi.mocked(useInfiniteSpendLogUsers).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteSpendLogUsers>,
);
vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteSpendLogEndUsers>,
@ -97,31 +91,27 @@ describe("RequestLogsFilters", () => {
expect(labels[1].compareDocumentPosition(labels[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("selects a user by display name while storing the user ID filter", async () => {
vi.mocked(useInfiniteUsers).mockReturnValue({
it("selects an internal user value from the caller's visible spend logs", async () => {
vi.mocked(useInfiniteSpendLogUsers).mockReturnValue({
...emptyInfiniteQuery,
data: {
pages: [
{
users: [{ user_id: "user-1", user_alias: "Alice", user_email: "alice@example.com" }],
page: 1,
page_size: 50,
total: 1,
total_pages: 1,
data: ["alice@example.com"],
meta: { page: 1, page_size: 50, has_more: false },
links: { self: "", next: null },
},
],
pageParams: [1],
},
} as unknown as ReturnType<typeof useInfiniteUsers>);
} as unknown as ReturnType<typeof useInfiniteSpendLogUsers>);
const user = userEvent.setup();
const { set } = renderFilters();
await user.click(await screen.findByPlaceholderText("Search an internal user"));
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com | User ID: user-1")).toBeInTheDocument();
await user.click(screen.getByText("Alice"));
await user.click(await screen.findByText("alice@example.com"));
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "user-1");
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "alice@example.com");
});
it("pushes the User ID picker query to the paginated user lookup", async () => {
@ -132,28 +122,26 @@ describe("RequestLogsFilters", () => {
await user.click(input);
await user.type(input, "alice@example.com");
await waitFor(() => expect(useInfiniteUsers).toHaveBeenCalledWith(50, "alice@example.com"));
await waitFor(() => expect(useInfiniteSpendLogUsers).toHaveBeenCalledWith(LOGS_WINDOW, 50, "alice@example.com"));
});
it("loads the next page when the User ID list is scrolled near the end", async () => {
const fetchNextPage = vi.fn();
vi.mocked(useInfiniteUsers).mockReturnValue({
vi.mocked(useInfiniteSpendLogUsers).mockReturnValue({
...emptyInfiniteQuery,
fetchNextPage,
hasNextPage: true,
data: {
pages: [
{
users: [{ user_id: "user-1", user_alias: "Alice", user_email: "alice@example.com" }],
page: 1,
page_size: 50,
total: 51,
total_pages: 2,
data: ["alice@example.com"],
meta: { page: 1, page_size: 50, has_more: true },
links: { self: "", next: "?page=2" },
},
],
pageParams: [1],
},
} as unknown as ReturnType<typeof useInfiniteUsers>);
} as unknown as ReturnType<typeof useInfiniteSpendLogUsers>);
const user = userEvent.setup();
renderFilters();
@ -167,13 +155,6 @@ describe("RequestLogsFilters", () => {
await waitFor(() => expect(fetchNextPage).toHaveBeenCalled());
});
it("does not show or query the User ID filter for non-admin request logs", () => {
renderFilters({}, false);
expect(screen.queryByText("User ID")).not.toBeInTheDocument();
expect(useInfiniteUsers).not.toHaveBeenCalled();
});
it("scopes the Key Alias lookup to the selected team", async () => {
renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" });
@ -264,15 +245,7 @@ describe("RequestLogsFilters", () => {
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}
showUserIdFilter
/>,
);
renderWithProviders(<RequestLogsFilters get={() => undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />);
await waitFor(() => expect(useInfiniteSpendLogEndUsers).toHaveBeenCalledWith(otherWindow, 50, undefined));
});

View file

@ -3,9 +3,9 @@
import { useMemo, useState } from "react";
import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers";
import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { DataTableFilterField } from "@/components/shared/DataTable";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
@ -145,9 +145,18 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value
);
}
function UserIdFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) {
function UserIdFilterField({
value,
onChange,
logsWindow,
}: {
value: string;
onChange: (value: string | undefined) => void;
logsWindow: LogsWindow;
}) {
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteUsers(
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteSpendLogUsers(
logsWindow,
PAGE_SIZE,
emptyToUndefined(search),
);
@ -155,14 +164,10 @@ function UserIdFilterField({ value, onChange }: { value: string; onChange: (valu
const options = useMemo<SearchSelectOption[]>(() => {
const seen = new Set<string>();
return (data?.pages ?? []).flatMap((page) =>
page.users.flatMap((user) => {
if (!user.user_id || seen.has(user.user_id)) return [];
seen.add(user.user_id);
const label = user.user_alias || user.user_email || user.user_id;
const email = user.user_email && user.user_email !== label ? user.user_email : "";
const sublabel =
user.user_id === label ? email : [email, `User ID: ${user.user_id}`].filter(Boolean).join(" | ");
return [{ label, value: user.user_id, sublabel }];
page.data.flatMap((userId) => {
if (!userId || seen.has(userId)) return [];
seen.add(userId);
return [{ label: userId, value: userId }];
}),
);
}, [data]);
@ -284,10 +289,9 @@ interface RequestLogsFiltersProps {
set: (columnId: string, value: unknown) => void;
teams: Team[];
logsWindow: LogsWindow;
showUserIdFilter: boolean;
}
export function RequestLogsFilters({ get, set, teams, logsWindow, showUserIdFilter }: 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);
@ -321,12 +325,11 @@ export function RequestLogsFilters({ get, set, teams, logsWindow, showUserIdFilt
teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)}
/>
{showUserIdFilter && (
<UserIdFilterField
value={valueOf(LOG_FILTER_IDS.USER_ID)}
onChange={setter(LOG_FILTER_IDS.USER_ID)}
/>
)}
<UserIdFilterField
value={valueOf(LOG_FILTER_IDS.USER_ID)}
onChange={setter(LOG_FILTER_IDS.USER_ID)}
logsWindow={logsWindow}
/>
<EndUserFilterField
value={valueOf(LOG_FILTER_IDS.END_USER)}

View file

@ -6,7 +6,6 @@ import moment from "moment";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells";
import { all_admin_roles, internalUserRoles } from "../../utils/roles";
import type { KeyResponse } from "../key_team_helpers/key_list";
import { keyInfoV1Call, uiSpendLogsCall } from "../networking";
import KeyInfoView from "../templates/key_info_view";
@ -73,15 +72,12 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail));
}, [isLiveTail]);
const filterByCurrentUser = internalUserRoles.includes(userRole);
const { logsQuery, filteredLogs, allTeams } = useLogFilterLogic({
accessToken,
token,
userRole,
userID,
columnFilters,
filterByCurrentUser,
activeTab: isActive ? "request logs" : "inactive",
isLiveTail,
startTime,
@ -305,7 +301,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
onSessionClick={handleSessionClick}
teams={allTeams ?? []}
logsWindow={logsWindow}
showUserIdFilter={all_admin_roles.includes(userRole)}
toolbarChildren={
<LogsTableToolbar
startTime={startTime}

View file

@ -31,7 +31,6 @@ interface RequestLogsTableProps {
onSessionClick: (sessionId: string) => void;
teams: Team[];
logsWindow: LogsWindow;
showUserIdFilter: boolean;
toolbarChildren?: ReactNode;
}
@ -70,7 +69,6 @@ export function RequestLogsTable({
onSessionClick,
teams,
logsWindow,
showUserIdFilter,
toolbarChildren,
}: RequestLogsTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
@ -124,15 +122,7 @@ export function RequestLogsTable({
title="Filters"
description="Narrow down request logs"
>
{({ get, set }) => (
<RequestLogsFilters
get={get}
set={set}
teams={teams}
logsWindow={logsWindow}
showUserIdFilter={showUserIdFilter}
/>
)}
{({ get, set }) => <RequestLogsFilters get={get} set={set} teams={teams} logsWindow={logsWindow} />}
</DataTableFilterDrawer>
</>
)}

View file

@ -45,7 +45,6 @@ const defaultProps = {
userRole: "Admin" as string | null,
userID: "user-1" as string | null,
columnFilters: [] as ColumnFiltersState,
filterByCurrentUser: false,
activeTab: "request logs",
isLiveTail: false,
startTime: "2025-01-01T00:00:00",
@ -181,17 +180,16 @@ describe("useLogFilterLogic", () => {
});
});
describe("filterByCurrentUser", () => {
it("scopes to the current user when no explicit user filter is set", async () => {
renderFilterHook({ filterByCurrentUser: true });
describe("user scope", () => {
it("leaves an empty user filter for the backend to authorize", async () => {
renderFilterHook();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ user_id: "user-1" });
expect(lastCallParams()?.params?.user_id).toBeUndefined();
});
it("lets an explicit user filter win over the current-user scope", async () => {
it("sends an explicit user filter for the backend to intersect with authorization", async () => {
renderFilterHook({
filterByCurrentUser: true,
columnFilters: [{ id: LOG_FILTER_IDS.USER_ID, value: "someone-else" }],
});

View file

@ -99,7 +99,6 @@ export function useLogFilterLogic({
userRole,
userID,
columnFilters,
filterByCurrentUser,
activeTab,
isLiveTail,
startTime,
@ -113,7 +112,6 @@ export function useLogFilterLogic({
userRole: string | null;
userID: string | null;
columnFilters: ColumnFiltersState;
filterByCurrentUser: boolean | null;
activeTab: string;
isLiveTail: boolean;
startTime: string;
@ -137,7 +135,6 @@ export function useLogFilterLogic({
endTime,
isCustomDate,
columnFilters,
filterByCurrentUser ? userID : null,
sortBy,
sortOrder,
],
@ -167,7 +164,7 @@ export function useLogFilterLogic({
team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID),
request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID),
session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID),
user_id: userIdFilter ?? (filterByCurrentUser ? userID ?? undefined : undefined),
user_id: userIdFilter,
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),

View file

@ -7529,6 +7529,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/spend_logs/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Spend Log Users
* @description The distinct internal users appearing in spend logs the caller can read.
*/
get: operations["list_spend_log_users_management_v1_spend_logs_users_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mcp-rest/test/connection": {
parameters: {
query?: never;
@ -45400,6 +45420,46 @@ export interface operations {
};
};
};
list_spend_log_users_management_v1_spend_logs_users_get: {
parameters: {
query: {
/** @description Window start (UTC when no offset is given) */
"filter[startTime][gte]": string;
/** @description Window end (UTC when no offset is given) */
"filter[startTime][lte]": string;
/** @description Case-insensitive partial match on the internal user id */
q?: string | null;
/** @description Page number */
page?: number;
/** @description Page size */
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["FacetListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
test_connection_mcp_rest_test_connection_post: {
parameters: {
query?: never;