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:
yassin 2026-09-15 22:12:01 +00:00
parent 79d4d4d8f5
commit 6c8b9a7b05
7 changed files with 422 additions and 30 deletions

View file

@ -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

View file

@ -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():
"""

View file

@ -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 });

View file

@ -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>

View file

@ -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 {

View file

@ -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;

View file

@ -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;