diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 8bd46672ae..d5ede4e951 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -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? diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 09b4952cd8..167f0c2d87 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 9675b82b14..03628fda47 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -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: diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 8bd46672ae..d5ede4e951 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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? diff --git a/schema.prisma b/schema.prisma index 8bd46672ae..d5ede4e951 100644 --- a/schema.prisma +++ b/schema.prisma @@ -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? diff --git a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py index 1dd5cba2c4..0fa0d4cf10 100644 --- a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py +++ b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py @@ -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 diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 749396c82f..9dc468c6a9 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index f7c47943e7..ff996a0434 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -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[] = [ + const columns: ColumnDef[] = 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: () => ( + + Last Active + + + + + ), + size: 130, + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + if (!value) return "Unknown"; + const date = new Date(value as string); + return ( + + {date.toLocaleDateString()} + + ); + }, + }, { id: "expires", accessorKey: "expires", @@ -437,7 +465,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo ); }, }, - ]; + ], []); const filterOptions: FilterOption[] = [ { diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index b54eb21a0a..5512809ba3 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -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;