mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
151bdbb2a9
commit
297fe272ec
14 changed files with 470 additions and 203 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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("_", "\\_")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
60
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
60
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue