[Feature] Track key last active timestamp

Virtual keys only track created_at and updated_at, which don't indicate
when a key was last used. This adds a last_active field that gets updated
during the async batch spend update, giving admins visibility into which
keys are actively being used.

Changes:
- Add last_active DateTime? to VerificationToken and
  DeletedVerificationToken in all 3 schema files and Python types
- Set last_active in the batch key spend update alongside spend increment
- Add Last Active column to virtual keys UI table with info popover
  and hover tooltip showing full date/time with timezone

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-02-18 23:11:58 -08:00
parent 1e372ebc82
commit 6097905e55
9 changed files with 198 additions and 7 deletions

View File

@ -306,6 +306,7 @@ model LiteLLM_VerificationToken {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
last_active DateTime? // When this key was last used
rotation_count Int? @default(0) // Number of times key has been rotated
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
rotation_interval String? // How often to rotate (e.g., "30d", "90d")
@ -376,6 +377,7 @@ model LiteLLM_DeletedVerificationToken {
created_by String? // Original creator
updated_at DateTime? // Last update timestamp before deletion
updated_by String? // Last user who updated before deletion
last_active DateTime? // When this key was last used before deletion
rotation_count Int? @default(0)
auto_rotate Boolean? @default(false)
rotation_interval String?

View File

@ -2190,6 +2190,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
created_by: Optional[str] = None
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
last_active: Optional[datetime] = None
object_permission_id: Optional[str] = None
object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
access_group_ids: Optional[List[str]] = None

View File

@ -12,7 +12,7 @@ import os
import random
import time
import traceback
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cast, overload
import litellm
@ -792,7 +792,10 @@ class DBSpendUpdateWriter:
) in key_list_transactions.items():
batcher.litellm_verificationtoken.update_many( # 'update_many' prevents error from being raised if no row exists
where={"token": token},
data={"spend": {"increment": response_cost}},
data={
"spend": {"increment": response_cost},
"last_active": datetime.now(timezone.utc),
},
)
break
except DB_CONNECTION_ERROR_TYPES as e:

View File

@ -306,6 +306,7 @@ model LiteLLM_VerificationToken {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
last_active DateTime? // When this key was last used
rotation_count Int? @default(0) // Number of times key has been rotated
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
rotation_interval String? // How often to rotate (e.g., "30d", "90d")
@ -376,6 +377,7 @@ model LiteLLM_DeletedVerificationToken {
created_by String? // Original creator
updated_at DateTime? // Last update timestamp before deletion
updated_by String? // Last user who updated before deletion
last_active DateTime? // When this key was last used before deletion
rotation_count Int? @default(0)
auto_rotate Boolean? @default(false)
rotation_interval String?

View File

@ -306,6 +306,7 @@ model LiteLLM_VerificationToken {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
last_active DateTime? // When this key was last used
rotation_count Int? @default(0) // Number of times key has been rotated
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
rotation_interval String? // How often to rotate (e.g., "30d", "90d")
@ -376,6 +377,7 @@ model LiteLLM_DeletedVerificationToken {
created_by String? // Original creator
updated_at DateTime? // Last update timestamp before deletion
updated_by String? // Last user who updated before deletion
last_active DateTime? // When this key was last used before deletion
rotation_count Int? @default(0)
auto_rotate Boolean? @default(false)
rotation_interval String?

View File

@ -7,7 +7,7 @@ sys.path.insert(
) # Adds the parent directory to the system path
from datetime import datetime
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch, call
import pytest
@ -1000,3 +1000,79 @@ async def test_update_daily_spend_re_raises_exception_after_logging():
table_name="litellm_dailyuserspend",
unique_constraint_name="user_id_date_api_key_model_custom_llm_provider_mcp_namespaced_tool_name_endpoint",
)
@pytest.mark.asyncio
async def test_commit_key_spend_updates_includes_last_active():
"""
Test that _commit_spend_updates_to_db sets last_active alongside spend
when updating the key table.
"""
db_writer = DBSpendUpdateWriter()
# Create mock prisma client with transaction support
mock_batcher = MagicMock()
mock_batcher.litellm_verificationtoken = MagicMock()
mock_batcher.litellm_verificationtoken.update_many = MagicMock()
mock_transaction = AsyncMock()
mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction)
mock_transaction.__aexit__ = AsyncMock(return_value=False)
mock_transaction.batch_ = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_batcher),
__aexit__=AsyncMock(return_value=False),
))
mock_prisma_client = MagicMock()
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction)
# Also mock the other table batchers to avoid errors
mock_batcher.litellm_usertable = MagicMock()
mock_batcher.litellm_usertable.update_many = MagicMock()
mock_batcher.litellm_teamtable = MagicMock()
mock_batcher.litellm_teamtable.update_many = MagicMock()
mock_batcher.litellm_organizationtable = MagicMock()
mock_batcher.litellm_organizationtable.update_many = MagicMock()
mock_proxy_logging = MagicMock()
db_spend_update_transactions = {
"user_list_transactions": {},
"end_user_list_transactions": {},
"key_list_transactions": {"hashed_token_abc": 0.05},
"team_list_transactions": {},
"team_member_list_transactions": {},
"org_list_transactions": {},
"tag_list_transactions": {},
}
before_call = datetime.now(timezone.utc)
with patch(
"litellm.proxy.utils._raise_failed_update_spend_exception"
):
await db_writer._commit_spend_updates_to_db(
prisma_client=mock_prisma_client,
n_retry_times=0,
proxy_logging_obj=mock_proxy_logging,
db_spend_update_transactions=db_spend_update_transactions,
)
after_call = datetime.now(timezone.utc)
# Verify update_many was called on the key table
mock_batcher.litellm_verificationtoken.update_many.assert_called_once()
call_kwargs = mock_batcher.litellm_verificationtoken.update_many.call_args[1]
# Verify the where clause targets the correct token
assert call_kwargs["where"] == {"token": "hashed_token_abc"}
# Verify data includes both spend increment and last_active
assert call_kwargs["data"]["spend"] == {"increment": 0.05}
assert "last_active" in call_kwargs["data"]
# Verify last_active is a datetime within the expected range
last_active = call_kwargs["data"]["last_active"]
assert isinstance(last_active, datetime)
assert before_call <= last_active <= after_call

View File

@ -99,6 +99,7 @@ const mockKey: KeyResponse = {
created_at: "2024-11-01T10:00:00Z",
created_by: "user-1",
updated_at: "2024-11-15T10:00:00Z",
last_active: "2024-11-20T14:30:00Z",
team_spend: 5.5,
team_alias: "Test Team",
team_tpm_limit: 5000,
@ -625,3 +626,78 @@ it("should render table without crashing when models is undefined", async () =>
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
});
});
it("should render Last Active column header with info icon", () => {
const mockProps = {
teams: [mockTeam],
organizations: [mockOrganization],
onSortChange: vi.fn(),
currentSort: {
sortBy: "created_at",
sortOrder: "desc" as const,
},
};
renderWithProviders(<VirtualKeysTable {...mockProps} />);
expect(screen.getByText("Last Active")).toBeInTheDocument();
});
it("should display formatted date for last_active when value exists", async () => {
const mockProps = {
teams: [mockTeam],
organizations: [mockOrganization],
onSortChange: vi.fn(),
currentSort: {
sortBy: "created_at",
sortOrder: "desc" as const,
},
};
renderWithProviders(<VirtualKeysTable {...mockProps} />);
await waitFor(() => {
const expectedDate = new Date("2024-11-20T14:30:00Z").toLocaleDateString();
expect(screen.getByText(expectedDate)).toBeInTheDocument();
});
});
it("should display 'Unknown' for last_active when value is null", async () => {
const keyWithNullLastActive = {
...mockKey,
last_active: null,
};
mockUseFilterLogic.mockReturnValue({
filters: {
"Team ID": "",
"Organization ID": "",
"Key Alias": "",
"User ID": "",
"Sort By": "created_at",
"Sort Order": "desc",
},
filteredKeys: [keyWithNullLastActive],
allKeyAliases: ["test-key-alias"],
allTeams: [mockTeam],
allOrganizations: [mockOrganization],
handleFilterChange: vi.fn(),
handleFilterReset: vi.fn(),
});
const mockProps = {
teams: [mockTeam],
organizations: [mockOrganization],
onSortChange: vi.fn(),
currentSort: {
sortBy: "created_at",
sortOrder: "desc" as const,
},
};
renderWithProviders(<VirtualKeysTable {...mockProps} />);
await waitFor(() => {
expect(screen.getByText("Unknown")).toBeInTheDocument();
});
});

View File

@ -24,8 +24,9 @@ import {
TableRow,
Text,
} from "@tremor/react";
import { Skeleton, Tooltip } from "antd";
import React, { useEffect, useState } from "react";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Popover, Skeleton, Tooltip } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { useFilterLogic } from "../key_team_helpers/filter_logic";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
@ -112,7 +113,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
}
}, [refetch]);
const columns: ColumnDef<KeyResponse>[] = [
const columns: ColumnDef<KeyResponse>[] = useMemo(() => [
{
id: "expander",
header: () => null,
@ -292,6 +293,33 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
return value ? new Date(value as string).toLocaleDateString() : "Never";
},
},
{
id: "last_active",
accessorKey: "last_active",
header: () => (
<span className="flex items-center gap-1">
Last Active
<Popover
content="This is a new field and is not backfilled. Only new key usage will update this value."
trigger="hover"
>
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
</Popover>
</span>
),
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
if (!value) return "Unknown";
const date = new Date(value as string);
return (
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
<span>{date.toLocaleDateString()}</span>
</Tooltip>
);
},
},
{
id: "expires",
accessorKey: "expires",
@ -437,7 +465,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
);
},
},
];
], []);
const filterOptions: FilterOption[] = [
{

View File

@ -48,6 +48,7 @@ export interface KeyResponse {
organization_id: string | null;
created_at: string;
updated_at: string;
last_active: string | null;
team_spend: number;
team_alias: string;
team_tpm_limit: number;