diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260810000000_add_verificationtoken_settings_updated_at/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260810000000_add_verificationtoken_settings_updated_at/migration.sql new file mode 100644 index 00000000000..fa12f4eb138 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260810000000_add_verificationtoken_settings_updated_at/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN IF NOT EXISTS "settings_updated_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN IF NOT EXISTS "settings_updated_at" TIMESTAMP(3); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 33fd9389b63..854602f5380 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -452,6 +452,7 @@ model LiteLLM_VerificationToken { created_by String? updated_at DateTime? @default(now()) @updatedAt @map("updated_at") updated_by String? + settings_updated_at DateTime? @map("settings_updated_at") 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 @@ -548,6 +549,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 + settings_updated_at DateTime? // Last configuration change before deletion last_active DateTime? // When this key was last used before deletion rotation_count Int? @default(0) auto_rotate Boolean? @default(false) diff --git a/litellm/models/verification_token.py b/litellm/models/verification_token.py index ea822c2dab0..fec3caec457 100644 --- a/litellm/models/verification_token.py +++ b/litellm/models/verification_token.py @@ -49,6 +49,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): created_by: str | None = None updated_at: datetime | None = None updated_by: str | None = None + settings_updated_at: datetime | None = None last_active: datetime | None = None object_permission_id: str | None = None object_permission: LiteLLM_ObjectPermissionTable | None = None diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 38b5d755535..836b223c6ad 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -88,6 +88,7 @@ from litellm.proxy.management_endpoints.common_utils import ( from litellm.proxy.management_endpoints.model_management_endpoints import ( _add_model_to_db, ) +from litellm.proxy.management_helpers.key_settings_audit import with_settings_updated_at from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, attach_object_permission_to_dict, @@ -4693,7 +4694,7 @@ async def _execute_virtual_key_regeneration( updated_token: Final = await VerificationTokenRepository(prisma_client).table.update( where={"token": hashed_api_key}, - data=jsonified_update_data, + data=with_settings_updated_at(jsonified_update_data), ) updated_token_dict: Final[dict[str, object]] = dict(updated_token) if updated_token is not None else {} updated_token_dict["key"] = new_token @@ -6203,7 +6204,7 @@ async def block_key( record: Final = await _prisma_table(VerificationTokenRepository(prisma_client)).update( where={"token": hashed_token}, - data={"blocked": True}, + data=with_settings_updated_at({"blocked": True}), ) ## UPDATE KEY CACHE - invalidate so next read re-fetches from DB @@ -6316,7 +6317,7 @@ async def unblock_key( record: Final = await _prisma_table(VerificationTokenRepository(prisma_client)).update( where={"token": hashed_token}, - data={"blocked": False}, + data=with_settings_updated_at({"blocked": False}), ) ## UPDATE KEY CACHE - invalidate so next read re-fetches from DB diff --git a/litellm/proxy/management_helpers/key_settings_audit.py b/litellm/proxy/management_helpers/key_settings_audit.py new file mode 100644 index 00000000000..a2c4bd8cac0 --- /dev/null +++ b/litellm/proxy/management_helpers/key_settings_audit.py @@ -0,0 +1,14 @@ +"""Audit stamping for virtual key configuration changes.""" + +from collections.abc import Mapping +from datetime import datetime, timezone + + +def with_settings_updated_at(data: Mapping[str, object]) -> dict[str, object]: + """Stamp a key update payload with the time its configuration changed. + + ``updated_at`` carries Prisma's ``@updatedAt`` and so is rewritten by every + spend flush, which makes it useless for auditing; ``settings_updated_at`` is + written only from key-management write paths. + """ + return {**data, "settings_updated_at": datetime.now(timezone.utc)} diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 33fd9389b63..854602f5380 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -452,6 +452,7 @@ model LiteLLM_VerificationToken { created_by String? updated_at DateTime? @default(now()) @updatedAt @map("updated_at") updated_by String? + settings_updated_at DateTime? @map("settings_updated_at") 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 @@ -548,6 +549,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 + settings_updated_at DateTime? // Last configuration change before deletion last_active DateTime? // When this key was last used before deletion rotation_count Int? @default(0) auto_rotate Boolean? @default(false) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index dd0c57aa911..b5972935806 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -135,6 +135,7 @@ from litellm.proxy.hooks.sensitive_data_routing import ( _PROXY_SensitiveDataRoutingHandler, ) from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup +from litellm.proxy.management_helpers.key_settings_audit import with_settings_updated_at from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor from litellm.repositories.budget_repository import BudgetRepository from litellm.repositories.config_repository import ConfigRepository @@ -3996,7 +3997,7 @@ class PrismaClient: db_data["token"] = token response: Final = await VerificationTokenRepository(self).table.update( where={"token": token}, - data={**db_data}, + data=with_settings_updated_at(db_data), ) verbose_proxy_logger.debug("\033[91m" + f"DB Token Table update succeeded {response}" + "\033[0m") _data: dict = {} diff --git a/schema.prisma b/schema.prisma index 33fd9389b63..854602f5380 100644 --- a/schema.prisma +++ b/schema.prisma @@ -452,6 +452,7 @@ model LiteLLM_VerificationToken { created_by String? updated_at DateTime? @default(now()) @updatedAt @map("updated_at") updated_by String? + settings_updated_at DateTime? @map("settings_updated_at") 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 @@ -548,6 +549,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 + settings_updated_at DateTime? // Last configuration change before deletion last_active DateTime? // When this key was last used before deletion rotation_count Int? @default(0) auto_rotate Boolean? @default(false) 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 51810a28cdd..2c426e0f071 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 @@ -2157,3 +2157,67 @@ async def test_daily_transaction_compression_saved_tokens_zero_when_absent(): assert transaction["compression_saved_tokens"] == 0 assert transaction["compression_savings_spend"] == 0 assert transaction["prompt_caching_savings_spend"] == 0 + + +@pytest.mark.asyncio +async def test_commit_spend_updates_to_db_does_not_stamp_key_settings_updated_at(): + """Spend flushes must leave settings_updated_at alone, or it decays into + another `updated_at` and stops being an audit signal.""" + db_writer = DBSpendUpdateWriter() + + mock_batcher = MagicMock() + mock_batcher.litellm_verificationtoken = MagicMock() + mock_batcher.litellm_verificationtoken.update_many = MagicMock() + 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_teammembership = MagicMock() + mock_batcher.litellm_teammembership.update_many = MagicMock() + mock_batcher.litellm_organizationtable = MagicMock() + mock_batcher.litellm_organizationtable.update_many = MagicMock() + mock_batcher.litellm_tagtable = MagicMock() + mock_batcher.litellm_tagtable.update_many = MagicMock() + mock_batcher.litellm_agentstable = MagicMock() + mock_batcher.litellm_agentstable.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) + + token = "hashed-token-abc" + response_cost = 0.25 + db_spend_update_transactions = { + "user_list_transactions": {}, + "end_user_list_transactions": {}, + "key_list_transactions": {token: response_cost}, + "team_list_transactions": {}, + "team_member_list_transactions": {}, + "org_list_transactions": {}, + "tag_list_transactions": {}, + "agent_list_transactions": {}, + } + + 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=MagicMock(), + db_spend_update_transactions=db_spend_update_transactions, + ) + + mock_batcher.litellm_verificationtoken.update_many.assert_called_once() + call_kwargs = mock_batcher.litellm_verificationtoken.update_many.call_args[1] + assert call_kwargs["where"] == {"token": token} + assert set(call_kwargs["data"]) == {"spend", "last_active"} + assert call_kwargs["data"]["spend"] == {"increment": response_cost} diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 8f151ed882c..4c13a3367d9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -2293,9 +2293,9 @@ async def test_unblock_key_supports_both_sk_and_hashed_tokens(monkeypatch): ) # Verify that the database update was called with hashed token - mock_prisma_client.db.litellm_verificationtoken.update.assert_called_with( - where={"token": test_hashed_token}, data={"blocked": False} - ) + sk_token_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs + assert sk_token_call["where"] == {"token": test_hashed_token} + assert sk_token_call["data"]["blocked"] is False assert result == mock_key_record @@ -2313,9 +2313,9 @@ async def test_unblock_key_supports_both_sk_and_hashed_tokens(monkeypatch): ) # Verify that the database update was called with the same hashed token - mock_prisma_client.db.litellm_verificationtoken.update.assert_called_with( - where={"token": test_hashed_token}, data={"blocked": False} - ) + hashed_token_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs + assert hashed_token_call["where"] == {"token": test_hashed_token} + assert hashed_token_call["data"]["blocked"] is False assert result == mock_key_record @@ -2849,9 +2849,10 @@ async def test_block_key_existing_key_succeeds(monkeypatch): mock_prisma_client.db.litellm_verificationtoken.find_unique.assert_called_once_with( where={"token": test_hashed_token} ) - mock_prisma_client.db.litellm_verificationtoken.update.assert_called_once_with( - where={"token": test_hashed_token}, data={"blocked": True} - ) + mock_prisma_client.db.litellm_verificationtoken.update.assert_called_once() + block_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs + assert block_call["where"] == {"token": test_hashed_token} + assert block_call["data"]["blocked"] is True assert result == mock_updated_record @@ -4717,6 +4718,7 @@ def test_transform_verification_tokens_to_deleted_records(): user_role=LitellmUserRoles.PROXY_ADMIN.value, ) + config_stamp = datetime(2026, 8, 10, 12, 30, 45, tzinfo=timezone.utc) key1 = LiteLLM_VerificationToken( token="hashed-token-1", user_id="user-123", @@ -4733,6 +4735,7 @@ def test_transform_verification_tokens_to_deleted_records(): model_spend={}, soft_budget_cooldown=False, allowed_routes=[], + settings_updated_at=config_stamp, ) key2 = LiteLLM_VerificationToken( @@ -4775,6 +4778,7 @@ def test_transform_verification_tokens_to_deleted_records(): assert record1["token"] == "hashed-token-1" assert record1["user_id"] == "user-123" assert record1["team_id"] == "team-456" + assert record1["settings_updated_at"] == config_stamp assert isinstance(record1["aliases"], str) assert isinstance(record1["config"], str) assert isinstance(record1["permissions"], str) @@ -15817,3 +15821,91 @@ async def test_regenerate_key_output_token_estimate_lowered_rejected_for_non_adm assert exc.value.status_code == 403 assert "Only proxy admins can set" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_execute_virtual_key_regeneration_stamps_settings_updated_at(): + """Regenerate rewrites the key's config, so it must move settings_updated_at.""" + from datetime import datetime, timezone + + from litellm.proxy._types import RegenerateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _execute_virtual_key_regeneration, + ) + + mock_prisma_client = _make_regenerate_mock_prisma() + + with _patch_regenerate_side_effects(): + before = datetime.now(timezone.utc) + await _execute_virtual_key_regeneration( + prisma_client=mock_prisma_client, + key_in_db=_make_regenerate_existing_key(), + hashed_api_key="abc123", + key="abc123", + data=RegenerateKeyRequest(max_budget=42.0), + user_api_key_dict=_make_regenerate_user_api_key_dict(), + litellm_changed_by=None, + user_api_key_cache=MagicMock(), + proxy_logging_obj=MagicMock(), + ) + after = datetime.now(timezone.utc) + + sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"] + assert sent["max_budget"] == 42.0 + assert before <= sent["settings_updated_at"] <= after + + +@pytest.mark.asyncio +async def test_block_key_stamps_settings_updated_at(monkeypatch): + """Blocking a key is a config change, not spend activity.""" + from datetime import datetime, timezone + + from litellm.proxy._types import BlockKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import block_key + + mock_prisma_client, _ = _setup_block_unblock_mocks(monkeypatch) + + before = datetime.now(timezone.utc) + await block_key( + data=BlockKeyRequest(key="sk-test123456789"), + http_request=MagicMock(), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-admin", + user_id="admin_user", + ), + litellm_changed_by=None, + ) + after = datetime.now(timezone.utc) + + sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"] + assert sent["blocked"] is True + assert before <= sent["settings_updated_at"] <= after + + +@pytest.mark.asyncio +async def test_unblock_key_stamps_settings_updated_at(monkeypatch): + """Unblocking a key is a config change, not spend activity.""" + from datetime import datetime, timezone + + from litellm.proxy._types import BlockKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import unblock_key + + mock_prisma_client, _ = _setup_block_unblock_mocks(monkeypatch) + + before = datetime.now(timezone.utc) + await unblock_key( + data=BlockKeyRequest(key="sk-test123456789"), + http_request=MagicMock(), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-admin", + user_id="admin_user", + ), + litellm_changed_by=None, + ) + after = datetime.now(timezone.utc) + + sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"] + assert sent["blocked"] is False + assert before <= sent["settings_updated_at"] <= after diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index a8e81e92ebd..a4f93e90673 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -1169,3 +1169,25 @@ async def test_prisma_health_check_failure_redacts_database_credentials(caplog): assert emitted assert all("hunter2" not in message for message in emitted) assert any("postgresql://REDACTED@db.internal" in message for message in emitted) + + +@pytest.mark.asyncio +async def test_update_data_key_branch_stamps_settings_updated_at(): + """`updated_at` carries Prisma's @updatedAt and is rewritten by every spend + flush, so key config edits need their own audit column.""" + from datetime import datetime, timezone + from unittest.mock import AsyncMock + + from litellm.proxy.utils import PrismaClient + + client = MagicMock() + client.jsonify_object = MagicMock(side_effect=lambda data: dict(data)) + client.db.litellm_verificationtoken.update = AsyncMock(return_value=None) + + before = datetime.now(timezone.utc) + await PrismaClient.update_data(client, token="sk-test-key", data={"models": ["gpt-4"]}) + after = datetime.now(timezone.utc) + + sent = client.db.litellm_verificationtoken.update.call_args.kwargs["data"] + assert sent["models"] == ["gpt-4"] + assert before <= sent["settings_updated_at"] <= after 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 4b446b0c283..920d1f4af5a 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 @@ -60,6 +60,7 @@ export interface KeyResponse { created_at: string; created_by?: string; updated_at: string; + settings_updated_at?: string | null; last_active: string | null; team_spend: number; team_alias: string; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 8e9dd2fa975..680fbe9ff1e 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -149,6 +149,46 @@ describe("KeyInfoView", () => { await userEvent.click(await screen.findByRole("button", { name: /more key actions/i })); }; + describe("last updated", () => { + const renderWithTimestamps = (overrides: Partial) => { + vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); + + return renderWithProviders( + {}} + keyId={"test-key-id"} + onKeyDataUpdate={() => {}} + teams={[]} + />, + ); + }; + + const findLastUpdatedText = async () => { + const label = await screen.findByText("Last Updated"); + return label.closest("div")?.parentElement?.parentElement?.textContent ?? ""; + }; + + it("should show when the key was last configured, not when it last recorded spend", async () => { + renderWithTimestamps({ settings_updated_at: "2022-06-15T12:00:00Z" }); + + expect(await findLastUpdatedText()).toMatch(/Jun \d+, 2022/); + expect(screen.queryByText(/Jun \d+, 2023/)).not.toBeInTheDocument(); + }); + + it("should fall back to creation time for a key that was never reconfigured", async () => { + renderWithTimestamps({ settings_updated_at: null }); + + expect(await findLastUpdatedText()).toMatch(/Jun \d+, 2021/); + expect(screen.queryByText(/Jun \d+, 2023/)).not.toBeInTheDocument(); + }); + }); + it("should render tags", async () => { vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index b1ada186bb8..6a2a4a88425 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -444,6 +444,8 @@ export default function KeyInfoView({ ); }; + const lastConfiguredAt = currentKeyData.settings_updated_at || currentKeyData.created_at; + const parentTeam = currentKeyData.team_id ? teamsData?.find((team) => team.team_id === currentKeyData.team_id) : null; const budgetDisplay = @@ -468,7 +470,7 @@ export default function KeyInfoView({ currentKeyData.created_by || "", createdAt: currentKeyData.created_at ? formatTimestamp(currentKeyData.created_at) : "", - lastUpdated: currentKeyData.updated_at ? formatTimestamp(currentKeyData.updated_at) : "", + lastUpdated: lastConfiguredAt ? formatTimestamp(lastConfiguredAt) : "", lastActive: currentKeyData.last_active ? formatTimestamp(currentKeyData.last_active) : "Never", expires: currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never", }} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 8989e098f54..35adc9e92cf 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26280,6 +26280,8 @@ export interface components { } | null; /** Rpm Limit */ rpm_limit?: number | null; + /** Settings Updated At */ + settings_updated_at?: string | null; /** * Soft Budget Cooldown * @default false @@ -27719,6 +27721,8 @@ export interface components { } | null; /** Rpm Limit */ rpm_limit?: number | null; + /** Settings Updated At */ + settings_updated_at?: string | null; /** * Soft Budget Cooldown * @default false @@ -34887,6 +34891,8 @@ export interface components { rpm_limit_per_model?: { [key: string]: number; } | null; + /** Settings Updated At */ + settings_updated_at?: string | null; /** Soft Budget */ soft_budget?: number | null; /**