Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_helpers/audit_logs.py: 31%
100 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Functions to create audit logs for LiteLLM Proxy
3"""
5import asyncio
6import json
7from datetime import datetime, timezone
8from typing import Final
10import litellm
11from litellm._logging import verbose_proxy_logger
12from litellm._uuid import uuid
13from litellm.integrations.custom_logger import CustomLogger
14from litellm.proxy._types import (
15 AUDIT_ACTIONS,
16 LiteLLM_AuditLogs,
17 LitellmTableNames,
18 UserAPIKeyAuth,
19)
20from litellm.repositories.table_repositories import AuditLogRepository
21from litellm.types.utils import StandardAuditLogPayload
23_audit_log_callback_cache: Final[dict[str, CustomLogger]] = {}
24ALLOW_LITELLM_CHANGED_BY_HEADER_METADATA_KEY: Final = "allow_litellm_changed_by_header"
27def is_audit_logging_enabled(store_audit_logs: bool | None = None) -> bool:
28 from litellm.secret_managers.main import get_secret_bool
30 configured_value: Final[bool | None] = litellm.store_audit_logs if store_audit_logs is None else store_audit_logs
31 if configured_value is not None: 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true
32 return configured_value
34 environment_value: Final[bool | None] = get_secret_bool("LITELLM_STORE_AUDIT_LOGS")
35 if environment_value is not None: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true
36 return environment_value
38 from litellm.proxy.proxy_server import premium_user
40 return premium_user is True
43def _allows_litellm_changed_by_header(user_api_key_dict: UserAPIKeyAuth) -> bool:
44 for admin_metadata in (user_api_key_dict.metadata, user_api_key_dict.team_metadata):
45 if ( 45 ↛ 49line 45 didn't jump to line 49 because the condition on line 45 was never true
46 isinstance(admin_metadata, dict)
47 and admin_metadata.get(ALLOW_LITELLM_CHANGED_BY_HEADER_METADATA_KEY) is True
48 ):
49 return True
50 return False
53def get_audit_log_changed_by(
54 *,
55 litellm_changed_by: str | None,
56 user_api_key_dict: UserAPIKeyAuth,
57 litellm_proxy_admin_name: str | None,
58) -> str | None:
59 if litellm_changed_by and _allows_litellm_changed_by_header(user_api_key_dict): 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 return litellm_changed_by
61 return user_api_key_dict.user_id or litellm_proxy_admin_name
64def _resolve_audit_log_callback(name: str) -> CustomLogger | None:
65 """Resolve a string callback name to a CustomLogger instance, with caching.
67 For "s3_v2" with `litellm.s3_audit_callback_params` set, constructs a
68 dedicated `S3Logger` so audit logs can target a different bucket than the
69 normal-log singleton served by `_init_custom_logger_compatible_class`.
70 """
71 if name in _audit_log_callback_cache:
72 return _audit_log_callback_cache[name]
74 instance: CustomLogger | None
75 if name == "s3_v2" and getattr(litellm, "s3_audit_callback_params", None) is not None:
76 from litellm.integrations.s3_v2 import S3Logger as S3V2Logger
78 instance = S3V2Logger(s3_callback_params_override=litellm.s3_audit_callback_params)
79 else:
80 from litellm.litellm_core_utils.litellm_logging import (
81 _init_custom_logger_compatible_class,
82 )
84 instance = _init_custom_logger_compatible_class(
85 logging_integration=name,
86 internal_usage_cache=None,
87 llm_router=None,
88 )
90 if instance is not None:
91 _audit_log_callback_cache[name] = instance
92 return instance
95def reset_audit_log_callback_cache() -> None:
96 """Clear cached audit-log callback instances. Call on config reload."""
97 _audit_log_callback_cache.clear()
100def _build_audit_log_payload(
101 request_data: LiteLLM_AuditLogs,
102) -> StandardAuditLogPayload:
103 """Convert LiteLLM_AuditLogs to StandardAuditLogPayload for callback dispatch."""
104 updated_at = ""
105 if request_data.updated_at is not None:
106 updated_at = request_data.updated_at.isoformat()
108 table_name_str: Final[str] = (
109 request_data.table_name.value
110 if isinstance(request_data.table_name, LitellmTableNames)
111 else str(request_data.table_name)
112 )
114 return StandardAuditLogPayload(
115 id=request_data.id,
116 updated_at=updated_at,
117 changed_by=request_data.changed_by or "",
118 changed_by_api_key=request_data.changed_by_api_key or "",
119 action=request_data.action,
120 table_name=table_name_str,
121 object_id=request_data.object_id,
122 before_value=request_data.before_value,
123 updated_values=request_data.updated_values,
124 )
127def _audit_log_task_done_callback(task: asyncio.Task) -> None:
128 """Log exceptions from audit log callback tasks so they don't slip through silently."""
129 try:
130 exc: Final = task.exception()
131 except asyncio.CancelledError:
132 return
133 if exc is not None:
134 verbose_proxy_logger.error("Audit log callback task failed: %s", exc, exc_info=exc)
137async def _dispatch_audit_log_to_callbacks(
138 request_data: LiteLLM_AuditLogs,
139) -> None:
140 """Dispatch audit log to all registered audit_log_callbacks."""
141 if not litellm.audit_log_callbacks:
142 return
144 payload: Final = _build_audit_log_payload(request_data)
146 for callback in litellm.audit_log_callbacks:
147 try:
148 resolved: CustomLogger | None = callback if isinstance(callback, CustomLogger) else None
149 if isinstance(callback, str):
150 resolved = _resolve_audit_log_callback(callback)
151 if resolved is None:
152 verbose_proxy_logger.warning("Could not resolve audit log callback: %s", callback)
153 continue
155 if isinstance(resolved, CustomLogger):
156 task = asyncio.create_task(resolved.async_log_audit_log_event(payload))
157 task.add_done_callback(_audit_log_task_done_callback)
158 except Exception as e:
159 verbose_proxy_logger.error("Failed dispatching audit log to callback: %s", e)
162async def create_object_audit_log(
163 object_id: str,
164 action: AUDIT_ACTIONS,
165 litellm_changed_by: str | None,
166 user_api_key_dict: UserAPIKeyAuth,
167 litellm_proxy_admin_name: str | None,
168 table_name: LitellmTableNames,
169 before_value: str | None = None,
170 after_value: str | None = None,
171):
172 """
173 Create an audit log for an internal user.
175 Parameters:
176 - user_id: str - The id of the user to create the audit log for.
177 - action: AUDIT_ACTIONS - The action to create the audit log for.
178 - user_row: LiteLLM_UserTable - The user row to create the audit log for.
179 - litellm_changed_by: Optional[str] - The user id of the user who is changing the user.
180 - user_api_key_dict: UserAPIKeyAuth - The user api key dictionary.
181 - litellm_proxy_admin_name: Optional[str] - The name of the proxy admin.
182 """
183 if not is_audit_logging_enabled(): 183 ↛ 186line 183 didn't jump to line 186 because the condition on line 183 was always true
184 return
186 _changed_by: Final = get_audit_log_changed_by(
187 litellm_changed_by=litellm_changed_by,
188 user_api_key_dict=user_api_key_dict,
189 litellm_proxy_admin_name=litellm_proxy_admin_name,
190 )
192 await create_audit_log_for_update(
193 request_data=LiteLLM_AuditLogs(
194 id=str(uuid.uuid4()),
195 updated_at=datetime.now(timezone.utc),
196 changed_by=_changed_by,
197 changed_by_api_key=user_api_key_dict.api_key,
198 table_name=table_name,
199 object_id=object_id,
200 action=action,
201 updated_values=after_value,
202 before_value=before_value,
203 )
204 )
207async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
208 """
209 Create an audit log for an object.
210 """
211 if not is_audit_logging_enabled():
212 return
214 from litellm.proxy.proxy_server import premium_user, prisma_client
216 if premium_user is not True:
217 return
219 verbose_proxy_logger.debug("creating audit log for %s", request_data)
221 if isinstance(request_data.updated_values, dict):
222 request_data.updated_values = json.dumps(request_data.updated_values)
224 if isinstance(request_data.before_value, dict):
225 request_data.before_value = json.dumps(request_data.before_value)
227 # Dispatch to external audit log callbacks regardless of DB availability
228 await _dispatch_audit_log_to_callbacks(request_data)
230 if prisma_client is None:
231 verbose_proxy_logger.error("prisma_client is None, cannot write audit log to DB")
232 return
234 _request_data: Final = request_data.model_dump(exclude_none=True)
236 try:
237 await AuditLogRepository(prisma_client).table.create(
238 data={
239 **_request_data,
240 }
241 )
242 except Exception as e:
243 # [Non-Blocking Exception. Do not allow blocking LLM API call]
244 verbose_proxy_logger.error("Failed Creating audit log %s", e)