feat(proxy)!: default audit logs on for enterprise licenses (#37518)

* feat(proxy): enable audit logs by premium license

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): support premium audit logging mocks

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(proxy): disable audit logging for key rotation mocks

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-08-19 18:49:21 -07:00 • committed by GitHub
parent 0edd245545
commit 3a04860122
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 250 additions and 82 deletions

View file

@ -221,7 +221,7 @@ overwrite_user_with_key_hash: bool = (
bedrock_request_metadata_fields: Optional[Sequence[str]] = (
None # allow-list of `user_api_key_*` fields (+ `spend_logs_metadata`) sent as Bedrock `requestMetadata`
)
store_audit_logs = False # Enterprise feature, allow users to see audit logs
store_audit_logs: bool | None = None
skip_system_message_in_guardrail: bool = False
skip_tool_message_in_guardrail: bool = False
### end of callbacks #############

View file

@ -43,6 +43,7 @@ class KeyManagementEventHooks:
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -53,8 +54,7 @@ class KeyManagementEventHooks:
except Exception as e:
verbose_proxy_logger.warning("Failed to send key created email: %s", e)
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
if litellm.store_audit_logs is True:
if is_audit_logging_enabled():
_updated_values: Final = response.model_dump_json(exclude_none=True)
asyncio.create_task(
create_audit_log_for_update(
@ -103,11 +103,11 @@ class KeyManagementEventHooks:
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
if litellm.store_audit_logs is True:
if is_audit_logging_enabled():
_updated_values: Final = json.dumps(data.json(exclude_none=True), default=str)
_before_value = existing_key_row.json(exclude_none=True)
@ -144,6 +144,7 @@ class KeyManagementEventHooks:
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -180,7 +181,7 @@ class KeyManagementEventHooks:
verbose_proxy_logger.warning("Failed to send key rotated email: %s", e)
# store the audit log
if litellm.store_audit_logs is True and existing_key_row.token is not None:
if is_audit_logging_enabled() and existing_key_row.token is not None:
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
@ -218,12 +219,12 @@ class KeyManagementEventHooks:
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
# we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes
if litellm.store_audit_logs is True and data.keys is not None:
if is_audit_logging_enabled() and data.keys is not None:
# make an audit log for each key deleted
for key in keys_being_deleted:
if key.token is None:

View file

@ -20,7 +20,10 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
WebhookEvent,
)
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
is_audit_logging_enabled,
)
from litellm.repositories.user_repository import UserRepository
@ -203,7 +206,7 @@ class UserManagementEventHooks:
- user_api_key_dict: UserAPIKeyAuth - The user api key dictionary.
- litellm_proxy_admin_name: Optional[str] - The name of the proxy admin.
"""
if not litellm.store_audit_logs:
if not is_audit_logging_enabled():
return
from litellm.proxy.management_helpers.audit_logs import (

View file

@ -17,7 +17,6 @@ from typing import TYPE_CHECKING, Any, Final, Protocol
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
import litellm
from litellm._logging import verbose_proxy_logger
from litellm._redis import _redis_kwargs_from_environment
from litellm._uuid import uuid
@ -299,14 +298,15 @@ async def _emit_cache_settings_audit_log(
exception. Captured under ``LiteLLM_CacheConfig`` so the row
co-locates with the table it mutates.
"""
if litellm.store_audit_logs is not True:
return
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
if not is_audit_logging_enabled():
return
task: Final = asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(

View file

@ -100,16 +100,15 @@ async def _emit_hashicorp_vault_audit_log(
``LiteLLM_ConfigOverrides`` so the row co-locates with the table it
mutates.
"""
import litellm
if litellm.store_audit_logs is not True:
return
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
if not is_audit_logging_enabled():
return
task: Final = asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(

View file

@ -243,12 +243,15 @@ async def _emit_coordination_redis_audit_log(
litellm_changed_by: str | None,
) -> None:
"""Emit an audit-log row for a /coordination_redis/settings mutation."""
if litellm.store_audit_logs is not True:
return
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
if not is_audit_logging_enabled():
return
task: Final = asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(

View file

@ -2220,6 +2220,7 @@ async def delete_user(
)
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
@ -2298,9 +2299,8 @@ async def delete_user(
},
)
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
# we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes
if litellm.store_audit_logs is True:
if is_audit_logging_enabled():
# make an audit log for each team deleted
_user_row = user_row.json(exclude_none=True)

View file

@ -6245,6 +6245,7 @@ async def block_key(
"""
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
@ -6291,7 +6292,7 @@ async def block_key(
code=status.HTTP_404_NOT_FOUND,
)
if litellm.store_audit_logs is True:
if is_audit_logging_enabled():
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
@ -6358,6 +6359,7 @@ async def unblock_key(
"""
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
@ -6404,7 +6406,7 @@ async def unblock_key(
code=status.HTTP_404_NOT_FOUND,
)
if litellm.store_audit_logs is True:
if is_audit_logging_enabled():
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(

View file

@ -64,7 +64,10 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.proxy.management_helpers.audit_logs import get_audit_log_changed_by
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.repositories.table_repositories import (
MCPServerRepository,
MCPUserCredentialsRepository,
@ -2018,7 +2021,7 @@ if MCP_AVAILABLE:
await global_mcp_server_manager.reload_servers_from_database()
# TODO: Enterprise: Finish audit log trail
if litellm.store_audit_logs:
if is_audit_logging_enabled():
pass
# TODO: Delete from virtual keys
@ -2613,7 +2616,7 @@ if MCP_AVAILABLE:
)
# TODO: Enterprise: Finish audit log trail
if litellm.store_audit_logs:
if is_audit_logging_enabled():
pass
return _redact_mcp_credentials(mcp_server_record_updated)

View file

@ -13,7 +13,6 @@ from typing import Annotated, Any, Final
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.proxy._types import (
@ -182,14 +181,15 @@ async def _emit_team_callback_audit_log(
Callback secrets are redacted before serialization so the audit table
cannot itself become a credential-harvest sink.
"""
if litellm.store_audit_logs is not True:
return
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
if not is_audit_logging_enabled():
return
redacted_before: Final = _redact_callback_secrets(before_metadata)
redacted_after: Final = _redact_callback_secrets(after_metadata)

View file

@ -1249,6 +1249,7 @@ async def new_team(
try:
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import (
_license_check,
@ -1551,8 +1552,7 @@ async def new_team(
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
if litellm.store_audit_logs is True:
if is_audit_logging_enabled():
_updated_values = complete_team_data.json(exclude_none=True)
_updated_values = json.dumps(_updated_values, default=str)
@ -1944,6 +1944,7 @@ async def update_team(
```
"""
try:
from litellm.proxy.management_helpers.audit_logs import is_audit_logging_enabled
from litellm.proxy.proxy_server import (
litellm_proxy_admin_name,
llm_router,
@ -2246,8 +2247,7 @@ async def update_team(
proxy_logging_obj=proxy_logging_obj,
)
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
if litellm.store_audit_logs is True:
if is_audit_logging_enabled():
await _create_team_update_audit_log(
existing_team_row=existing_team_row,
updated_kv=updated_kv,
@ -3712,6 +3712,7 @@ async def delete_team(
"""
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
@ -3756,9 +3757,8 @@ async def delete_team(
litellm_changed_by=litellm_changed_by,
)
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
# we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes
if litellm.store_audit_logs is True:
if is_audit_logging_enabled():
# make an audit log for each team deleted
for team_id in data.team_ids:
team_row: LiteLLM_TeamTable | None = await prisma_client.get_data(

View file

@ -24,6 +24,22 @@ _audit_log_callback_cache: Final[dict[str, CustomLogger]] = {}
ALLOW_LITELLM_CHANGED_BY_HEADER_METADATA_KEY: Final = "allow_litellm_changed_by_header"
def is_audit_logging_enabled(store_audit_logs: bool | None = None) -> bool:
from litellm.secret_managers.main import get_secret_bool
configured_value: Final[bool | None] = litellm.store_audit_logs if store_audit_logs is None else store_audit_logs
if configured_value is not None:
return configured_value
environment_value: Final[bool | None] = get_secret_bool("LITELLM_STORE_AUDIT_LOGS")
if environment_value is not None:
return environment_value
from litellm.proxy.proxy_server import premium_user
return premium_user is True
def _allows_litellm_changed_by_header(user_api_key_dict: UserAPIKeyAuth) -> bool:
for admin_metadata in (user_api_key_dict.metadata, user_api_key_dict.team_metadata):
if (
@ -164,11 +180,7 @@ async def create_object_audit_log(
- user_api_key_dict: UserAPIKeyAuth - The user api key dictionary.
- litellm_proxy_admin_name: Optional[str] - The name of the proxy admin.
"""
from litellm.secret_managers.main import get_secret_bool
_store_audit_logs: Final[bool | None] = litellm.store_audit_logs or get_secret_bool("LITELLM_STORE_AUDIT_LOGS")
if _store_audit_logs is not True:
if not is_audit_logging_enabled():
return
_changed_by: Final = get_audit_log_changed_by(
@ -196,10 +208,7 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
"""
Create an audit log for an object.
"""
from litellm.secret_managers.main import get_secret_bool
_store_audit_logs: Final[bool | None] = litellm.store_audit_logs or get_secret_bool("LITELLM_STORE_AUDIT_LOGS")
if _store_audit_logs is not True:
if not is_audit_logging_enabled():
return
from litellm.proxy.proxy_server import premium_user, prisma_client

View file

@ -5035,6 +5035,7 @@ class ProxyConfig:
)
elif key == "audit_log_callbacks":
from litellm.proxy.management_helpers.audit_logs import (
is_audit_logging_enabled,
reset_audit_log_callback_cache,
)
@ -5053,14 +5054,14 @@ class ProxyConfig:
litellm.audit_log_callbacks.append(callback)
_store_audit_logs = litellm_settings.get("store_audit_logs", litellm.store_audit_logs)
if _store_audit_logs:
if is_audit_logging_enabled(store_audit_logs=_store_audit_logs):
print( # noqa: T201
f"{blue_color_code} Initialized Audit Log Callbacks - {litellm.audit_log_callbacks} {reset_color_code}"
)
else:
verbose_proxy_logger.warning(
"'audit_log_callbacks' is configured but 'store_audit_logs' is not enabled. "
"Audit log callbacks will not fire until 'store_audit_logs: true' is added to litellm_settings."
"'audit_log_callbacks' is configured but audit logging is not enabled. "
"Audit log callbacks will not fire."
)
elif key == "cache_params":
# this is set in the cache branch

View file

@ -25,6 +25,11 @@ from litellm.proxy._types import (
from litellm.proxy.common_utils.key_rotation_manager import KeyRotationManager
@pytest.fixture
def disable_audit_logging_for_mocked_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("litellm.store_audit_logs", False)
class TestKeyRotationManagerPassesKeyAlias:
"""
Regression tests to ensure KeyRotationManager passes key_alias
@ -155,7 +160,10 @@ class TestKeyRotationSecretNamingStability:
"""
@pytest.mark.asyncio
async def test_rotation_hook_uses_initial_secret_name_fallback(self):
async def test_rotation_hook_uses_initial_secret_name_fallback(
self,
disable_audit_logging_for_mocked_key,
):
"""
GIVEN: A key WITHOUT an alias (has an initial_secret_name based on token ID)
WHEN: The key is rotated
@ -206,7 +214,10 @@ class TestKeyRotationSecretNamingStability:
), f"Secret name drift! Expected {initial_secret_name}, got {call_kwargs['new_secret_name']}. This causes secret sprawl."
@pytest.mark.asyncio
async def test_rotation_hook_pre_rotation_alias_consistency(self):
async def test_rotation_hook_pre_rotation_alias_consistency(
self,
disable_audit_logging_for_mocked_key,
):
"""
GIVEN: A key WITH an alias
WHEN: The key is rotated

View file

@ -4,6 +4,7 @@ Tests for KeyManagementEventHooks.
Validates that email and secret manager operations are independent and non-blocking.
"""
import asyncio
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -155,6 +156,44 @@ class TestKeyManagementEventHooksIndependentOperations:
assert email_called["called"] is True
@pytest.mark.parametrize(
("premium_user", "expected_audit_log_calls"),
((True, 1), (False, 0)),
)
@pytest.mark.asyncio
async def test_key_generated_audit_log_uses_license_default(
monkeypatch: pytest.MonkeyPatch,
premium_user: bool,
expected_audit_log_calls: int,
):
from litellm.proxy._types import GenerateKeyRequest, GenerateKeyResponse, UserAPIKeyAuth
monkeypatch.setattr("litellm.store_audit_logs", None)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user)
monkeypatch.delenv("LITELLM_STORE_AUDIT_LOGS", raising=False)
response = GenerateKeyResponse(key="sk-test-key", token_id="token-123")
with (
patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
new_callable=AsyncMock,
) as mock_create_audit_log,
patch.object(
KeyManagementEventHooks,
"_store_virtual_key_in_secret_manager",
new_callable=AsyncMock,
),
):
await KeyManagementEventHooks.async_key_generated_hook(
data=GenerateKeyRequest(),
response=response,
user_api_key_dict=UserAPIKeyAuth(api_key="sk-admin-key", user_id="admin"),
)
await asyncio.sleep(0.01)
assert mock_create_audit_log.await_count == expected_audit_log_calls
class TestRotateVirtualKeyInSecretManager:
"""Tests for _rotate_virtual_key_in_secret_manager with team_id support."""

View file

@ -212,8 +212,9 @@ async def test_v1_key_generation_sends_email_when_send_invite_email_true():
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting
with patch.object(
KeyManagementEventHooks, "_send_key_created_email", mock_send_key_created_email
with (
patch("litellm.store_audit_logs", False),
patch.object(KeyManagementEventHooks, "_send_key_created_email", mock_send_key_created_email),
):
with patch(
"litellm.logging_callback_manager.get_custom_loggers_for_type",
@ -257,8 +258,9 @@ async def test_v1_key_generation_no_email_when_send_invite_email_false():
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting
with patch.object(
KeyManagementEventHooks, "_send_key_created_email", mock_send_key_created_email
with (
patch("litellm.store_audit_logs", False),
patch.object(KeyManagementEventHooks, "_send_key_created_email", mock_send_key_created_email),
):
with patch(
"litellm.logging_callback_manager.get_custom_loggers_for_type",

View file

@ -91,6 +91,8 @@ mock_prisma_client = MagicMock()
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.litellm_teamtable = MagicMock()
mock_prisma_client.db.litellm_teamtable.update = AsyncMock()
mock_prisma_client.db.litellm_auditlog = MagicMock()
mock_prisma_client.db.litellm_auditlog.create = AsyncMock()
# Fixture to provide the mock prisma client
@ -103,6 +105,11 @@ def mock_db_client():
mock_prisma_client.reset_mock()
@pytest.fixture
def disable_audit_logging_for_mocked_team(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("litellm.store_audit_logs", False)
# Fixture to provide a mock admin user auth object
@pytest.fixture
def mock_admin_auth():
@ -2060,7 +2067,9 @@ async def test_team_model_add_delete_refresh_team_cache(endpoint_name):
@pytest.mark.asyncio
async def test_update_team_team_member_budget_not_passed_to_db():
async def test_update_team_team_member_budget_not_passed_to_db(
disable_audit_logging_for_mocked_team,
):
"""
Test that 'team_member_budget' is never passed to prisma_client.db.litellm_teamtable.update
regardless of whether the value is set or None.
@ -2498,7 +2507,9 @@ async def test_upsert_team_member_budget_table_no_existing_budget():
@pytest.mark.asyncio
async def test_update_team_with_team_member_budget_duration():
async def test_update_team_with_team_member_budget_duration(
disable_audit_logging_for_mocked_team,
):
"""
Test that team/update endpoint properly handles team_member_budget_duration.
"""
@ -5171,7 +5182,9 @@ async def test_update_team_standalone_budget_raise_blocked_for_team_admin():
@pytest.mark.asyncio
async def test_update_team_standalone_budget_raise_allowed_for_proxy_admin():
async def test_update_team_standalone_budget_raise_allowed_for_proxy_admin(
disable_audit_logging_for_mocked_team,
):
"""
Test that a proxy admin CAN raise a standalone team's budget on /team/update.
@ -5325,7 +5338,9 @@ async def test_update_team_standalone_budget_removal_blocked_for_team_admin():
@pytest.mark.asyncio
async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed():
async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed(
disable_audit_logging_for_mocked_team,
):
"""
When a team currently has NO cap (max_budget=None / unlimited), a team admin
setting a finite max_budget is a RESTRICTION, not a raise, and is
@ -5407,7 +5422,9 @@ async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed():
@pytest.mark.asyncio
async def test_update_team_standalone_unchanged_budget_allowed():
async def test_update_team_standalone_unchanged_budget_allowed(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update for a standalone team does NOT compare against the
caller's personal max_budget when the budget is unchanged.
@ -5508,7 +5525,9 @@ async def test_update_team_standalone_unchanged_budget_allowed():
@pytest.mark.asyncio
async def test_update_team_standalone_lower_budget_allowed():
async def test_update_team_standalone_lower_budget_allowed(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update for a standalone team allows lowering the budget
below the team's current value even when the new value still exceeds the
@ -5691,7 +5710,9 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit():
@pytest.mark.asyncio
async def test_update_team_standalone_models_not_gated_by_user_limit():
async def test_update_team_standalone_models_not_gated_by_user_limit(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update for a standalone team does NOT gate the team's models
by the caller's personal allowed models.
@ -5775,7 +5796,9 @@ async def test_update_team_standalone_models_not_gated_by_user_limit():
@pytest.mark.asyncio
async def test_update_team_org_scoped_budget_bypasses_user_limit():
async def test_update_team_org_scoped_budget_bypasses_user_limit(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update for an org-scoped team does NOT validate budget against user's personal max_budget.
@ -5890,7 +5913,9 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit():
@pytest.mark.asyncio
async def test_update_team_org_scoped_models_bypasses_user_limit():
async def test_update_team_org_scoped_models_bypasses_user_limit(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update for an org-scoped team does NOT validate models against user's personal models.
@ -6080,7 +6105,9 @@ async def test_update_team_org_scoped_models_not_in_org_models():
@pytest.mark.asyncio
async def test_update_team_org_scoped_models_with_all_proxy_models():
async def test_update_team_org_scoped_models_with_all_proxy_models(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update for an org-scoped team succeeds when organization has 'all-proxy-models'.
@ -6196,7 +6223,9 @@ async def test_update_team_org_scoped_models_with_all_proxy_models():
@pytest.mark.asyncio
async def test_update_team_tpm_limit_not_gated_by_user_limit():
async def test_update_team_tpm_limit_not_gated_by_user_limit(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update does NOT gate the team's tpm_limit by the caller's
personal tpm_limit.
@ -6279,7 +6308,9 @@ async def test_update_team_tpm_limit_not_gated_by_user_limit():
@pytest.mark.asyncio
async def test_update_team_rpm_limit_not_gated_by_user_limit():
async def test_update_team_rpm_limit_not_gated_by_user_limit(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update does NOT gate the team's rpm_limit by the caller's
personal rpm_limit.
@ -6795,7 +6826,9 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit():
@pytest.mark.asyncio
async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit():
async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update for an org-scoped team bypasses user's TPM/RPM limits.
@ -6905,7 +6938,9 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit():
@pytest.mark.asyncio
async def test_update_team_guardrails_with_org_id():
async def test_update_team_guardrails_with_org_id(
disable_audit_logging_for_mocked_team,
):
"""
Test that updating team guardrails works when team has an organization_id.
The fix ensures 'teams' field is included when fetching organization data.
@ -7242,7 +7277,10 @@ async def test_persist_deleted_team_records():
@pytest.mark.asyncio
async def test_delete_team_persists_deleted_teams(monkeypatch):
async def test_delete_team_persists_deleted_teams(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
from litellm.proxy._types import DeleteTeamRequest
mock_prisma_client = AsyncMock()
@ -7325,7 +7363,10 @@ async def test_delete_team_persists_deleted_teams(monkeypatch):
@pytest.mark.asyncio
async def test_delete_team_sweeps_references_outside_members_with_roles(monkeypatch):
async def test_delete_team_sweeps_references_outside_members_with_roles(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
"""
Regression pin for LIT-5511: a deleted team stayed visible on user records.
@ -7431,7 +7472,10 @@ async def test_delete_team_sweeps_references_outside_members_with_roles(monkeypa
@pytest.mark.asyncio
async def test_delete_team_evicts_the_auth_cache_of_the_keys_it_deletes(monkeypatch):
async def test_delete_team_evicts_the_auth_cache_of_the_keys_it_deletes(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
"""
A virtual key scoped to the team is deleted from the db with the team, but auth resolves a
cached key object without re-reading the team, so leaving the cache entry behind lets that key
@ -7493,7 +7537,10 @@ async def test_delete_team_evicts_the_auth_cache_of_the_keys_it_deletes(monkeypa
@pytest.mark.asyncio
async def test_delete_team_failing_reconcile_sweep_cannot_strand_the_team_in_cache(monkeypatch):
async def test_delete_team_failing_reconcile_sweep_cannot_strand_the_team_in_cache(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
"""
The reconcile sweep runs after the team row is committed deleted. If it ran before cache
eviction, a sweep failure would return an error with the team gone from the db but still
@ -7555,7 +7602,10 @@ async def test_delete_team_failing_reconcile_sweep_cannot_strand_the_team_in_cac
@pytest.mark.asyncio
async def test_delete_team_broadcasts_cache_invalidation_to_other_workers(monkeypatch):
async def test_delete_team_broadcasts_cache_invalidation_to_other_workers(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
"""
Evicting locally only reaches the worker that handled the delete. Without the broadcast, every
other worker keeps serving the deleted team, and the deleted team's keys, out of its own
@ -7619,7 +7669,10 @@ async def test_delete_team_broadcasts_cache_invalidation_to_other_workers(monkey
@pytest.mark.asyncio
async def test_delete_team_survives_a_failing_cache_backend(monkeypatch):
async def test_delete_team_survives_a_failing_cache_backend(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
"""
Cache eviction runs after the reference sweep has already committed, so a cache backend that
is unreachable must not abort the delete. If it did, `/team/delete` would fail with the team
@ -8088,6 +8141,7 @@ async def test_update_team_soft_budget_validation(
expected_soft_budget,
expected_max_budget,
error_message,
disable_audit_logging_for_mocked_team,
):
"""
Test soft_budget validation in /team/update endpoint.
@ -8498,7 +8552,11 @@ async def test_get_team_daily_activity_member_without_permission_filters_by_keys
@pytest.mark.asyncio
async def test_update_team_with_router_settings(mock_db_client, mock_admin_auth):
async def test_update_team_with_router_settings(
mock_db_client,
mock_admin_auth,
disable_audit_logging_for_mocked_team,
):
"""
Test that /team/update correctly handles router_settings by:
1. Accepting router_settings as a dict parameter
@ -11594,7 +11652,9 @@ async def test_update_team_output_token_estimate_lowered_rejected_for_team_admin
@pytest.mark.asyncio
async def test_update_team_output_token_estimate_unchanged_allows_team_admin_edit():
async def test_update_team_output_token_estimate_unchanged_allows_team_admin_edit(
disable_audit_logging_for_mocked_team,
):
"""The team settings form resends every field it renders, so gating on
presence would break a team admin editing an unrelated setting."""
import contextlib
@ -11957,7 +12017,9 @@ class _FakeMirrorDb:
@pytest.mark.asyncio
async def test_update_team_syncs_access_group_assigned_team_ids_in_both_directions():
async def test_update_team_syncs_access_group_assigned_team_ids_in_both_directions(
disable_audit_logging_for_mocked_team,
):
"""
A team-side edit of `access_group_ids` must be mirrored onto every affected access
group's `assigned_team_ids`, in one transaction, in both directions.
@ -12113,7 +12175,9 @@ async def test_sync_reads_the_committed_team_row_rather_than_the_callers_snapsho
@pytest.mark.asyncio
async def test_new_team_and_delete_team_both_drive_the_mirror():
async def test_new_team_and_delete_team_both_drive_the_mirror(
disable_audit_logging_for_mocked_team,
):
"""Every writer of `team.access_group_ids` has to reach the mirror, not just update.
These pin the wiring on the other two paths; the mirror's own behavior is covered above.

View file

@ -19,6 +19,7 @@ from litellm.proxy.management_helpers.audit_logs import (
_build_audit_log_payload,
_dispatch_audit_log_to_callbacks,
create_audit_log_for_update,
is_audit_logging_enabled,
)
from litellm.types.utils import StandardAuditLogPayload
@ -49,6 +50,34 @@ def _make_audit_log(
)
@pytest.mark.parametrize(
("premium_user", "configured_value", "environment_value", "expected"),
(
(True, None, None, True),
(True, False, None, False),
(True, None, "false", False),
(False, None, None, False),
(False, True, None, True),
(True, True, "false", True),
),
)
def test_is_audit_logging_enabled_precedence(
monkeypatch: pytest.MonkeyPatch,
premium_user: bool,
configured_value: bool | None,
environment_value: str | None,
expected: bool,
):
monkeypatch.setattr(litellm, "store_audit_logs", configured_value)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user)
if environment_value is None:
monkeypatch.delenv("LITELLM_STORE_AUDIT_LOGS", raising=False)
else:
monkeypatch.setenv("LITELLM_STORE_AUDIT_LOGS", environment_value)
assert is_audit_logging_enabled() is expected
class TestBuildAuditLogPayload:
def test_builds_correct_payload(self):
audit_log = _make_audit_log()
@ -185,12 +214,14 @@ class TestCreateAuditLogForUpdateWithCallbacks:
with (
patch("litellm.proxy.proxy_server.premium_user", False),
patch("litellm.store_audit_logs", True),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
):
audit_log = _make_audit_log()
await create_audit_log_for_update(audit_log)
await asyncio.sleep(0.1)
mock_logger.async_log_audit_log_event.assert_not_called()
mock_prisma.db.litellm_auditlog.create.assert_not_called()
@pytest.mark.asyncio
async def test_no_dispatch_when_store_audit_logs_false(self):