diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.41-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.41-py3-none-any.whl new file mode 100644 index 00000000000..9d7fdb78f72 Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.41-py3-none-any.whl differ diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.41.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.41.tar.gz new file mode 100644 index 00000000000..a478356f886 Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.41.tar.gz differ diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.43-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.43-py3-none-any.whl new file mode 100644 index 00000000000..ee821fed313 Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.43-py3-none-any.whl differ diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.43.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.43.tar.gz new file mode 100644 index 00000000000..d0304bd9825 Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.43.tar.gz differ diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260218231534_add_last_active_to_key_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260218231534_add_last_active_to_key_table/migration.sql new file mode 100644 index 00000000000..ded1856059b --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260218231534_add_last_active_to_key_table/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN "last_active" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "last_active" TIMESTAMP(3); + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 84d6f3a391f..d1695e21ff0 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -384,6 +384,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") @@ -455,6 +456,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-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 5c8ade56400..28179d7656a 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-proxy-extras" -version = "0.4.42" +version = "0.4.43" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." authors = ["BerriAI"] readme = "README.md" @@ -22,7 +22,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "0.4.42" +version = "0.4.43" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-proxy-extras==", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9a208d48392..231b9fdd1f6 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2206,6 +2206,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 9675b82b145..03628fda47f 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 67395b8be72..b646db013c5 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -337,6 +337,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") @@ -408,6 +409,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/pyproject.toml b/pyproject.toml index f2b8a8019f0..b707d1cae33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,11 @@ boto3 = { version = "1.40.76", optional = true } redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"} a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"} -litellm-proxy-extras = {version = "0.4.42", optional = true} +<<<<<<< HEAD +litellm-proxy-extras = {version = "0.4.41", optional = true} +======= +litellm-proxy-extras = {version = "0.4.43", optional = true} +>>>>>>> origin rich = {version = "13.7.1", optional = true} litellm-enterprise = {version = "0.1.32", optional = true} diskcache = {version = "^5.6.1", optional = true} diff --git a/requirements.txt b/requirements.txt index 3cf902b5320..c7b9a203b21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ grpcio>=1.75.0; python_version >= "3.14" sentry_sdk==2.21.0 # for sentry error handling detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests tzdata==2025.1 # IANA time zone database -litellm-proxy-extras==0.4.42 # for proxy extras - e.g. prisma migrations +litellm-proxy-extras==0.4.43 # for proxy extras - e.g. prisma migrations llm-sandbox==0.3.31 # for skill execution in sandbox ### LITELLM PACKAGE DEPENDENCIES python-dotenv==1.0.1 # for env diff --git a/schema.prisma b/schema.prisma index 67395b8be72..b646db013c5 100644 --- a/schema.prisma +++ b/schema.prisma @@ -337,6 +337,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") @@ -408,6 +409,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 1dd5cba2c4b..0fa0d4cf10b 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 749396c82f3..9dc468c6a9f 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 f7c47943e7e..ff996a0434a 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 b54eb21a0ae..5512809ba3f 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;