mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(keys): filter /key/list by active, expired, revoked or deleted status and serve deleted keys from /key/info
Persist and expose the lifecycle of API keys so spend, audit and FinOps workflows can still resolve a key after it is revoked, expires or is deleted. /key/list?status= now accepts active, expired and revoked next to the existing deleted value. revoked means blocked=true, expired means not blocked with a past expiry, active is the rest, so the three values partition the live key table. deleted keeps reading the LiteLLM_DeletedVerificationToken archive. /key/info falls back to that archive when the key is no longer in the live table, running the same owner/team/org authorization check, and every response now carries a derived status field. The hashed token is still stripped. The Virtual Keys page gets a Status filter (URL-persisted) and a Deleted badge that shows when and by whom the key was deleted. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
79d4d4d8f5
commit
6c8b9a7b05
7 changed files with 422 additions and 30 deletions
|
|
@ -4166,7 +4166,10 @@ async def info_key_fn(
|
|||
|
||||
Returns:
|
||||
- key: str - The key that was looked up, echoed back as it was passed in
|
||||
- info: dict - The key's row, minus the hashed token
|
||||
- info: dict - The key's row, minus the hashed token. Deleted keys are served from the
|
||||
LiteLLM_DeletedVerificationToken archive and carry deleted_at / deleted_by
|
||||
- status: "active" | "expired" | "revoked" | "deleted" - Derived from blocked, expires and
|
||||
whether the row came from the archive
|
||||
- key_alias: str | None - User-friendly key alias
|
||||
- spend: float - Amount spent by the key. When budget_duration is set this covers only the
|
||||
current budget window, not the key's lifetime
|
||||
|
|
@ -4220,10 +4223,15 @@ async def info_key_fn(
|
|||
hashed_key: str | None = key
|
||||
if key is not None:
|
||||
hashed_key = _hash_token_if_needed(token=key)
|
||||
key_info = await _prisma_table(VerificationTokenRepository(prisma_client)).find_unique(
|
||||
live_key_info: Final = await _prisma_table(VerificationTokenRepository(prisma_client)).find_unique(
|
||||
where={"token": hashed_key},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
key_info: Final = (
|
||||
live_key_info
|
||||
if live_key_info is not None
|
||||
else await _find_deleted_key_info(prisma_client=prisma_client, hashed_key=hashed_key)
|
||||
)
|
||||
if key_info is None:
|
||||
raise ProxyException(
|
||||
message="Key not found in database",
|
||||
|
|
@ -4231,7 +4239,6 @@ async def info_key_fn(
|
|||
param="key",
|
||||
code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
await _can_user_query_key_info(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -4245,38 +4252,46 @@ async def info_key_fn(
|
|||
detail=f"You are not allowed to access this key's info. Your role={user_api_key_dict.user_role}",
|
||||
)
|
||||
## REMOVE HASHED TOKEN INFO BEFORE RETURNING ##
|
||||
try:
|
||||
key_info = key_info.model_dump()
|
||||
except Exception:
|
||||
# if using pydantic v1
|
||||
key_info = key_info.dict() # pyright: ignore[reportDeprecated] # deliberate pydantic v1 fallback
|
||||
key_token_hash: Final[str | None] = key_info.pop("token")
|
||||
key_info_dict: Final = key_info.model_dump()
|
||||
key_token_hash: Final[str | None] = key_info_dict.pop("token")
|
||||
key_info_dict["status"] = (
|
||||
"deleted" if live_key_info is None else _derive_key_status(key_info_dict, now=datetime.now(timezone.utc))
|
||||
)
|
||||
|
||||
model_max_budget = key_info.get("model_max_budget") or {}
|
||||
budget_table: Final = key_info.get("litellm_budget_table") or {}
|
||||
model_max_budget = key_info_dict.get("model_max_budget") or {}
|
||||
budget_table: Final = key_info_dict.get("litellm_budget_table") or {}
|
||||
if not model_max_budget and isinstance(budget_table, dict):
|
||||
model_max_budget = budget_table.get("model_max_budget") or {}
|
||||
if model_max_budget and key_token_hash:
|
||||
key_info["model_max_budget_usage"] = await _build_model_max_budget_usage(
|
||||
key_info_dict["model_max_budget_usage"] = await _build_model_max_budget_usage(
|
||||
api_key_hash=key_token_hash,
|
||||
model_max_budget=model_max_budget,
|
||||
user_api_key_cache=model_max_budget_limiter.dual_cache,
|
||||
)
|
||||
budget_limits_usage: Final = await _build_budget_limits_usage(
|
||||
budget_limits=key_info.get("budget_limits"),
|
||||
budget_limits=key_info_dict.get("budget_limits"),
|
||||
api_key_hash=key_token_hash,
|
||||
)
|
||||
if budget_limits_usage is not None:
|
||||
key_info["budget_limits_usage"] = budget_limits_usage
|
||||
key_info_dict["budget_limits_usage"] = budget_limits_usage
|
||||
|
||||
# Attach object_permission if object_permission_id is set
|
||||
key_info = await attach_object_permission_to_dict(key_info, prisma_client)
|
||||
|
||||
return {"key": key, "info": key_info}
|
||||
return {"key": key, "info": await attach_object_permission_to_dict(key_info_dict, prisma_client)}
|
||||
except Exception as e:
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
async def _find_deleted_key_info(
|
||||
prisma_client: PrismaClient, hashed_key: str | None
|
||||
) -> LiteLLM_DeletedVerificationToken | None:
|
||||
archived_row: Final = await _deleted_verification_token_table(prisma_client).find_first(
|
||||
where={"token": hashed_key},
|
||||
order={"deleted_at": "desc"},
|
||||
)
|
||||
if archived_row is None:
|
||||
return None
|
||||
return LiteLLM_DeletedVerificationToken.model_validate(archived_row.model_dump())
|
||||
|
||||
|
||||
def _check_model_access_group(models: list[str] | None, llm_router: Router | None, premium_user: bool) -> Literal[True]:
|
||||
"""
|
||||
if is_model_access_group is True + is_wildcard_route is True, check if user is a premium user
|
||||
|
|
@ -6216,6 +6231,25 @@ async def get_member_team_ids(
|
|||
|
||||
VALID_EXPIRES_FILTER_VALUES: Final = frozenset({"active", "expired"})
|
||||
|
||||
KeyStatus = Literal["active", "expired", "revoked", "deleted"]
|
||||
VALID_STATUS_FILTER_VALUES: Final[frozenset[KeyStatus]] = frozenset({"active", "expired", "revoked", "deleted"})
|
||||
|
||||
|
||||
class _KeyStatusSource(BaseModel):
|
||||
blocked: bool | None = None
|
||||
expires: datetime | None = None
|
||||
|
||||
|
||||
def _derive_key_status(row: Mapping[str, object], now: datetime) -> KeyStatus:
|
||||
"""Status of a live key row; mirrors the partition `_build_status_where_clause` applies at query time."""
|
||||
source: Final = _KeyStatusSource.model_validate(row)
|
||||
if source.blocked is True:
|
||||
return "revoked"
|
||||
if source.expires is None:
|
||||
return "active"
|
||||
expires_utc: Final = source.expires if source.expires.tzinfo else source.expires.replace(tzinfo=timezone.utc)
|
||||
return "expired" if expires_utc < now else "active"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/key/list",
|
||||
|
|
@ -6252,7 +6286,10 @@ async def list_keys(
|
|||
),
|
||||
sort_order: str = Query(default="desc", description="Sort order ('asc' or 'desc')"),
|
||||
expand: list[str] | None = Query(None, description="Expand related objects (e.g. 'user')"),
|
||||
status: str | None = Query(None, description="Filter by status (e.g. 'deleted')"),
|
||||
status: str | None = Query(
|
||||
None,
|
||||
description="Filter by status: 'active' (not blocked, not expired), 'expired' (not blocked, past expiry), 'revoked' (blocked) or 'deleted' (archived keys). Omit to return live keys regardless of status.",
|
||||
),
|
||||
project_id: str | None = Query(None, description="Filter keys by project ID"),
|
||||
access_group_id: str | None = Query(None, description="Filter keys by access group ID"),
|
||||
agent_id: str | None = Query(None, description="Filter keys by agent ID"),
|
||||
|
|
@ -6270,7 +6307,9 @@ async def list_keys(
|
|||
|
||||
Parameters:
|
||||
expand: Optional[List[str]] - Expand related objects (e.g. 'user' to include user information)
|
||||
status: Optional[str] - Filter by status. Currently supports "deleted" to query deleted keys.
|
||||
status: Optional[str] - Filter by status: "active", "expired", "revoked" (blocked) or "deleted".
|
||||
"deleted" reads the LiteLLM_DeletedVerificationToken archive; the other values partition the
|
||||
live key table, so every live key matches exactly one of them.
|
||||
|
||||
Returns:
|
||||
{
|
||||
|
|
@ -6292,11 +6331,10 @@ async def list_keys(
|
|||
verbose_proxy_logger.error("Database not connected")
|
||||
raise Exception("Database not connected")
|
||||
|
||||
# Validate status parameter
|
||||
if status is not None and status != "deleted":
|
||||
if status is not None and status not in VALID_STATUS_FILTER_VALUES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "Invalid status value. Currently only 'deleted' is supported."},
|
||||
detail={"error": "Invalid status value. Supported: 'active', 'expired', 'revoked', 'deleted'."},
|
||||
)
|
||||
|
||||
if isinstance(expires, str) and expires not in VALID_EXPIRES_FILTER_VALUES:
|
||||
|
|
@ -6608,6 +6646,23 @@ def _build_expires_where_clause(expires_filter: str, now: datetime) -> dict[str,
|
|||
return {"OR": [{"expires": None}, {"expires": {"gte": now}}]}
|
||||
|
||||
|
||||
def _not_blocked_where_clause() -> dict[str, object]:
|
||||
return {"OR": [{"blocked": None}, {"blocked": False}]}
|
||||
|
||||
|
||||
def _build_status_where_clause(status_filter: str | None, now: datetime) -> dict[str, object] | None:
|
||||
"""Live-table clause for a status filter; None when the status needs no clause (deleted rows live elsewhere)."""
|
||||
match status_filter:
|
||||
case "revoked":
|
||||
return {"blocked": True}
|
||||
case "expired":
|
||||
return {"AND": [_not_blocked_where_clause(), _build_expires_where_clause("expired", now)]}
|
||||
case "active":
|
||||
return {"AND": [_not_blocked_where_clause(), _build_expires_where_clause("active", now)]}
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _build_key_search_where(search: str) -> KeySearchWhere:
|
||||
search_where: Final[KeySearchWhere] = {
|
||||
"OR": (
|
||||
|
|
@ -6635,6 +6690,7 @@ def _build_key_filter_conditions(
|
|||
use_key_alias_substring_matching: bool = False,
|
||||
expires_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
) -> Mapping[str, object]:
|
||||
"""Build filter conditions for key listing.
|
||||
|
||||
|
|
@ -6724,6 +6780,8 @@ 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.)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
status_where: Final = _build_status_where_clause(status_filter, now)
|
||||
global_filters: Final[tuple[Mapping[str, object], ...]] = (
|
||||
*(
|
||||
(
|
||||
|
|
@ -6741,10 +6799,11 @@ def _build_key_filter_conditions(
|
|||
*(({"access_group_ids": {"hasSome": [access_group_id]}},) if access_group_id else ()),
|
||||
*(({"agent_id": agent_id},) if agent_id and isinstance(agent_id, str) else ()),
|
||||
*(
|
||||
(_build_expires_where_clause(expires_filter, datetime.now(timezone.utc)),)
|
||||
(_build_expires_where_clause(expires_filter, now),)
|
||||
if expires_filter is not None and expires_filter in VALID_EXPIRES_FILTER_VALUES
|
||||
else ()
|
||||
),
|
||||
*((status_where,) if status_where is not None else ()),
|
||||
)
|
||||
combined_where: Final[Mapping[str, object]] = {"AND": [where, *global_filters]} if global_filters else where
|
||||
verbose_proxy_logger.debug("Filter conditions: %s", combined_where)
|
||||
|
|
@ -6817,6 +6876,7 @@ async def _list_key_helper(
|
|||
use_key_alias_substring_matching=use_key_alias_substring_matching,
|
||||
expires_filter=expires_filter,
|
||||
search=search,
|
||||
status_filter=status,
|
||||
)
|
||||
|
||||
# Calculate skip for pagination
|
||||
|
|
|
|||
|
|
@ -6017,6 +6017,224 @@ async def test_list_keys_with_invalid_status():
|
|||
assert "deleted" in str(exc_info.value.message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status_filter", ["active", "expired", "revoked"])
|
||||
async def test_list_keys_accepts_live_status_filters(monkeypatch, status_filter):
|
||||
"""LIT-1650: /key/list used to 400 on every status but "deleted"; the live statuses reach the helper."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
from litellm.proxy.management_endpoints import key_management_endpoints
|
||||
|
||||
helper = AsyncMock(return_value={"keys": [], "total_count": 0, "current_page": 1, "total_pages": 0})
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", AsyncMock())
|
||||
monkeypatch.setattr(key_management_endpoints, "_list_key_helper", helper)
|
||||
await key_management_endpoints.list_keys(
|
||||
request=Mock(),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
status=status_filter,
|
||||
)
|
||||
|
||||
assert helper.await_args is not None
|
||||
assert helper.await_args.kwargs["status"] == status_filter
|
||||
|
||||
|
||||
def _status_filter_where(status_filter: str | None) -> Mapping[str, object]:
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import _build_key_filter_conditions
|
||||
|
||||
return _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,
|
||||
status_filter=status_filter,
|
||||
)
|
||||
|
||||
|
||||
def test_build_key_filter_conditions_status_filter_partitions_live_keys():
|
||||
"""LIT-1650: active, expired and revoked are disjoint predicates over blocked + expires on the live table."""
|
||||
not_blocked = {"OR": [{"blocked": None}, {"blocked": False}]}
|
||||
|
||||
revoked_where = _status_filter_where("revoked")
|
||||
assert {"blocked": True} in revoked_where["AND"]
|
||||
|
||||
expired_clause = next(clause for clause in _status_filter_where("expired")["AND"] if "AND" in clause)
|
||||
assert expired_clause["AND"][0] == not_blocked
|
||||
assert expired_clause["AND"][1]["AND"][0] == {"expires": {"not": None}}
|
||||
assert "lt" in expired_clause["AND"][1]["AND"][1]["expires"]
|
||||
|
||||
active_clause = next(clause for clause in _status_filter_where("active")["AND"] if "AND" in clause)
|
||||
assert active_clause["AND"][0] == not_blocked
|
||||
assert active_clause["AND"][1]["OR"][0] == {"expires": None}
|
||||
assert "gte" in active_clause["AND"][1]["OR"][1]["expires"]
|
||||
|
||||
|
||||
def test_build_key_filter_conditions_deleted_status_adds_no_live_clause():
|
||||
"""Deleted rows live in the archive table, so the status must not narrow the live-table query."""
|
||||
assert _status_filter_where("deleted") == _status_filter_where(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_key_helper_revoked_status_filters_live_table_on_blocked():
|
||||
"""LIT-1650: status="revoked" stays on the live table and narrows it to blocked keys."""
|
||||
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)
|
||||
mock_prisma_client.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||
|
||||
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,
|
||||
exclude_team_id=None,
|
||||
return_full_object=True,
|
||||
admin_team_ids=None,
|
||||
include_created_by_keys=False,
|
||||
status="revoked",
|
||||
)
|
||||
|
||||
mock_prisma_client.db.litellm_deletedverificationtoken.find_many.assert_not_called()
|
||||
where = mock_find_many.call_args.kwargs["where"]
|
||||
assert {"blocked": True} in where["AND"]
|
||||
|
||||
|
||||
def _archived_key_row(token: str, user_id: str) -> MagicMock:
|
||||
row = MagicMock()
|
||||
row.model_dump.return_value = {
|
||||
"id": "archive-row-1",
|
||||
"token": token,
|
||||
"key_alias": "finops-2024",
|
||||
"user_id": user_id,
|
||||
"team_id": None,
|
||||
"blocked": None,
|
||||
"deleted_at": datetime(2024, 11, 15, 10, 0, tzinfo=timezone.utc),
|
||||
"deleted_by": "admin-1",
|
||||
}
|
||||
return row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_key_fn_serves_deleted_key_from_archive(monkeypatch):
|
||||
"""LIT-1650: /key/info falls back to LiteLLM_DeletedVerificationToken and reports status="deleted"."""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn
|
||||
|
||||
hashed = "hashed_deleted_token"
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
||||
mock_prisma_client.db.litellm_deletedverificationtoken.find_first = AsyncMock(
|
||||
return_value=_archived_key_row(hashed, "user-x")
|
||||
)
|
||||
|
||||
result = await info_key_fn(
|
||||
key=hashed,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin"),
|
||||
)
|
||||
|
||||
mock_prisma_client.db.litellm_deletedverificationtoken.find_first.assert_awaited_once()
|
||||
assert mock_prisma_client.db.litellm_deletedverificationtoken.find_first.await_args.kwargs["where"] == {
|
||||
"token": hashed
|
||||
}
|
||||
info = result["info"]
|
||||
assert info["status"] == "deleted"
|
||||
assert info["key_alias"] == "finops-2024"
|
||||
assert info["deleted_by"] == "admin-1"
|
||||
assert info["deleted_at"] is not None
|
||||
assert "token" not in info
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_key_fn_archived_key_keeps_owner_authorization(monkeypatch):
|
||||
"""An archived key is still scoped: a different internal user gets 403, the owner gets the record."""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn
|
||||
|
||||
hashed = "hashed_deleted_token"
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
||||
mock_prisma_client.db.litellm_deletedverificationtoken.find_first = AsyncMock(
|
||||
return_value=_archived_key_row(hashed, "owner-1")
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await info_key_fn(
|
||||
key=hashed,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="someone-else", api_key="sk-other"
|
||||
),
|
||||
)
|
||||
assert exc_info.value.code == "403"
|
||||
|
||||
owner_result = await info_key_fn(
|
||||
key=hashed,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="owner-1", api_key="sk-own"),
|
||||
)
|
||||
assert owner_result["info"]["status"] == "deleted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_info_key_fn_unknown_key_still_404s(monkeypatch):
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
||||
mock_prisma_client.db.litellm_deletedverificationtoken.find_first = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await info_key_fn(
|
||||
key="hashed_missing",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin"),
|
||||
)
|
||||
assert exc_info.value.code == "404"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("blocked", "expires", "expected_status"),
|
||||
[
|
||||
(True, None, "revoked"),
|
||||
(True, "2020-01-01T00:00:00Z", "revoked"),
|
||||
(False, "2020-01-01T00:00:00Z", "expired"),
|
||||
(None, datetime(2020, 1, 1, tzinfo=timezone.utc), "expired"),
|
||||
(False, None, "active"),
|
||||
(None, "2999-01-01T00:00:00Z", "active"),
|
||||
],
|
||||
)
|
||||
async def test_info_key_fn_reports_live_key_status(monkeypatch, blocked, expires, expected_status):
|
||||
"""LIT-1650: live keys carry the same status vocabulary /key/list filters on."""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
live_row = MagicMock(spec=LiteLLM_VerificationToken)
|
||||
live_row.model_dump.return_value = {
|
||||
"token": "hashed_live",
|
||||
"user_id": "user-x",
|
||||
"team_id": None,
|
||||
"object_permission_id": None,
|
||||
"blocked": blocked,
|
||||
"expires": expires,
|
||||
}
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=live_row)
|
||||
|
||||
result = await info_key_fn(
|
||||
key="hashed_live",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin"),
|
||||
)
|
||||
|
||||
assert result["info"]["status"] == expected_status
|
||||
mock_prisma_client.db.litellm_deletedverificationtoken.find_first.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keys_non_admin_user_id_auto_set():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -638,6 +638,23 @@ describe("server-side filtering – the LIT-4080 regression guard", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("threads the Status drawer filter into the useKeys query and the URL", async () => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
|
||||
|
||||
openFilters();
|
||||
const user = userEvent.setup();
|
||||
await chooseSelectOption(user, await screen.findByRole("combobox", { name: "Status" }), "Revoked (blocked)");
|
||||
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ status: "revoked" }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "filter_status")).toBe("revoked");
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the search box as the combined alias-or-ID search rather than the key-alias filter", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
|
|
@ -745,6 +762,25 @@ describe("Status column reflects blocked / expiry / scim metadata", () => {
|
|||
expect(screen.queryByText(/Blocked by SCIM/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Deleted for an archived key, even when the archived row was also blocked", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{ ...mockKey, blocked: true, metadata: {}, deleted_at: "2024-11-15T10:00:00Z", deleted_by: "admin-1" },
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const tag = await screen.findByTestId(`key-status-${mockKey.token_id}`);
|
||||
expect(tag).toHaveTextContent("Deleted");
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.hover(tag);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/by admin-1/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a SCIM-blocked key with the SCIM tooltip reason", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: { scim_blocked: true } }]));
|
||||
|
||||
|
|
@ -790,6 +826,24 @@ describe("table state lives in the URL so it survives leaving and returning to t
|
|||
expect(screen.getByTestId("filter-chip-team_id")).toHaveTextContent("Test Team");
|
||||
});
|
||||
|
||||
it("restores the status filter from the URL and sends it to /key/list", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { filter_status: "deleted" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ status: "deleted" }));
|
||||
});
|
||||
expect(screen.getByTestId("filter-chip-status")).toHaveTextContent("Deleted");
|
||||
});
|
||||
|
||||
it("ignores a hand-edited status the backend would reject instead of 400ing the page", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { filter_status: "bogus" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ status: undefined }));
|
||||
});
|
||||
expect(screen.queryByTestId("filter-chip-status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes the search term to the URL", async () => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { PageHeader } from "@/components/shared/PageHeader";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { ColumnFiltersState, functionalUpdate, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
|
@ -28,7 +29,7 @@ interface VirtualKeysTableProps {
|
|||
headerActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const FILTER_COLUMNS = ["team_id", "org_id", "user_id", "key_hash"] as const;
|
||||
const FILTER_COLUMNS = ["team_id", "org_id", "user_id", "key_hash", "status"] as const;
|
||||
type FilterColumn = (typeof FILTER_COLUMNS)[number];
|
||||
|
||||
const FILTER_LABELS: Record<FilterColumn, string> = {
|
||||
|
|
@ -36,8 +37,28 @@ const FILTER_LABELS: Record<FilterColumn, string> = {
|
|||
org_id: "Organization",
|
||||
user_id: "User ID",
|
||||
key_hash: "Key ID",
|
||||
status: "Status",
|
||||
};
|
||||
|
||||
const KEY_STATUS_VALUES = ["active", "expired", "revoked", "deleted"] as const;
|
||||
type KeyStatusFilter = (typeof KEY_STATUS_VALUES)[number];
|
||||
const ALL_STATUSES = "all";
|
||||
|
||||
const KEY_STATUS_LABELS: Record<KeyStatusFilter, string> = {
|
||||
active: "Active",
|
||||
expired: "Expired",
|
||||
revoked: "Revoked (blocked)",
|
||||
deleted: "Deleted",
|
||||
};
|
||||
|
||||
const STATUS_FILTER_ITEMS = [
|
||||
{ value: ALL_STATUSES, label: "All statuses" },
|
||||
...KEY_STATUS_VALUES.map((value) => ({ value, label: KEY_STATUS_LABELS[value] })),
|
||||
];
|
||||
|
||||
const isKeyStatusFilter = (value: string): value is KeyStatusFilter =>
|
||||
(KEY_STATUS_VALUES as readonly string[]).includes(value);
|
||||
|
||||
const DEFAULT_SORT_BY = "created_at";
|
||||
const DEFAULT_SORT_ORDER = "desc";
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
|
@ -65,6 +86,7 @@ const TABLE_STATE = {
|
|||
filter_org: parseAsString.withDefault(""),
|
||||
filter_user: parseAsString.withDefault(""),
|
||||
filter_key_id: parseAsString.withDefault(""),
|
||||
filter_status: parseAsString.withDefault(""),
|
||||
};
|
||||
|
||||
const toSortOrder = (active: SortingState[number]): "asc" | "desc" => (active.desc ? "desc" : "asc");
|
||||
|
|
@ -96,15 +118,16 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
() => ({ pageIndex: tableState.page - 1, pageSize: tableState.page_size }),
|
||||
[tableState.page, tableState.page_size],
|
||||
);
|
||||
const { filter_team, filter_org, filter_user, filter_key_id } = tableState;
|
||||
const { filter_team, filter_org, filter_user, filter_key_id, filter_status } = tableState;
|
||||
const appliedFilters = useMemo(
|
||||
() => ({
|
||||
team_id: filter_team.trim(),
|
||||
org_id: filter_org.trim(),
|
||||
user_id: filter_user.trim(),
|
||||
key_hash: filter_key_id.trim(),
|
||||
status: isKeyStatusFilter(filter_status) ? filter_status : "",
|
||||
}),
|
||||
[filter_team, filter_org, filter_user, filter_key_id],
|
||||
[filter_team, filter_org, filter_user, filter_key_id, filter_status],
|
||||
);
|
||||
const columnFilters = useMemo<ColumnFiltersState>(
|
||||
() =>
|
||||
|
|
@ -121,6 +144,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
search: searchQuery.trim() || undefined,
|
||||
userID: appliedFilters.user_id || undefined,
|
||||
keyHash: appliedFilters.key_hash || undefined,
|
||||
status: appliedFilters.status || undefined,
|
||||
sortBy,
|
||||
sortOrder: tableState.sort_order,
|
||||
expand: "user",
|
||||
|
|
@ -164,6 +188,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
filter_org: filterValue(next, "org_id"),
|
||||
filter_user: filterValue(next, "user_id"),
|
||||
filter_key_id: filterValue(next, "key_hash"),
|
||||
filter_status: filterValue(next, "status"),
|
||||
page: null,
|
||||
};
|
||||
void setTableState(nextFilters);
|
||||
|
|
@ -233,6 +258,9 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
if (columnId === "org_id") {
|
||||
return organizations.find((org) => org.organization_id === raw)?.organization_alias || raw;
|
||||
}
|
||||
if (columnId === "status" && isKeyStatusFilter(raw)) {
|
||||
return KEY_STATUS_LABELS[raw];
|
||||
}
|
||||
return raw;
|
||||
},
|
||||
[allTeams, organizations],
|
||||
|
|
@ -340,6 +368,24 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
placeholder="Enter Key ID…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Status">
|
||||
<Select
|
||||
items={STATUS_FILTER_ITEMS}
|
||||
value={(get("status") as string) || ALL_STATUSES}
|
||||
onValueChange={(value) => set("status", value === ALL_STATUSES ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label="Status">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_FILTER_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
)}
|
||||
</DataTableFilterDrawer>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ export const KEY_TABLE_SORT_FIELDS: readonly string[] = [
|
|||
];
|
||||
|
||||
const getKeyStatus = (key: KeyResponse): KeyStatus => {
|
||||
if (key.deleted_at) {
|
||||
return {
|
||||
tone: "neutral",
|
||||
label: "Deleted",
|
||||
tooltip: `Deleted ${new Date(key.deleted_at).toLocaleString()}${key.deleted_by ? ` by ${key.deleted_by}` : ""}. Kept for audit and spend history; requests using this key are rejected.`,
|
||||
};
|
||||
}
|
||||
if (key.blocked === true) {
|
||||
const isScimBlocked = (key.metadata as Record<string, unknown> | null | undefined)?.scim_blocked === true;
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ export interface KeyResponse {
|
|||
model_max_budget_usage?: Record<string, ModelBudgetUsage> | null;
|
||||
soft_budget_cooldown: boolean;
|
||||
blocked: boolean;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
litellm_budget_table: Record<string, unknown>;
|
||||
organization_id: string | null;
|
||||
org_id?: string | null;
|
||||
|
|
|
|||
11
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
11
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -7859,7 +7859,10 @@ export interface paths {
|
|||
*
|
||||
* Returns:
|
||||
* - key: str - The key that was looked up, echoed back as it was passed in
|
||||
* - info: dict - The key's row, minus the hashed token
|
||||
* - info: dict - The key's row, minus the hashed token. Deleted keys are served from the
|
||||
* LiteLLM_DeletedVerificationToken archive and carry deleted_at / deleted_by
|
||||
* - status: "active" | "expired" | "revoked" | "deleted" - Derived from blocked, expires and
|
||||
* whether the row came from the archive
|
||||
* - key_alias: str | None - User-friendly key alias
|
||||
* - spend: float - Amount spent by the key. When budget_duration is set this covers only the
|
||||
* current budget window, not the key's lifetime
|
||||
|
|
@ -7917,7 +7920,9 @@ export interface paths {
|
|||
*
|
||||
* Parameters:
|
||||
* expand: Optional[List[str]] - Expand related objects (e.g. 'user' to include user information)
|
||||
* status: Optional[str] - Filter by status. Currently supports "deleted" to query deleted keys.
|
||||
* status: Optional[str] - Filter by status: "active", "expired", "revoked" (blocked) or "deleted".
|
||||
* "deleted" reads the LiteLLM_DeletedVerificationToken archive; the other values partition the
|
||||
* live key table, so every live key matches exactly one of them.
|
||||
*
|
||||
* Returns:
|
||||
* {
|
||||
|
|
@ -51185,7 +51190,7 @@ export interface operations {
|
|||
sort_order?: string;
|
||||
/** @description Expand related objects (e.g. 'user') */
|
||||
expand?: string[] | null;
|
||||
/** @description Filter by status (e.g. 'deleted') */
|
||||
/** @description Filter by status: 'active' (not blocked, not expired), 'expired' (not blocked, past expiry), 'revoked' (blocked) or 'deleted' (archived keys). Omit to return live keys regardless of status. */
|
||||
status?: string | null;
|
||||
/** @description Filter keys by project ID */
|
||||
project_id?: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue