mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge pull request #39661 from BerriAI/litellm_lit_4741_copy_id_search
feat(ui): find rows by a pasted ID on keys, agents, memory, audit, and request logs
This commit is contained in:
commit
a53c550951
34 changed files with 1176 additions and 74 deletions
|
|
@ -48,6 +48,18 @@ def _build_json_field_or_condition(json_key: str, value: str) -> dict[str, objec
|
|||
}
|
||||
|
||||
|
||||
def _build_search_condition(search: str) -> dict[str, object]:
|
||||
"""Match a row whose id, changed_by, object_id, or changed_by_api_key equals the search value."""
|
||||
return {
|
||||
"OR": (
|
||||
{"id": search},
|
||||
{"changed_by": search},
|
||||
{"object_id": search},
|
||||
{"changed_by_api_key": search},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/audit",
|
||||
tags=["Audit Logging"],
|
||||
|
|
@ -83,6 +95,10 @@ async def get_audit_logs(
|
|||
None,
|
||||
description="Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only)",
|
||||
),
|
||||
search: str | None = Query(
|
||||
None,
|
||||
description="Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value",
|
||||
),
|
||||
# Sorting parameters
|
||||
sort_by: str | None = Query(
|
||||
None,
|
||||
|
|
@ -118,6 +134,11 @@ async def get_audit_logs(
|
|||
*([_build_json_field_or_condition("token", object_key_hash)] if object_key_hash else []),
|
||||
]
|
||||
|
||||
and_conditions: Final[tuple[dict[str, object], ...]] = (
|
||||
*json_field_conditions,
|
||||
*((_build_search_condition(search),) if search else ()),
|
||||
)
|
||||
|
||||
# Build filter conditions
|
||||
where_conditions: Final[dict[str, object]] = {
|
||||
**({"changed_by": changed_by} if changed_by else {}),
|
||||
|
|
@ -126,14 +147,14 @@ async def get_audit_logs(
|
|||
**({"table_name": table_name} if table_name else {}),
|
||||
**({"object_id": object_id} if object_id else {}),
|
||||
**({"updated_at": date_filter} if start_date or end_date else {}),
|
||||
**({"AND": json_field_conditions} if json_field_conditions else {}),
|
||||
**({"AND": and_conditions} if and_conditions else {}),
|
||||
}
|
||||
|
||||
order_by: Final[dict[str, str]] = (
|
||||
{sort_by: sort_order} if sort_by and isinstance(sort_by, str) else {"updated_at": sort_order}
|
||||
)
|
||||
|
||||
audit_log_table: Final[TableActions["prisma_models.LiteLLM_AuditLog"]] = AuditLogRepository(prisma_client).table
|
||||
audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table
|
||||
|
||||
# Get paginated results
|
||||
audit_logs: Final = await audit_log_table.find_many(
|
||||
|
|
@ -195,7 +216,7 @@ async def get_audit_log_by_id(
|
|||
detail={"message": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
audit_log_table: Final[TableActions["prisma_models.LiteLLM_AuditLog"]] = AuditLogRepository(prisma_client).table
|
||||
audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table
|
||||
|
||||
# Get the audit log by ID
|
||||
audit_log: Final = await audit_log_table.find_unique(where={"id": id})
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ from litellm.types.proxy.management_endpoints.key_management_endpoints import (
|
|||
BulkUpdateKeyResponse,
|
||||
BulkUpdateTeamKeysRequest,
|
||||
FailedKeyUpdate,
|
||||
KeySearchWhere,
|
||||
SuccessfulKeyUpdate,
|
||||
)
|
||||
from litellm.types.router import Deployment
|
||||
|
|
@ -5800,6 +5801,10 @@ async def list_keys(
|
|||
None,
|
||||
description="Filter keys by key alias. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching.",
|
||||
),
|
||||
search: str | None = Query(
|
||||
None,
|
||||
description="Combined search: matches keys whose token (key hash) equals the value OR whose key_alias contains it (case-insensitive).",
|
||||
),
|
||||
return_full_object: bool = Query(False, description="Return full key object"),
|
||||
include_team_keys: bool = Query(False, description="Include all keys for teams that user is an admin of."),
|
||||
include_created_by_keys: bool = Query(False, description="Include keys created by the user"),
|
||||
|
|
@ -5943,6 +5948,7 @@ async def list_keys(
|
|||
agent_id=agent_id,
|
||||
use_substring_matching=use_substring_matching,
|
||||
expires_filter=expires if isinstance(expires, str) else None,
|
||||
search=search,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug("Successfully prepared response")
|
||||
|
|
@ -6162,6 +6168,16 @@ def _build_expires_where_clause(expires_filter: str, now: datetime) -> dict[str,
|
|||
return {"OR": [{"expires": None}, {"expires": {"gte": now}}]}
|
||||
|
||||
|
||||
def _build_key_search_where(search: str) -> KeySearchWhere:
|
||||
search_where: Final[KeySearchWhere] = {
|
||||
"OR": (
|
||||
{"token": search},
|
||||
{"key_alias": {"contains": search, "mode": "insensitive"}},
|
||||
)
|
||||
}
|
||||
return search_where
|
||||
|
||||
|
||||
def _build_key_filter_conditions(
|
||||
user_id: str | None,
|
||||
team_id: str | None,
|
||||
|
|
@ -6177,6 +6193,7 @@ def _build_key_filter_conditions(
|
|||
agent_id: str | None = None,
|
||||
use_substring_matching: bool = False,
|
||||
expires_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> Mapping[str, object]:
|
||||
"""Build filter conditions for key listing.
|
||||
|
||||
|
|
@ -6266,7 +6283,7 @@ def _build_key_filter_conditions(
|
|||
|
||||
# Apply team_id, project_id and access_group_id as global AND filters so they
|
||||
# narrow results across all visibility conditions (own keys, team keys, etc.)
|
||||
global_filters: Final[tuple[dict[str, object], ...]] = (
|
||||
global_filters: Final[tuple[Mapping[str, object], ...]] = (
|
||||
*(
|
||||
(
|
||||
{"key_alias": {"contains": key_alias, "mode": "insensitive"}}
|
||||
|
|
@ -6277,6 +6294,7 @@ def _build_key_filter_conditions(
|
|||
else ()
|
||||
),
|
||||
*(({"token": key_hash},) if key_hash and isinstance(key_hash, str) else ()),
|
||||
*((_build_key_search_where(search),) if isinstance(search, str) and search else ()),
|
||||
*(({"team_id": team_id},) if team_id and isinstance(team_id, str) else ()),
|
||||
*(({"project_id": project_id},) if project_id else ()),
|
||||
*(({"access_group_ids": {"hasSome": [access_group_id]}},) if access_group_id else ()),
|
||||
|
|
@ -6316,6 +6334,7 @@ async def _list_key_helper(
|
|||
agent_id: str | None = None,
|
||||
use_substring_matching: bool = False,
|
||||
expires_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> KeyListResponseObject:
|
||||
"""
|
||||
Helper function to list keys
|
||||
|
|
@ -6354,6 +6373,7 @@ async def _list_key_helper(
|
|||
agent_id=agent_id,
|
||||
use_substring_matching=use_substring_matching,
|
||||
expires_filter=expires_filter,
|
||||
search=search,
|
||||
)
|
||||
|
||||
# Calculate skip for pagination
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from collections.abc import Mapping
|
|||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -91,6 +92,36 @@ def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object
|
|||
return {"OR": ors}
|
||||
|
||||
|
||||
class _StartsWith(TypedDict):
|
||||
startsWith: ReadOnly[str]
|
||||
|
||||
|
||||
class _MemoryKeyWhere(TypedDict):
|
||||
key: ReadOnly[str | _StartsWith]
|
||||
|
||||
|
||||
class _MemoryIdWhere(TypedDict):
|
||||
memory_id: ReadOnly[str]
|
||||
|
||||
|
||||
class _MemorySearchWhere(TypedDict):
|
||||
OR: ReadOnly[tuple[_MemoryKeyWhere, _MemoryIdWhere]]
|
||||
|
||||
|
||||
def _key_filter(search: str | None, key_prefix: str | None, key: str | None) -> Mapping[str, object] | None:
|
||||
"""`search` matches a key prefix or an exact memory_id; otherwise `key_prefix` wins over `key`."""
|
||||
if search is not None:
|
||||
search_where: Final[_MemorySearchWhere] = {"OR": ({"key": {"startsWith": search}}, {"memory_id": search})}
|
||||
return search_where
|
||||
if key_prefix is not None:
|
||||
prefix_where: Final[_MemoryKeyWhere] = {"key": {"startsWith": key_prefix}}
|
||||
return prefix_where
|
||||
if key is not None:
|
||||
exact_where: Final[_MemoryKeyWhere] = {"key": key}
|
||||
return exact_where
|
||||
return None
|
||||
|
||||
|
||||
def _row_to_model(row: "prisma_models.LiteLLM_MemoryTable") -> LiteLLM_MemoryRow:
|
||||
return LiteLLM_MemoryRow(
|
||||
memory_id=row.memory_id,
|
||||
|
|
@ -326,6 +357,13 @@ async def list_memory(
|
|||
"Mutually exclusive with `key`; if both are provided, `key_prefix` wins."
|
||||
),
|
||||
),
|
||||
search: str | None = Query(
|
||||
None,
|
||||
description=(
|
||||
"Match entries whose key starts with this value or whose memory_id equals it. "
|
||||
"Takes precedence over `key_prefix` and `key` when provided."
|
||||
),
|
||||
),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=500),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
|
|
@ -333,22 +371,16 @@ async def list_memory(
|
|||
"""List memory entries visible to the caller."""
|
||||
prisma_client: Final = _require_prisma()
|
||||
|
||||
# Build the key filter first (prefix wins if both `key` and `key_prefix`
|
||||
# are passed). Then AND it with the visibility filter via an explicit
|
||||
# top-level "AND" — safer than `dict.update` since future visibility
|
||||
# filters could grow an "OR" key that would clobber this one if merged
|
||||
# by key.
|
||||
key_filter: Final[dict[str, object]] = {}
|
||||
if key_prefix is not None:
|
||||
key_filter["key"] = {"startsWith": key_prefix}
|
||||
elif key is not None:
|
||||
key_filter["key"] = key
|
||||
# AND the key filter with the visibility filter via an explicit top-level
|
||||
# "AND": both sides can carry an "OR" key (`search`, non-admin visibility),
|
||||
# so merging them by key would let one clobber the other and leak rows.
|
||||
key_filter: Final = _key_filter(search=search, key_prefix=key_prefix, key=key)
|
||||
|
||||
vis: Final = _visibility_filter(user_api_key_dict)
|
||||
where: Mapping[str, object]
|
||||
where: Mapping[str, object] | None
|
||||
if vis is None:
|
||||
where = key_filter
|
||||
elif not key_filter:
|
||||
elif key_filter is None:
|
||||
where = vis
|
||||
else:
|
||||
where = {"AND": [key_filter, vis]}
|
||||
|
|
|
|||
|
|
@ -2229,6 +2229,31 @@ async def calculate_spend(request: SpendCalculateRequest):
|
|||
)
|
||||
|
||||
|
||||
class _SpendLogSearchCondition(NamedTuple):
|
||||
sql: str
|
||||
params: tuple[object, ...]
|
||||
|
||||
|
||||
def _build_spend_log_search_condition(
|
||||
search: str,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
next_param_index: int,
|
||||
) -> _SpendLogSearchCondition:
|
||||
"""request_id (indexed) matches across all time; the unindexed id columns only inside the window."""
|
||||
raw: Final = f"${next_param_index}"
|
||||
window_start: Final = f"${next_param_index + 1}"
|
||||
window_end: Final = f"${next_param_index + 2}"
|
||||
sql: Final = (
|
||||
f"(request_id = {raw} OR ("
|
||||
f"\"startTime\" >= ({window_start}::timestamptz AT TIME ZONE 'UTC') "
|
||||
f"AND \"startTime\" <= ({window_end}::timestamptz AT TIME ZONE 'UTC') "
|
||||
f'AND (api_key = {raw} OR team_id = {raw} OR "user" = {raw} OR end_user = {raw} '
|
||||
f"OR session_id = {raw} OR model_id = {raw})))"
|
||||
)
|
||||
return _SpendLogSearchCondition(sql=sql, params=(search, start_date, end_date))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spend/logs/v2",
|
||||
tags=["Budget & Spend Tracking"],
|
||||
|
|
@ -2329,6 +2354,14 @@ async def ui_view_spend_logs(
|
|||
"UI route only, honored when sorting by startTime"
|
||||
),
|
||||
),
|
||||
search: str | None = fastapi.Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Match a log whose request_id, api_key (hash), team_id, user, end_user, "
|
||||
"session_id, or model_id equals this value. request_id matches across all time; the other columns "
|
||||
"match inside start_date/end_date, which stay required"
|
||||
),
|
||||
),
|
||||
):
|
||||
"""
|
||||
View spend logs with pagination support.
|
||||
|
|
@ -2392,8 +2425,10 @@ async def ui_view_spend_logs(
|
|||
try:
|
||||
is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
|
||||
is_request_id_lookup: Final = request_id is not None and not is_v2
|
||||
is_search_lookup: Final = search is not None
|
||||
search_owns_window: Final = is_search_lookup and not is_v2
|
||||
|
||||
if is_request_id_lookup:
|
||||
if is_request_id_lookup and not is_search_lookup:
|
||||
# request_id is the @id primary key: it identifies a single row, so a
|
||||
# time window is meaningless. The dashboard always sends a default 24h
|
||||
# window, which hid ids copied from an older page (LIT-3981). Drop the
|
||||
|
|
@ -2576,7 +2611,7 @@ async def ui_view_spend_logs(
|
|||
# Date range. Wrap the param side with `AT TIME ZONE 'UTC'` so comparison
|
||||
# against the plain `timestamp` column does not depend on the DB session
|
||||
# timezone (see #22529). Absent for a request_id-only lookup (see above).
|
||||
if start_date_obj is not None and end_date_obj is not None:
|
||||
if start_date_obj is not None and end_date_obj is not None and not search_owns_window:
|
||||
sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')")
|
||||
sql_params.append(start_date_obj)
|
||||
p += 1
|
||||
|
|
@ -2584,6 +2619,17 @@ async def ui_view_spend_logs(
|
|||
sql_params.append(end_date_obj)
|
||||
p += 1
|
||||
|
||||
if search is not None and start_date_obj is not None and end_date_obj is not None:
|
||||
search_condition: Final = _build_spend_log_search_condition(
|
||||
search=search,
|
||||
start_date=start_date_obj,
|
||||
end_date=end_date_obj,
|
||||
next_param_index=p,
|
||||
)
|
||||
sql_conditions.append(search_condition.sql)
|
||||
sql_params.extend(search_condition.params)
|
||||
p += len(search_condition.params) # rebind-ok: advances the file's shared $N placeholder counter
|
||||
|
||||
# Equality filters - read effective values from where_conditions (post-authorization)
|
||||
for sql_col, wc_key in [
|
||||
("team_id", "team_id"),
|
||||
|
|
@ -2662,7 +2708,13 @@ async def ui_view_spend_logs(
|
|||
sql_params.append(f"%{error_message}%")
|
||||
p += 1
|
||||
|
||||
if group_by_session is True and not is_v2 and not is_request_id_lookup and sort_by == "startTime":
|
||||
if (
|
||||
group_by_session is True
|
||||
and not is_v2
|
||||
and not is_request_id_lookup
|
||||
and not is_search_lookup
|
||||
and sort_by == "startTime"
|
||||
):
|
||||
return await _ui_session_grouped_spend_logs(
|
||||
prisma_client=prisma_client,
|
||||
sql_conditions=sql_conditions,
|
||||
|
|
@ -2696,7 +2748,7 @@ async def ui_view_spend_logs(
|
|||
_order_expr = order_column
|
||||
|
||||
joined_conditions: Final = " AND ".join(sql_conditions)
|
||||
session_grouping: Final = group_by_session is True
|
||||
session_grouping: Final = group_by_session is True and not is_search_lookup
|
||||
count_group_clause: Final = f"GROUP BY {_SESSION_GROUP_KEY_SQL}" if session_grouping else ""
|
||||
count_query: Final = f"""
|
||||
SELECT COUNT(*) AS total_count
|
||||
|
|
|
|||
|
|
@ -2,6 +2,23 @@ from datetime import datetime
|
|||
from typing import Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains
|
||||
|
||||
|
||||
class KeyTokenWhere(TypedDict):
|
||||
token: ReadOnly[str]
|
||||
|
||||
|
||||
class KeyAliasContainsWhere(TypedDict):
|
||||
key_alias: ReadOnly[InsensitiveContains]
|
||||
|
||||
|
||||
class KeySearchWhere(TypedDict):
|
||||
"""Prisma filter behind `/key/list?search=`: exact token or case-insensitive alias substring."""
|
||||
|
||||
OR: ReadOnly[tuple[KeyTokenWhere, KeyAliasContainsWhere]]
|
||||
|
||||
|
||||
class BulkUpdateKeyRequestItem(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
|
@ -8,10 +9,12 @@ from litellm_enterprise.proxy.audit_logging_endpoints import router as audit_rou
|
|||
from litellm_enterprise.types.proxy.audit_logging_endpoints import AuditLogResponse
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
# Create an app with just the audit router for testing
|
||||
app = FastAPI()
|
||||
app.include_router(audit_router)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role="proxy_admin")
|
||||
client = TestClient(app)
|
||||
|
||||
# Mock data for testing
|
||||
|
|
@ -130,3 +133,45 @@ async def test_get_audit_log_by_id_not_found(mock_prisma_client):
|
|||
data = response.json()
|
||||
assert "message" in data["detail"]
|
||||
assert "not found" in data["detail"]["message"].lower()
|
||||
|
||||
|
||||
def _list_audit_logs_where(mock_prisma_client: MagicMock, query: str) -> dict[str, object]:
|
||||
mock_prisma_client.db.litellm_auditlog.find_many.return_value = []
|
||||
mock_prisma_client.db.litellm_auditlog.count.return_value = 0
|
||||
|
||||
response: Final = client.get(f"/audit?{query}")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
find_many_where: Final = mock_prisma_client.db.litellm_auditlog.find_many.call_args.kwargs["where"]
|
||||
assert mock_prisma_client.db.litellm_auditlog.count.call_args.kwargs["where"] == find_many_where
|
||||
return find_many_where
|
||||
|
||||
|
||||
def test_search_matches_any_id_column_alongside_the_other_filters(mock_prisma_client):
|
||||
where: Final = _list_audit_logs_where(mock_prisma_client, "search=abc-123&action=create&object_team_id=team-1")
|
||||
|
||||
assert where == {
|
||||
"action": "create",
|
||||
"AND": (
|
||||
{
|
||||
"OR": [
|
||||
{"before_value": {"path": ["team_id"], "string_contains": "team-1"}},
|
||||
{"updated_values": {"path": ["team_id"], "string_contains": "team-1"}},
|
||||
]
|
||||
},
|
||||
{
|
||||
"OR": (
|
||||
{"id": "abc-123"},
|
||||
{"changed_by": "abc-123"},
|
||||
{"object_id": "abc-123"},
|
||||
{"changed_by_api_key": "abc-123"},
|
||||
)
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_an_empty_search_leaves_the_where_clause_unchanged(mock_prisma_client):
|
||||
where: Final = _list_audit_logs_where(mock_prisma_client, "action=create&search=")
|
||||
|
||||
assert where == {"action": "create"}
|
||||
|
|
|
|||
|
|
@ -6347,6 +6347,93 @@ def test_build_key_filter_conditions_key_hash_narrows_team_admin_visibility():
|
|||
assert {"token": "hashed-token-123"} in where["AND"], f"key_hash not ANDed: {where}"
|
||||
|
||||
|
||||
def _search_clause(search: str, token: str) -> dict:
|
||||
return {"OR": [{"token": token}, {"key_alias": {"contains": search, "mode": "insensitive"}}]}
|
||||
|
||||
|
||||
def test_build_key_filter_conditions_search_ors_token_and_alias_contains():
|
||||
"""
|
||||
LIT-4741: `search` matches a key by its alias (case-insensitive contains) OR by
|
||||
its ID (the token column), with the pasted value used verbatim.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_build_key_filter_conditions,
|
||||
)
|
||||
|
||||
hashed_where = json.loads(
|
||||
json.dumps(
|
||||
_build_key_filter_conditions(
|
||||
user_id=None,
|
||||
team_id=None,
|
||||
organization_id=None,
|
||||
key_alias=None,
|
||||
key_hash=None,
|
||||
exclude_team_id=None,
|
||||
admin_team_ids=None,
|
||||
search="already-hashed-token",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert _search_clause("already-hashed-token", "already-hashed-token") in hashed_where["AND"], (
|
||||
f"hashed search not used verbatim: {hashed_where}"
|
||||
)
|
||||
|
||||
|
||||
def test_build_key_filter_conditions_search_narrows_team_admin_visibility():
|
||||
"""
|
||||
LIT-4741, same class as LIT-3243: `search` must be a top-level AND so it
|
||||
narrows a team admin's admin-team branch instead of being bypassed by it.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_build_key_filter_conditions,
|
||||
)
|
||||
|
||||
where = json.loads(
|
||||
json.dumps(
|
||||
_build_key_filter_conditions(
|
||||
user_id="team-admin-user",
|
||||
team_id=None,
|
||||
organization_id=None,
|
||||
key_alias=None,
|
||||
key_hash=None,
|
||||
exclude_team_id=None,
|
||||
admin_team_ids=["team-a"],
|
||||
member_team_ids=["team-a"],
|
||||
include_created_by_keys=False,
|
||||
search="member-key-id",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert where.get("AND"), f"expected top-level AND, got: {where}"
|
||||
assert _search_clause("member-key-id", "member-key-id") in where["AND"], f"search not ANDed: {where}"
|
||||
assert json.dumps({"team_id": {"in": ["team-a"]}}) in json.dumps(where)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_key_helper_applies_search_to_prisma_where():
|
||||
"""LIT-4741: `search` given to _list_key_helper must reach the Prisma where clause."""
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_find_many = AsyncMock(return_value=[])
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_many = mock_find_many
|
||||
mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0)
|
||||
|
||||
await _list_key_helper(
|
||||
prisma_client=mock_prisma_client,
|
||||
page=1,
|
||||
size=50,
|
||||
user_id=None,
|
||||
team_id=None,
|
||||
organization_id=None,
|
||||
key_alias=None,
|
||||
key_hash=None,
|
||||
search="key-id-123",
|
||||
)
|
||||
|
||||
where = json.loads(json.dumps(mock_find_many.call_args.kwargs["where"]))
|
||||
assert _search_clause("key-id-123", "key-id-123") in where["AND"], f"search not in Prisma where: {where}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_key_negative_max_budget():
|
||||
"""
|
||||
|
|
@ -14870,6 +14957,16 @@ async def test_list_keys_non_admin_cannot_opt_into_substring():
|
|||
assert kwargs["user_id"] == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keys_search_is_honored_for_non_admin():
|
||||
"""LIT-4741: unlike substring_matching, `search` is not admin-gated. A non-admin's
|
||||
search reaches the helper while their own-user scoping stays in place."""
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice")
|
||||
kwargs = await _list_keys_capture_helper_kwargs(user, user_id=None, search="key-id-123")
|
||||
assert kwargs["search"] == "key-id-123"
|
||||
assert kwargs["user_id"] == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_session_token_delegation_ceiling_blocked_by_team_budget():
|
||||
team = LiteLLM_TeamTableCachedObj(team_id="team-1", max_budget=50.0)
|
||||
|
|
|
|||
|
|
@ -615,6 +615,96 @@ class TestMemoryEndpoints:
|
|||
assert keys == {"user:profile"}
|
||||
assert body["total"] == 1
|
||||
|
||||
def test_list_memory_search_matches_key_prefix_or_memory_id_within_scope(self):
|
||||
"""
|
||||
`search` matches a key prefix OR an exact memory_id, and stays ANDed
|
||||
with the visibility filter so a pasted foreign id cannot leak a row.
|
||||
"""
|
||||
table = self.prisma.db.litellm_memorytable
|
||||
table.rows.extend(
|
||||
[
|
||||
_make_row(memory_id="mem-own", key="user:profile", user_id="user-a", team_id=None),
|
||||
_make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None),
|
||||
_make_row(memory_id="mem-foreign", key="user:secret", user_id="user-b", team_id=None),
|
||||
]
|
||||
)
|
||||
client = _make_client(_user_auth("user-a", "team-a"))
|
||||
with _patch_prisma(self.prisma):
|
||||
by_id = client.get("/v1/memory?search=mem-target")
|
||||
by_prefix = client.get("/v1/memory?search=user:")
|
||||
foreign_id = client.get("/v1/memory?search=mem-foreign")
|
||||
|
||||
assert by_id.status_code == 200, by_id.text
|
||||
assert [m["memory_id"] for m in by_id.json()["memories"]] == ["mem-target"]
|
||||
assert by_id.json()["total"] == 1
|
||||
|
||||
assert by_prefix.status_code == 200, by_prefix.text
|
||||
assert {m["key"] for m in by_prefix.json()["memories"]} == {"user:profile"}
|
||||
assert by_prefix.json()["total"] == 1
|
||||
|
||||
assert foreign_id.status_code == 200, foreign_id.text
|
||||
assert foreign_id.json()["memories"] == []
|
||||
assert foreign_id.json()["total"] == 0
|
||||
|
||||
def test_list_memory_search_by_memory_id_for_admin_sees_any_scope(self):
|
||||
"""Admins have no visibility filter, so an id search returns the row whoever owns it."""
|
||||
table = self.prisma.db.litellm_memorytable
|
||||
table.rows.extend(
|
||||
[
|
||||
_make_row(memory_id="mem-a", key="a", user_id="user-a", team_id=None),
|
||||
_make_row(memory_id="mem-b", key="b", user_id="user-b", team_id=None),
|
||||
]
|
||||
)
|
||||
client = _make_client(_admin_auth())
|
||||
with _patch_prisma(self.prisma):
|
||||
resp = client.get("/v1/memory?search=mem-b")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert [m["memory_id"] for m in resp.json()["memories"]] == ["mem-b"]
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
def test_list_memory_search_wins_over_key_prefix(self):
|
||||
"""When both are sent, `search` decides the match and `key_prefix` is ignored."""
|
||||
table = self.prisma.db.litellm_memorytable
|
||||
table.rows.extend(
|
||||
[
|
||||
_make_row(memory_id="mem-own", key="user:profile", user_id="user-a", team_id=None),
|
||||
_make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None),
|
||||
]
|
||||
)
|
||||
client = _make_client(_user_auth("user-a", "team-a"))
|
||||
with _patch_prisma(self.prisma):
|
||||
resp = client.get("/v1/memory?search=mem-target&key_prefix=user:")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert [m["memory_id"] for m in resp.json()["memories"]] == ["mem-target"]
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
def test_list_memory_key_prefix_never_matches_memory_id(self):
|
||||
"""`key_prefix` stays a pure key-prefix match; only `search` consults memory_id."""
|
||||
table = self.prisma.db.litellm_memorytable
|
||||
table.rows.append(_make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None))
|
||||
client = _make_client(_user_auth("user-a", "team-a"))
|
||||
with _patch_prisma(self.prisma):
|
||||
resp = client.get("/v1/memory?key_prefix=mem-target")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["memories"] == []
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
def test_list_memory_key_exact_filter(self):
|
||||
"""`key` is an exact match, never a prefix."""
|
||||
table = self.prisma.db.litellm_memorytable
|
||||
table.rows.extend(
|
||||
[
|
||||
_make_row(memory_id="m1", key="user:profile", user_id="user-a", team_id=None),
|
||||
_make_row(memory_id="m2", key="user:profile:archived", user_id="user-a", team_id=None),
|
||||
]
|
||||
)
|
||||
client = _make_client(_user_auth("user-a", "team-a"))
|
||||
with _patch_prisma(self.prisma):
|
||||
resp = client.get("/v1/memory?key=user:profile")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert [m["memory_id"] for m in resp.json()["memories"]] == ["m1"]
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
def test_list_memory_admin_sees_all(self):
|
||||
table = self.prisma.db.litellm_memorytable
|
||||
table.rows.extend(
|
||||
|
|
|
|||
|
|
@ -58,6 +58,24 @@ def _filter_logs_by_date_range(logs, where):
|
|||
return filtered
|
||||
|
||||
|
||||
_SEARCH_CLAUSE_RE = re.compile(
|
||||
r'\(request_id = \$(\d+) OR \("startTime" >= \(\$(\d+)::timestamptz AT TIME ZONE \'UTC\'\) '
|
||||
r'AND "startTime" <= \(\$(\d+)::timestamptz AT TIME ZONE \'UTC\'\) '
|
||||
r'AND \(api_key = \$\1 OR team_id = \$\1 OR "user" = \$\1 OR end_user = \$\1 '
|
||||
r"OR session_id = \$\1 OR model_id = \$\1\)\)\)"
|
||||
)
|
||||
|
||||
|
||||
def _matches_spend_log_search(log, search):
|
||||
"""Mirror the search clause: request_id across all time, the other id columns inside the window."""
|
||||
if log.get("request_id") == search["value"]:
|
||||
return True
|
||||
if not _filter_logs_by_date_range([log], {"startTime": {"gte": search["gte"], "lte": search["lte"]}}):
|
||||
return False
|
||||
columns = ("api_key", "team_id", "user", "end_user", "session_id", "model_id")
|
||||
return any(log.get(col) == search["value"] for col in columns)
|
||||
|
||||
|
||||
def _reconstruct_ui_where_from_sql(sql_query, params):
|
||||
"""
|
||||
Rebuild the Prisma-style ``where`` dict the filter_fns below expect from the
|
||||
|
|
@ -77,6 +95,16 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
|
|||
def _iso(value):
|
||||
return value.isoformat() if hasattr(value, "isoformat") else str(value)
|
||||
|
||||
search_clause = _SEARCH_CLAUSE_RE.search(clause.group(1))
|
||||
if search_clause:
|
||||
raw_index, start_index, end_index = (int(g) for g in search_clause.groups())
|
||||
where["search"] = {
|
||||
"value": params[raw_index - 1],
|
||||
"gte": _iso(params[start_index - 1]),
|
||||
"lte": _iso(params[end_index - 1]),
|
||||
}
|
||||
remaining = clause.group(1) if search_clause is None else clause.group(1).replace(search_clause.group(0), "")
|
||||
|
||||
eq_cols = {
|
||||
"team_id": "team_id",
|
||||
'"user"': "user",
|
||||
|
|
@ -89,7 +117,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
|
|||
}
|
||||
date_bounds: dict = {}
|
||||
metadata_conds: list = []
|
||||
for cond in (c.strip() for c in clause.group(1).split(" AND ")):
|
||||
for cond in (c.strip() for c in remaining.split(" AND ")):
|
||||
gte = re.search(r'"startTime" >= \(\$(\d+)', cond)
|
||||
lte = re.search(r'"startTime" <= \(\$(\d+)', cond)
|
||||
alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond)
|
||||
|
|
@ -2352,6 +2380,208 @@ async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only(
|
|||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
def test_build_spend_log_search_condition_windows_every_branch_except_request_id():
|
||||
"""LIT-4741: request_id matches across all time; the six other id columns only inside the window,
|
||||
all comparing the pasted value verbatim."""
|
||||
start = datetime.datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
end = datetime.datetime(2026, 8, 2, tzinfo=timezone.utc)
|
||||
|
||||
condition = spend_management_endpoints._build_spend_log_search_condition(
|
||||
search="key-hash-7", start_date=start, end_date=end, next_param_index=3
|
||||
)
|
||||
|
||||
assert condition.sql == (
|
||||
"(request_id = $3 OR (\"startTime\" >= ($4::timestamptz AT TIME ZONE 'UTC') "
|
||||
"AND \"startTime\" <= ($5::timestamptz AT TIME ZONE 'UTC') "
|
||||
'AND (api_key = $3 OR team_id = $3 OR "user" = $3 OR end_user = $3 OR session_id = $3 OR model_id = $3)))'
|
||||
)
|
||||
assert condition.params == ("key-hash-7", start, end)
|
||||
|
||||
|
||||
def _search_fixture_logs(today):
|
||||
recent = (today - datetime.timedelta(days=1)).isoformat()
|
||||
old = (today - datetime.timedelta(days=90)).isoformat()
|
||||
base = {
|
||||
"api_key": "hashed-other",
|
||||
"user": "user-x",
|
||||
"team_id": "team-x",
|
||||
"end_user": "cust-x",
|
||||
"session_id": "sess-x",
|
||||
"model_id": "mdl-x",
|
||||
"spend": 0.01,
|
||||
"model": "gpt-4",
|
||||
}
|
||||
return [
|
||||
{**base, "request_id": "req-session", "session_id": "sess-42", "startTime": recent},
|
||||
{**base, "request_id": "req-session-old", "session_id": "sess-42", "startTime": old},
|
||||
{**base, "request_id": "req-key", "api_key": "hashed-7", "startTime": recent},
|
||||
{**base, "request_id": "req-team", "team_id": "team-7", "startTime": recent},
|
||||
{**base, "request_id": "req-user", "user": "user-7", "startTime": recent},
|
||||
{**base, "request_id": "req-end-user", "end_user": "cust-7", "startTime": recent},
|
||||
{**base, "request_id": "req-model", "model_id": "mdl-7", "startTime": recent},
|
||||
]
|
||||
|
||||
|
||||
def _search_filter_fn(logs, captured):
|
||||
def filter_fn(where):
|
||||
captured["where"] = where
|
||||
rows = _filter_logs_by_date_range(logs, where)
|
||||
if "user" in where:
|
||||
rows = [row for row in rows if row["user"] == where["user"]]
|
||||
if "search" in where:
|
||||
rows = [row for row in rows if _matches_spend_log_search(row, where["search"])]
|
||||
return rows
|
||||
|
||||
return filter_fn
|
||||
|
||||
|
||||
def _five_day_window(today):
|
||||
return {
|
||||
"start_date": (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_date": today.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"search,expected_request_ids",
|
||||
[
|
||||
("req-session-old", {"req-session-old"}),
|
||||
("sess-42", {"req-session"}),
|
||||
("hashed-7", {"req-key"}),
|
||||
("team-7", {"req-team"}),
|
||||
("user-7", {"req-user"}),
|
||||
("cust-7", {"req-end-user"}),
|
||||
("mdl-7", {"req-model"}),
|
||||
("no-such-id", set()),
|
||||
],
|
||||
)
|
||||
async def test_ui_view_spend_logs_search_matches_any_id(client, monkeypatch, search, expected_request_ids):
|
||||
"""LIT-4741: one box matches any id column. A request_id is found across all time (the 5-day
|
||||
window excludes the 90-day-old row), every other column only inside the window, and a raw
|
||||
sk- key is hashed before it is compared with api_key. The window is not applied globally."""
|
||||
today = datetime.datetime.now(timezone.utc)
|
||||
logs = _search_fixture_logs(today)
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)),
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={"search": search, **_five_day_window(today)},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert {row["request_id"] for row in data["data"]} == expected_request_ids
|
||||
assert data["total"] == len(expected_request_ids)
|
||||
assert "startTime" not in captured["where"]
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_logs_v2_search_keeps_global_window(client, monkeypatch):
|
||||
"""The public route keeps the caller's window on the whole query, so a search only finds rows
|
||||
inside it even by request_id; the windowless request_id branch is a dashboard-only relaxation."""
|
||||
today = datetime.datetime.now(timezone.utc)
|
||||
logs = _search_fixture_logs(today)
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)),
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/v2",
|
||||
params={"search": "req-session-old", **_five_day_window(today)},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["data"] == []
|
||||
assert data["total"] == 0
|
||||
assert "startTime" in captured["where"]
|
||||
assert captured["where"]["search"]["value"] == "req-session-old"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
{"search": "req-old"},
|
||||
{"search": "req-old", "request_id": "req-old"},
|
||||
],
|
||||
)
|
||||
async def test_ui_view_spend_logs_search_requires_dates(client, monkeypatch, params):
|
||||
"""A search needs the window for its non-request_id branches, so it stays required even
|
||||
alongside a request_id, which on its own may drop the window."""
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma([], lambda where: []),
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
try:
|
||||
response = client.get("/spend/logs/ui", params=params, headers={"Authorization": "Bearer sk-test"})
|
||||
assert response.status_code == 400
|
||||
assert "date" in response.text.lower()
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"search,expected_request_ids",
|
||||
[("sess-9", {"req-own"}), ("req-foreign", set())],
|
||||
)
|
||||
async def test_ui_view_spend_logs_search_keeps_non_admin_scope(client, monkeypatch, search, expected_request_ids):
|
||||
"""A search is scoped like any other listing: an internal user only sees their own rows even
|
||||
when the id is on someone else's row, and the request_id ownership shortcut is not used."""
|
||||
yesterday = (datetime.datetime.now(timezone.utc) - datetime.timedelta(days=1)).isoformat()
|
||||
base = {"api_key": "hashed-key", "team_id": None, "spend": 0.01, "startTime": yesterday, "model": "gpt-4"}
|
||||
logs = [
|
||||
{**base, "request_id": "req-own", "user": "internal_user_1", "session_id": "sess-9"},
|
||||
{**base, "request_id": "req-own-other", "user": "internal_user_1", "session_id": "sess-other"},
|
||||
{**base, "request_id": "req-foreign", "user": "internal_user_2", "session_id": "sess-9"},
|
||||
]
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
ownership_check = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.spend_tracking.spend_management_endpoints._assert_user_can_view_request_id",
|
||||
ownership_check,
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="internal_user_1"
|
||||
)
|
||||
try:
|
||||
start_date, end_date = _default_date_range()
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={"search": search, "start_date": start_date, "end_date": end_date},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert {row["request_id"] for row in response.json()["data"]} == expected_request_ids
|
||||
assert captured["where"]["user"] == "internal_user_1"
|
||||
ownership_check.assert_not_awaited()
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_unauthorized(client):
|
||||
# Test without authorization header
|
||||
|
|
@ -6351,3 +6581,46 @@ async def test_ui_view_spend_logs_group_by_session_offset_for_non_starttime_sort
|
|||
assert "OFFSET" in emitted_sql[1]
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_search_returns_flat_rows_when_grouping_by_session(client, monkeypatch):
|
||||
"""The dashboard lists sessions by default; a search for an id lists every matching row instead,
|
||||
so both calls of a session show up rather than one representative, and no session cursor is returned."""
|
||||
rows = [_session_representative_row("req-1", "sess-1"), _session_representative_row("req-2", "sess-1")]
|
||||
|
||||
async def mock_query_raw(sql_query, *params):
|
||||
if "mcp_tool_call_count" in sql_query:
|
||||
return []
|
||||
grouped = "DISTINCT ON" in sql_query or "GROUP BY" in sql_query
|
||||
visible = rows[:1] if grouped else rows
|
||||
if "COUNT(*)" in sql_query:
|
||||
return [{"total_count": len(visible)}]
|
||||
return visible
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.query_raw = AsyncMock(side_effect=mock_query_raw)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
|
||||
)
|
||||
try:
|
||||
start_date, end_date = _default_date_range()
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={
|
||||
"search": "sess-1",
|
||||
"group_by_session": "true",
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert [row["request_id"] for row in data["data"]] == ["req-1", "req-2"]
|
||||
assert data["total"] == 2
|
||||
assert "next_session_cursor" not in data
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ async def test_spend_logs_ui_wraps_params_in_at_time_zone_utc(monkeypatch):
|
|||
api_key=None,
|
||||
user_id=None,
|
||||
request_id=None,
|
||||
search=None,
|
||||
start_date="2026-02-16 00:00:00",
|
||||
end_date="2026-02-16 23:59:59",
|
||||
page=1,
|
||||
|
|
@ -247,6 +248,7 @@ async def test_spend_logs_ui_uses_bounded_count_not_full_scan(monkeypatch):
|
|||
api_key=None,
|
||||
user_id=None,
|
||||
request_id=None,
|
||||
search=None,
|
||||
start_date="2026-02-16 00:00:00",
|
||||
end_date="2026-02-16 23:59:59",
|
||||
page=1,
|
||||
|
|
@ -314,6 +316,7 @@ async def test_spend_logs_ui_caps_total_for_large_result_sets(monkeypatch):
|
|||
api_key=None,
|
||||
user_id=None,
|
||||
request_id=None,
|
||||
search=None,
|
||||
start_date="2026-02-16 00:00:00",
|
||||
end_date="2026-02-16 23:59:59",
|
||||
page=1,
|
||||
|
|
@ -359,6 +362,7 @@ async def test_spend_logs_ui_empty_page_reports_zero_total(monkeypatch):
|
|||
api_key=None,
|
||||
user_id=None,
|
||||
request_id=None,
|
||||
search=None,
|
||||
start_date="2026-02-16 00:00:00",
|
||||
end_date="2026-02-16 23:59:59",
|
||||
page=1,
|
||||
|
|
@ -406,6 +410,7 @@ async def test_spend_logs_ui_out_of_range_page_keeps_total(monkeypatch):
|
|||
api_key=None,
|
||||
user_id=None,
|
||||
request_id=None,
|
||||
search=None,
|
||||
start_date="2026-02-16 00:00:00",
|
||||
end_date="2026-02-16 23:59:59",
|
||||
page=99,
|
||||
|
|
@ -552,6 +557,7 @@ async def test_spend_logs_ui_group_by_session_paginates_sessions(monkeypatch):
|
|||
api_key=None,
|
||||
user_id=None,
|
||||
request_id=None,
|
||||
search=None,
|
||||
start_date="2026-02-16 00:00:00",
|
||||
end_date="2026-02-16 23:59:59",
|
||||
page=1,
|
||||
|
|
@ -616,6 +622,7 @@ async def test_spend_logs_ui_group_by_session_offset_pages_for_other_sorts(monke
|
|||
api_key=None,
|
||||
user_id=None,
|
||||
request_id=None,
|
||||
search=None,
|
||||
start_date="2026-02-16 00:00:00",
|
||||
end_date="2026-02-16 23:59:59",
|
||||
page=2,
|
||||
|
|
@ -664,6 +671,7 @@ async def test_spend_logs_ui_request_id_lookup_with_grouping_returns_exact_row(m
|
|||
api_key=None,
|
||||
user_id=None,
|
||||
request_id="req-deep-link",
|
||||
search=None,
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
page=1,
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ describe("AgentsTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const search = screen.getByPlaceholderText("Search agent names or descriptions...");
|
||||
const search = screen.getByPlaceholderText("Search agents by name, ID, or description...");
|
||||
await user.type(search, "billing");
|
||||
expect(screen.getByText("Billing Router")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Second Agent")).not.toBeInTheDocument();
|
||||
|
|
@ -101,11 +101,36 @@ describe("AgentsTable", () => {
|
|||
expect(screen.queryByText("Billing Router")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters agents by a pasted agent_id so only that agent's row survives", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentsTable
|
||||
agents={[
|
||||
makeAgent({ agent_id: "5f3c2a1b-9d8e-4f7a-b6c5-d4e3f2a1b0c9", agent_name: "Billing Router" }),
|
||||
makeAgent({ agent_id: "0a9b8c7d-6e5f-4a3b-8c2d-1e0f9a8b7c6d", agent_name: "Second Agent" }),
|
||||
]}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
const search = screen.getByPlaceholderText("Search agents by name, ID, or description...");
|
||||
await user.click(search);
|
||||
await user.paste("5f3c2a1b-9d8e-4f7a-b6c5-d4e3f2a1b0c9");
|
||||
expect(screen.getByText("Billing Router")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Second Agent")).not.toBeInTheDocument();
|
||||
|
||||
await user.clear(search);
|
||||
await user.paste("ffffffff-0000-4000-8000-000000000000");
|
||||
expect(screen.queryByText("Billing Router")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Second Agent")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No matching agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the no-match empty state when the search matches nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentsTable agents={[makeAgent()]} {...baseProps} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Search agent names or descriptions..."), "zzzz");
|
||||
await user.type(screen.getByPlaceholderText("Search agents by name, ID, or description..."), "zzzz");
|
||||
expect(screen.queryByText("Test Agent")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No matching agents")).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,7 +55,12 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const filteredAgents = useMemo(
|
||||
() => filterBySearchTerm(agents, searchTerm, (agent) => [agent.agent_name, agent.agent_card_params?.description]),
|
||||
() =>
|
||||
filterBySearchTerm(agents, searchTerm, (agent) => [
|
||||
agent.agent_name,
|
||||
agent.agent_id,
|
||||
agent.agent_card_params?.description,
|
||||
]),
|
||||
[agents, searchTerm],
|
||||
);
|
||||
|
||||
|
|
@ -83,7 +88,7 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search agent names or descriptions..."
|
||||
placeholder="Search agents by name, ID, or description..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -518,6 +518,24 @@ describe("useKeys", () => {
|
|||
const callUrl = mockFetch.mock.calls[0][0];
|
||||
expect(callUrl).not.toContain("agent_id");
|
||||
});
|
||||
|
||||
it("sends the combined alias-or-ID search as the search param, separate from key_alias and key_hash", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockKeysResponse,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useKeys(1, 10, { search: "pasted-key-id" }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
const callUrl = new URL(mockFetch.mock.calls[0][0], "http://localhost");
|
||||
expect(callUrl.searchParams.get("search")).toBe("pasted-key-id");
|
||||
expect(callUrl.searchParams.has("key_alias")).toBe(false);
|
||||
expect(callUrl.searchParams.has("key_hash")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeletedKeys", () => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface KeyListCallOptions {
|
|||
selectedKeyAlias?: string | null;
|
||||
userID?: string | null;
|
||||
keyHash?: string | null;
|
||||
search?: string | null;
|
||||
sortBy?: string | null;
|
||||
sortOrder?: string | null;
|
||||
expand?: string | null;
|
||||
|
|
@ -61,6 +62,7 @@ const keyListCall = async (accessToken: string, page: number, pageSize: number,
|
|||
organization_id: options.organizationID,
|
||||
key_alias: options.selectedKeyAlias,
|
||||
key_hash: options.keyHash,
|
||||
search: options.search,
|
||||
user_id: options.userID,
|
||||
page,
|
||||
size: pageSize,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ describe("MemoryTable", () => {
|
|||
it("shows the filtered-empty copy when a search is active", () => {
|
||||
render(<MemoryTable {...baseProps} data={[]} rowCount={0} hasActiveSearch={true} />);
|
||||
expect(screen.getByText("No matching memories")).toBeInTheDocument();
|
||||
expect(screen.getByText("No memories match your search.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No memories stored yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -128,6 +129,7 @@ describe("MemoryTable", () => {
|
|||
const onRefresh = vi.fn();
|
||||
render(<MemoryTable {...baseProps} onSearchChange={onSearchChange} onRefresh={onRefresh} />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Search by key prefix or memory ID…")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "u" } });
|
||||
expect(onSearchChange).toHaveBeenCalledWith("u");
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function MemoryEmptyState({ hasActiveSearch }: { hasActiveSearch: boolean }) {
|
|||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasActiveSearch
|
||||
? "No memories have keys starting with your search."
|
||||
? "No memories match your search."
|
||||
: "Memories your agents store under /v1/memory will appear here."}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -81,7 +81,7 @@ export function MemoryTable({
|
|||
table={table}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder='Filter by key prefix, e.g. "user:"'
|
||||
searchPlaceholder="Search by key prefix or memory ID…"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
showViewOptions={false}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MemoryRow } from "@/components/networking";
|
||||
|
||||
|
|
@ -13,10 +14,13 @@ interface CapturedTableProps {
|
|||
rowCount: number;
|
||||
data: MemoryRow[];
|
||||
hasActiveSearch: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onPaginationChange: (state: PaginationState) => void;
|
||||
onViewClick: (row: MemoryRow) => void;
|
||||
}
|
||||
|
||||
const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null }));
|
||||
const fetchMemoryListMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./MemoryTable", () => ({
|
||||
MemoryTable: function MemoryTableMock(props: CapturedTableProps) {
|
||||
|
|
@ -25,6 +29,15 @@ vi.mock("./MemoryTable", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/components/networking")>()),
|
||||
fetchMemoryList: fetchMemoryListMock,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-pacer/debouncer", () => ({
|
||||
useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }],
|
||||
}));
|
||||
|
||||
const renderView = (accessToken: string | null) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
|
|
@ -35,6 +48,28 @@ const renderView = (accessToken: string | null) => {
|
|||
};
|
||||
|
||||
describe("MemoryView", () => {
|
||||
beforeEach(() => {
|
||||
fetchMemoryListMock.mockReset();
|
||||
fetchMemoryListMock.mockResolvedValue({ memories: [], total: 0 });
|
||||
});
|
||||
|
||||
it("queries the server with the search box value as `search` and resets to page 1", async () => {
|
||||
renderView("token");
|
||||
await waitFor(() => expect(fetchMemoryListMock).toHaveBeenCalled());
|
||||
|
||||
act(() => captured.current?.onPaginationChange({ pageIndex: 2, pageSize: 50 }));
|
||||
await waitFor(() =>
|
||||
expect(fetchMemoryListMock).toHaveBeenLastCalledWith("token", expect.objectContaining({ page: 3 })),
|
||||
);
|
||||
|
||||
act(() => captured.current?.onSearchChange("mem-abc123"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMemoryListMock).toHaveBeenLastCalledWith("token", { search: "mem-abc123", page: 1, pageSize: 50 }),
|
||||
);
|
||||
expect(captured.current?.hasActiveSearch).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the table out of the skeleton state when the token is null (disabled query)", () => {
|
||||
renderView(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,10 +43,8 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
queryKey: [MEMORY_LIST_KEY, debouncedSearch, pagination.pageIndex, pagination.pageSize],
|
||||
queryFn: () => {
|
||||
if (!accessToken) throw new Error("Access token required");
|
||||
// Prefix search matches the Redis-style mental model (namespace scan):
|
||||
// typing "user:" finds "user:profile", "user:prefs", etc.
|
||||
return fetchMemoryList(accessToken, {
|
||||
keyPrefix: debouncedSearch || undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -542,6 +542,19 @@ describe("server-side filtering – the LIT-4080 regression guard", () => {
|
|||
expect((lastCall[2] ?? {}).userID).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the search box as the combined alias-or-ID search rather than the key-alias filter", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by key alias or ID/), { target: { value: mockKey.token } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: mockKey.token }));
|
||||
});
|
||||
const lastOptions = mockUseKeys.mock.calls.at(-1)?.[2];
|
||||
expect(lastOptions?.selectedKeyAlias).toBeUndefined();
|
||||
expect(lastOptions?.keyHash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination display – total count comes from useKeys", () => {
|
||||
|
|
@ -663,7 +676,7 @@ describe("table state lives in the URL so it survives leaving and returning to t
|
|||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
3,
|
||||
25,
|
||||
expect.objectContaining({ selectedKeyAlias: "prod", sortBy: "spend", sortOrder: "asc" }),
|
||||
expect.objectContaining({ search: "prod", sortBy: "spend", sortOrder: "asc" }),
|
||||
);
|
||||
});
|
||||
expect(screen.getByPlaceholderText(/Search by key alias/)).toHaveValue("prod");
|
||||
|
|
@ -736,7 +749,7 @@ describe("table state lives in the URL so it survives leaving and returning to t
|
|||
fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "prod" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "prod" }));
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: "prod" }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "page")).toBeNull();
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
const keyListOptions = {
|
||||
teamID: appliedFilters.team_id || undefined,
|
||||
organizationID: appliedFilters.org_id || undefined,
|
||||
selectedKeyAlias: searchQuery.trim() || undefined,
|
||||
search: searchQuery.trim() || undefined,
|
||||
userID: appliedFilters.user_id || undefined,
|
||||
keyHash: appliedFilters.key_hash || undefined,
|
||||
sortBy,
|
||||
|
|
@ -291,7 +291,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
table={table}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="Search by key alias…"
|
||||
searchPlaceholder="Search by key alias or ID…"
|
||||
onRefresh={() => refetch?.()}
|
||||
isRefreshing={isFetching}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
|
|
|
|||
|
|
@ -854,3 +854,48 @@ describe("userListCall search serialization", () => {
|
|||
expect(lastParams(mockFetch).get("user_email")).toBe("ada@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchMemoryList search serialization", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockOkFetch = () => {
|
||||
const emptyPage = { memories: [], total: 0 };
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(emptyPage) } as any);
|
||||
global.fetch = mockFetch as any;
|
||||
return mockFetch;
|
||||
};
|
||||
|
||||
const lastParams = (mockFetch: ReturnType<typeof vi.fn>) => {
|
||||
const [url] = mockFetch.mock.calls.at(-1) ?? [];
|
||||
return new URL(url as string, "http://example.com").searchParams;
|
||||
};
|
||||
|
||||
it("sends the search box value as search and omits key_prefix and key", async () => {
|
||||
const mockFetch = mockOkFetch();
|
||||
|
||||
await Networking.fetchMemoryList("token", { search: "mem-abc123", page: 1, pageSize: 50 });
|
||||
|
||||
const params = lastParams(mockFetch);
|
||||
expect(params.get("search")).toBe("mem-abc123");
|
||||
expect(params.has("key_prefix")).toBe(false);
|
||||
expect(params.has("key")).toBe(false);
|
||||
expect(params.get("page")).toBe("1");
|
||||
expect(params.get("page_size")).toBe("50");
|
||||
});
|
||||
|
||||
it("keeps key_prefix and key working when no search is given", async () => {
|
||||
const mockFetch = mockOkFetch();
|
||||
|
||||
await Networking.fetchMemoryList("token", { keyPrefix: "user:" });
|
||||
expect(lastParams(mockFetch).get("key_prefix")).toBe("user:");
|
||||
expect(lastParams(mockFetch).has("search")).toBe(false);
|
||||
|
||||
await Networking.fetchMemoryList("token", { key: "user:profile" });
|
||||
expect(lastParams(mockFetch).get("key")).toBe("user:profile");
|
||||
expect(lastParams(mockFetch).has("search")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2056,6 +2056,7 @@ interface UiSpendLogsParams {
|
|||
exclude_internal_health_checks?: boolean;
|
||||
group_by_session?: boolean;
|
||||
session_cursor?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface UiSpendLogsCallOptions {
|
||||
|
|
@ -6563,6 +6564,7 @@ interface UiAuditLogsParams {
|
|||
changed_by_api_key?: string;
|
||||
object_team_id?: string;
|
||||
object_key_hash?: string;
|
||||
search?: string | null;
|
||||
sort_by?: string;
|
||||
sort_order?: "asc" | "desc";
|
||||
}
|
||||
|
|
@ -8061,15 +8063,18 @@ export const fetchMemoryList = async (
|
|||
options: {
|
||||
key?: string;
|
||||
keyPrefix?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<MemoryListResponse> => {
|
||||
const base = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`;
|
||||
const params = new URLSearchParams();
|
||||
// keyPrefix takes precedence — backend also does, but we omit `key`
|
||||
// Backend precedence is search > key_prefix > key; only the winner is sent
|
||||
// to keep the URL clean and intent obvious.
|
||||
if (options.keyPrefix) {
|
||||
if (options.search) {
|
||||
params.append("search", options.search);
|
||||
} else if (options.keyPrefix) {
|
||||
params.append("key_prefix", options.keyPrefix);
|
||||
} else if (options.key) {
|
||||
params.append("key", options.key);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ vi.mock("@tanstack/react-pacer/debouncer", () => ({
|
|||
|
||||
const mockUseKeys = useKeys as MockedFunction<typeof useKeys>;
|
||||
|
||||
const KEY_HASH = "88a145505dd6e87e2ea166fcef1e4b53948dbdb32af6431dfd05ec06b571ee52";
|
||||
|
||||
const createMockKey = (overrides: Partial<KeyResponse> = {}): KeyResponse =>
|
||||
({
|
||||
token: "sk-test123",
|
||||
|
|
@ -277,7 +279,7 @@ describe("TeamVirtualKeysTable", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("maps the search box to a server-side key-alias query", async () => {
|
||||
it("maps the Key ID drawer filter to a server-side useKeys query and clears it", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 },
|
||||
|
|
@ -288,11 +290,42 @@ describe("TeamVirtualKeysTable", () => {
|
|||
|
||||
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
|
||||
|
||||
fireEvent.change(await screen.findByTestId("datatable-search"), { target: { value: "check-002" } });
|
||||
await user.click(await screen.findByTestId("datatable-filters-trigger"));
|
||||
const drawerBody = await screen.findByTestId("filter-drawer-body");
|
||||
fireEvent.change(within(drawerBody).getByPlaceholderText("Enter Key ID…"), { target: { value: KEY_HASH } });
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "check-002" })),
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ keyHash: KEY_HASH })),
|
||||
);
|
||||
expect(screen.getByTestId("filter-chip-key_hash")).toHaveTextContent("Key ID");
|
||||
|
||||
await user.click(screen.getByTestId("datatable-clear-filters"));
|
||||
await waitFor(() =>
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ keyHash: undefined })),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the search box to the combined alias-or-ID search rather than the key-alias filter", async () => {
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 },
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useKeys>);
|
||||
|
||||
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
|
||||
|
||||
const searchBox = await screen.findByTestId("datatable-search");
|
||||
expect(searchBox).toHaveAttribute("placeholder", "Search by key alias or ID…");
|
||||
fireEvent.change(searchBox, { target: { value: KEY_HASH } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: KEY_HASH })),
|
||||
);
|
||||
const lastOptions = mockUseKeys.mock.calls.at(-1)?.[2];
|
||||
expect(lastOptions?.selectedKeyAlias).toBeUndefined();
|
||||
expect(lastOptions?.keyHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should show Loading keys when isPending", async () => {
|
||||
|
|
|
|||
|
|
@ -68,19 +68,17 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
const pageIndex = tablePagination.pageIndex;
|
||||
const pageSize = tablePagination.pageSize;
|
||||
|
||||
const {
|
||||
data: keys,
|
||||
isPending: isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useKeys(pageIndex + 1, pageSize, {
|
||||
const keyListOptions = {
|
||||
teamID: teamId,
|
||||
selectedKeyAlias: searchQuery.trim() || undefined,
|
||||
search: searchQuery.trim() || undefined,
|
||||
userID: getFilterValue("user_id"),
|
||||
keyHash: getFilterValue("key_hash"),
|
||||
sortBy: sortBy || undefined,
|
||||
sortOrder: sortOrder || undefined,
|
||||
expand: "user",
|
||||
});
|
||||
};
|
||||
|
||||
const { data: keys, isPending: isLoading, isFetching, refetch } = useKeys(pageIndex + 1, pageSize, keyListOptions);
|
||||
|
||||
const displayKeys = useMemo(() => {
|
||||
const kList = keys?.keys || [];
|
||||
|
|
@ -481,11 +479,11 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
table={table}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="Search by key alias…"
|
||||
searchPlaceholder="Search by key alias or ID…"
|
||||
onRefresh={() => refetch?.()}
|
||||
isRefreshing={isFetching}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
filterLabels={{ user_id: "User ID" }}
|
||||
filterLabels={{ user_id: "User ID", key_hash: "Key ID" }}
|
||||
/>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
|
|
@ -495,13 +493,22 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
description={`Narrow down keys for ${teamAlias ?? "this team"}`}
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<DataTableFilterField label="User ID">
|
||||
<Input
|
||||
value={(get("user_id") as string) ?? ""}
|
||||
onChange={(event) => set("user_id", event.target.value)}
|
||||
placeholder="Filter by user ID…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<>
|
||||
<DataTableFilterField label="User ID">
|
||||
<Input
|
||||
value={(get("user_id") as string) ?? ""}
|
||||
onChange={(event) => set("user_id", event.target.value)}
|
||||
placeholder="Filter by user ID…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Key ID">
|
||||
<Input
|
||||
value={(get("key_hash") as string) ?? ""}
|
||||
onChange={(event) => set("key_hash", event.target.value)}
|
||||
placeholder="Enter Key ID…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
)}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { chooseSelectOption } from "../../../tests/test-utils";
|
||||
import AuditLogsPanel from "./AuditLogsPanel";
|
||||
|
||||
vi.mock("../networking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../networking")>();
|
||||
return { ...actual, uiAuditLogsCall: vi.fn() };
|
||||
});
|
||||
|
||||
// Resolve the debounced search synchronously so typed input reaches the query within the test tick.
|
||||
vi.mock("@tanstack/react-pacer/debouncer", () => ({
|
||||
useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }],
|
||||
}));
|
||||
|
||||
import { uiAuditLogsCall } from "../networking";
|
||||
|
||||
type AuditLogsParams = NonNullable<Parameters<typeof uiAuditLogsCall>[0]["params"]>;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const ID_PARAM_KEYS = [
|
||||
"search",
|
||||
"object_id",
|
||||
"changed_by",
|
||||
"object_team_id",
|
||||
"object_key_hash",
|
||||
"action",
|
||||
"table_name",
|
||||
] as const satisfies readonly (keyof AuditLogsParams)[];
|
||||
|
||||
const respondWith = (total: number) => {
|
||||
const response = { audit_logs: [], total, page: 1, page_size: PAGE_SIZE, total_pages: Math.ceil(total / PAGE_SIZE) };
|
||||
return vi.mocked(uiAuditLogsCall).mockResolvedValue(response);
|
||||
};
|
||||
|
||||
const lastCall = () => vi.mocked(uiAuditLogsCall).mock.calls.at(-1)?.[0];
|
||||
const sentIdParams = () => ID_PARAM_KEYS.filter((key) => lastCall()?.params?.[key] !== undefined);
|
||||
|
||||
const defaultProps = {
|
||||
accessToken: "sk-test",
|
||||
token: "jwt-test",
|
||||
userRole: "Admin",
|
||||
userID: "user-1",
|
||||
isActive: true,
|
||||
premiumUser: true,
|
||||
};
|
||||
|
||||
const renderPanel = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuditLogsPanel {...defaultProps} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const TEXT_FILTERS: { filterId: string; placeholder: string; paramKey: keyof AuditLogsParams }[] = [
|
||||
{ filterId: "object_id", placeholder: "Enter object ID…", paramKey: "object_id" },
|
||||
{ filterId: "changed_by", placeholder: "Enter user ID…", paramKey: "changed_by" },
|
||||
{ filterId: "team_id", placeholder: "Enter team ID…", paramKey: "object_team_id" },
|
||||
{ filterId: "key_hash", placeholder: "Enter key hash…", paramKey: "object_key_hash" },
|
||||
];
|
||||
|
||||
const SELECT_FILTERS: {
|
||||
label: string;
|
||||
comboboxIndex: number;
|
||||
option: string;
|
||||
paramKey: keyof AuditLogsParams;
|
||||
value: string;
|
||||
}[] = [
|
||||
{ label: "Action", comboboxIndex: 0, option: "Created", paramKey: "action", value: "created" },
|
||||
{ label: "Table", comboboxIndex: 1, option: "Teams", paramKey: "table_name", value: "LiteLLM_TeamTable" },
|
||||
];
|
||||
|
||||
describe("AuditLogsPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
respondWith(0);
|
||||
});
|
||||
|
||||
it("sends the typed search as params.search and returns to the first page", async () => {
|
||||
const user = userEvent.setup();
|
||||
respondWith(120);
|
||||
renderPanel();
|
||||
await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled());
|
||||
expect(lastCall()?.params?.search).toBeUndefined();
|
||||
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
await waitFor(() => expect(lastCall()?.page).toBe(2));
|
||||
|
||||
await user.type(screen.getByTestId("datatable-search"), "team-abc");
|
||||
|
||||
await waitFor(() => expect(lastCall()?.params?.search).toBe("team-abc"));
|
||||
expect(lastCall()?.page).toBe(1);
|
||||
expect(sentIdParams()).toEqual(["search"]);
|
||||
});
|
||||
|
||||
it("trims the search and drops params.search once the box is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
const input = await screen.findByTestId("datatable-search");
|
||||
|
||||
await user.type(input, " abc");
|
||||
await waitFor(() => expect(lastCall()?.params?.search).toBe("abc"));
|
||||
|
||||
await user.clear(input);
|
||||
|
||||
await waitFor(() => expect(lastCall()?.params?.search).toBeUndefined());
|
||||
expect(sentIdParams()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(TEXT_FILTERS)("maps the $filterId drawer filter to params.$paramKey", async ({ placeholder, paramKey }) => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled());
|
||||
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
fireEvent.change(await screen.findByPlaceholderText(placeholder), { target: { value: "val-1" } });
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => expect(lastCall()?.params?.[paramKey]).toBe("val-1"));
|
||||
expect(sentIdParams()).toEqual([paramKey]);
|
||||
});
|
||||
|
||||
it.each(SELECT_FILTERS)(
|
||||
"maps the $label drawer select to params.$paramKey",
|
||||
async ({ comboboxIndex, option, paramKey, value }) => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled());
|
||||
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
const triggers = await screen.findAllByRole("combobox");
|
||||
await chooseSelectOption(user, triggers[comboboxIndex], option);
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => expect(lastCall()?.params?.[paramKey]).toBe(value));
|
||||
expect(sentIdParams()).toEqual([paramKey]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { uiAuditLogsCall } from "../networking";
|
||||
import { AuditLogEntry } from "./AuditLogsTableColumns";
|
||||
import { AuditLogsTable } from "./AuditLogsTable";
|
||||
|
|
@ -39,9 +41,13 @@ export default function AuditLogsPanel({
|
|||
}: AuditLogsProps) {
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLogEntry | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const searchTerm = debouncedSearch.trim();
|
||||
|
||||
const getFilterValue = (columnId: string): string | undefined => {
|
||||
const entry = columnFilters.find((filter) => filter.id === columnId);
|
||||
return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined;
|
||||
|
|
@ -50,7 +56,7 @@ export default function AuditLogsPanel({
|
|||
const canQueryAuditLogs = !!accessToken && !!token && !!userRole && !!userID && isActive && premiumUser;
|
||||
|
||||
const query = useQuery<AuditLogsResponse>({
|
||||
queryKey: ["audit_logs", pagination.pageIndex, pagination.pageSize, columnFilters],
|
||||
queryKey: ["audit_logs", pagination.pageIndex, pagination.pageSize, columnFilters, searchTerm],
|
||||
queryFn: async () => {
|
||||
if (!accessToken) {
|
||||
return { audit_logs: [], total: 0, page: 1, page_size: pagination.pageSize, total_pages: 0 };
|
||||
|
|
@ -60,6 +66,7 @@ export default function AuditLogsPanel({
|
|||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
params: {
|
||||
search: searchTerm || undefined,
|
||||
object_id: getFilterValue("object_id"),
|
||||
changed_by: getFilterValue("changed_by"),
|
||||
object_key_hash: getFilterValue("key_hash"),
|
||||
|
|
@ -80,6 +87,11 @@ export default function AuditLogsPanel({
|
|||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchInput(value);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const handleViewLog = useCallback((log: AuditLogEntry) => {
|
||||
setSelectedLog(log);
|
||||
setDrawerOpen(true);
|
||||
|
|
@ -128,6 +140,8 @@ export default function AuditLogsPanel({
|
|||
onPaginationChange={setPagination}
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={handleColumnFiltersChange}
|
||||
searchValue={searchInput}
|
||||
onSearchChange={handleSearchChange}
|
||||
onRefresh={() => query.refetch()}
|
||||
onViewLog={handleViewLog}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -120,6 +120,24 @@ describe("AuditLogsTable", () => {
|
|||
expect(screen.getByText("No matching audit logs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the toolbar search box from the search props and forwards typed input", () => {
|
||||
const onSearchChange = vi.fn();
|
||||
renderTable({ searchValue: "team-", onSearchChange });
|
||||
|
||||
const input = screen.getByPlaceholderText("Search audit logs by ID…");
|
||||
expect(input).toHaveValue("team-");
|
||||
|
||||
fireEvent.change(input, { target: { value: "team-7" } });
|
||||
expect(onSearchChange).toHaveBeenCalledWith("team-7");
|
||||
});
|
||||
|
||||
it("treats an active search as a filter for the empty state", () => {
|
||||
const emptySearchResult = { data: [], rowCount: 0, searchValue: "zzz", onSearchChange: vi.fn() };
|
||||
renderTable(emptySearchResult);
|
||||
|
||||
expect(screen.getByText("No matching audit logs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders active filter chips with human-readable labels", () => {
|
||||
const filters: ColumnFiltersState = [{ id: "action", value: "created" }];
|
||||
renderTable({ columnFilters: filters });
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ interface AuditLogsTableProps {
|
|||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
columnFilters: ColumnFiltersState;
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onViewLog: (log: AuditLogEntry) => void;
|
||||
}
|
||||
|
|
@ -102,11 +104,14 @@ export function AuditLogsTable({
|
|||
onPaginationChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
onRefresh,
|
||||
onViewLog,
|
||||
}: AuditLogsTableProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]);
|
||||
const hasActiveSearch = Boolean(searchValue?.trim());
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -122,12 +127,15 @@ export function AuditLogsTable({
|
|||
onColumnFiltersChange={onColumnFiltersChange}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading audit logs…"
|
||||
noDataMessage={<AuditLogsEmptyState filtered={columnFilters.length > 0} />}
|
||||
noDataMessage={<AuditLogsEmptyState filtered={columnFilters.length > 0 || hasActiveSearch} />}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Search audit logs by ID…"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,14 @@ vi.mock("./LogDetailsDrawer", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const debounce = vi.hoisted(() => ({ settled: null as string | null }));
|
||||
|
||||
vi.mock("@tanstack/react-pacer/debouncer", () => ({
|
||||
useDebouncedValue: vi.fn((value: unknown) => [debounce.settled ?? value, { cancel: vi.fn(), flush: vi.fn() }]),
|
||||
}));
|
||||
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { uiSpendLogsCall } from "../networking";
|
||||
|
||||
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
|
||||
|
|
@ -136,6 +144,7 @@ describe("RequestLogsPanel", () => {
|
|||
sessionStorage.clear();
|
||||
testQueryClient.clear();
|
||||
respondWith([]);
|
||||
debounce.settled = null;
|
||||
});
|
||||
|
||||
describe("server-grouped session pagination (#38060)", () => {
|
||||
|
|
@ -322,9 +331,8 @@ describe("RequestLogsPanel", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("search by request id (LIT-3981)", () => {
|
||||
it("sends the typed request id to the server on the first page instead of filtering the loaded rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
describe("search by any id (LIT-3981, LIT-4741)", () => {
|
||||
it("sends the typed id to the server as search on the first page instead of filtering the loaded rows", async () => {
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
|
||||
|
|
@ -334,9 +342,54 @@ describe("RequestLogsPanel", () => {
|
|||
await waitFor(() => {
|
||||
const call = lastCall();
|
||||
if (!call) throw new Error("uiSpendLogsCall was not called");
|
||||
expect(call.params?.request_id).toBe("req-on-another-page");
|
||||
expect(call.params?.search).toBe("req-on-another-page");
|
||||
expect(call.page).toBe(1);
|
||||
});
|
||||
expect(lastCall()?.params?.request_id).toBeUndefined();
|
||||
expect(lastCall()?.params?.session_cursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends the debounced value to the server while the box shows what is being typed", async () => {
|
||||
debounce.settled = "settled-id";
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
|
||||
|
||||
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "still-typing" } });
|
||||
|
||||
expect(screen.getByTestId("datatable-search")).toHaveValue("still-typing");
|
||||
await waitFor(() =>
|
||||
expect(useDebouncedValue).toHaveBeenLastCalledWith("still-typing", { wait: DEBOUNCE_WAIT_MS }),
|
||||
);
|
||||
await waitFor(() => expect(lastCall()?.params?.search).toBe("settled-id"));
|
||||
const sentLiveValue = vi
|
||||
.mocked(uiSpendLogsCall)
|
||||
.mock.calls.some(([options]) => options.params?.search === "still-typing");
|
||||
expect(sentLiveValue).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a Search chip whose remove button clears the box and restores the unsearched listing", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) => {
|
||||
const data =
|
||||
params?.search === "sess-42"
|
||||
? [logEntry({ request_id: "req-sess", session_id: "sess-42" })]
|
||||
: [logEntry({ request_id: "req-initial" })];
|
||||
return { data, total: data.length, page: 1, page_size: 50, total_pages: 1 };
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => expect(row("req-initial")).not.toBeNull());
|
||||
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "sess-42" } });
|
||||
await waitFor(() => expect(row("req-sess")).not.toBeNull());
|
||||
expect(row("req-initial")).toBeNull();
|
||||
expect(screen.getByTestId("filter-chip-search")).toHaveTextContent("Search:sess-42");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove Search filter" }));
|
||||
|
||||
expect(screen.getByTestId("datatable-search")).toHaveValue("");
|
||||
await waitFor(() => expect(row("req-initial")).not.toBeNull());
|
||||
expect(row("req-sess")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import moment from "moment";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import type { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import { keyInfoV1Call, uiSpendLogsCall } from "../networking";
|
||||
import KeyInfoView from "../templates/key_info_view";
|
||||
|
|
@ -75,12 +77,22 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
sessionStorage.setItem("excludeInternalHealthChecks", JSON.stringify(excludeInternalHealthChecks));
|
||||
}, [excludeInternalHealthChecks]);
|
||||
|
||||
const searchTerm = useMemo(() => {
|
||||
const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.SEARCH);
|
||||
return typeof entry?.value === "string" ? entry.value : "";
|
||||
}, [columnFilters]);
|
||||
const [debouncedSearch] = useDebouncedValue(searchTerm, { wait: DEBOUNCE_WAIT_MS });
|
||||
const queryColumnFilters = useMemo<ColumnFiltersState>(() => {
|
||||
const others = columnFilters.filter((filter) => filter.id !== LOG_FILTER_IDS.SEARCH);
|
||||
return debouncedSearch === "" ? others : [...others, { id: LOG_FILTER_IDS.SEARCH, value: debouncedSearch }];
|
||||
}, [columnFilters, debouncedSearch]);
|
||||
|
||||
const { logsQuery, filteredLogs, allTeams, usesSessionCursor } = useLogFilterLogic({
|
||||
accessToken,
|
||||
token,
|
||||
userRole,
|
||||
userID,
|
||||
columnFilters,
|
||||
columnFilters: queryColumnFilters,
|
||||
activeTab: isActive ? "request logs" : "inactive",
|
||||
isLiveTail,
|
||||
excludeInternalHealthChecks,
|
||||
|
|
@ -155,15 +167,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
|
||||
const rows: LogEntry[] = filteredLogs.data;
|
||||
|
||||
const searchTerm = useMemo(() => {
|
||||
const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.REQUEST_ID);
|
||||
return typeof entry?.value === "string" ? entry.value : "";
|
||||
}, [columnFilters]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setColumnFilters((previous) => {
|
||||
const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.REQUEST_ID);
|
||||
return value === "" ? others : [...others, { id: LOG_FILTER_IDS.REQUEST_ID, value }];
|
||||
const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.SEARCH);
|
||||
return value === "" ? others : [...others, { id: LOG_FILTER_IDS.SEARCH, value }];
|
||||
});
|
||||
setSessionCursors({});
|
||||
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export function RequestLogsTable({
|
|||
table={table}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Search by Request ID"
|
||||
searchPlaceholder="Search logs by ID…"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ describe("useLogFilterLogic", () => {
|
|||
{ id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" },
|
||||
{ id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" },
|
||||
{ id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" },
|
||||
{ id: LOG_FILTER_IDS.SEARCH, value: "any-id", param: "search" },
|
||||
];
|
||||
|
||||
it.each(cases)("sends $id as $param", async ({ id, value, param }) => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const LOG_FILTER_IDS = {
|
|||
PUBLIC_MODEL_OR_SEARCH_TOOL: "model",
|
||||
REQUEST_ID: "request_id",
|
||||
USER_ID: "user_id",
|
||||
SEARCH: "search",
|
||||
} as const;
|
||||
|
||||
export const LOG_FILTER_LABELS: Record<string, string> = {
|
||||
|
|
@ -48,6 +49,7 @@ export const LOG_FILTER_LABELS: Record<string, string> = {
|
|||
[LOG_FILTER_IDS.SESSION_ID]: "Session ID",
|
||||
[LOG_FILTER_IDS.MODEL_ID]: "Model",
|
||||
[LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool",
|
||||
[LOG_FILTER_IDS.SEARCH]: "Search",
|
||||
};
|
||||
|
||||
export interface LogsWindow {
|
||||
|
|
@ -175,6 +177,7 @@ export function useLogFilterLogic({
|
|||
api_key: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_HASH),
|
||||
team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID),
|
||||
request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID),
|
||||
search: getFilterValue(columnFilters, LOG_FILTER_IDS.SEARCH),
|
||||
session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID),
|
||||
user_id: userIdFilter,
|
||||
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
|
||||
|
|
|
|||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -40904,6 +40904,8 @@ export interface operations {
|
|||
object_team_id?: string | null;
|
||||
/** @description Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only) */
|
||||
object_key_hash?: string | null;
|
||||
/** @description Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value */
|
||||
search?: string | null;
|
||||
/** @description Column to sort by (e.g. 'updated_at', 'action', 'table_name') */
|
||||
sort_by?: string | null;
|
||||
/** @description Sort order ('asc' or 'desc') */
|
||||
|
|
@ -49622,6 +49624,8 @@ export interface operations {
|
|||
key_hash?: string | null;
|
||||
/** @description Filter keys by key alias. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching. */
|
||||
key_alias?: string | null;
|
||||
/** @description Combined search: matches keys whose token (key hash) equals the value OR whose key_alias contains it (case-insensitive). */
|
||||
search?: string | null;
|
||||
/** @description Return full key object */
|
||||
return_full_object?: boolean;
|
||||
/** @description Include all keys for teams that user is an admin of. */
|
||||
|
|
@ -56880,6 +56884,8 @@ export interface operations {
|
|||
group_by_session?: boolean;
|
||||
/** @description Keyset cursor '<last_activity>|<api_key>|<session_key>' from a previous group_by_session page. UI route only, honored when sorting by startTime */
|
||||
session_cursor?: string | null;
|
||||
/** @description Match a log whose request_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */
|
||||
search?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -56996,6 +57002,8 @@ export interface operations {
|
|||
group_by_session?: boolean;
|
||||
/** @description Keyset cursor '<last_activity>|<api_key>|<session_key>' from a previous group_by_session page. UI route only, honored when sorting by startTime */
|
||||
session_cursor?: string | null;
|
||||
/** @description Match a log whose request_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */
|
||||
search?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -63308,6 +63316,8 @@ export interface operations {
|
|||
key?: string | null;
|
||||
/** @description Filter by key prefix (Redis-style namespace scan). Mutually exclusive with `key`; if both are provided, `key_prefix` wins. */
|
||||
key_prefix?: string | null;
|
||||
/** @description Match entries whose key starts with this value or whose memory_id equals it. Takes precedence over `key_prefix` and `key` when provided. */
|
||||
search?: string | null;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue