Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
success_callback: List[CALLBACK_TYPES] = []
failure_callback: List[CALLBACK_TYPES] = []
service_callback: List[CALLBACK_TYPES] = []
audit_log_callbacks: List[CALLBACK_TYPES] = []
# logging_callback_manager is lazy-loaded via __getattr__
_custom_logger_compatible_callbacks_literal = Literal[
"lago",
Expand Down
5 changes: 5 additions & 0 deletions litellm/integrations/custom_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
LLMResponseTypes,
ModelResponse,
ModelResponseStream,
StandardAuditLogPayload,
StandardCallbackDynamicParams,
StandardLoggingPayload,
)
Expand Down Expand Up @@ -177,6 +178,10 @@ async def async_log_success_event(self, kwargs, response_obj, start_time, end_ti
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
pass

async def async_log_audit_log_event(self, audit_log: "StandardAuditLogPayload"):
"""Called when an audit log is created. Override in subclasses to handle."""
pass

#### PROMPT MANAGEMENT HOOKS ####

async def async_get_chat_completion_prompt(
Expand Down
34 changes: 33 additions & 1 deletion litellm/integrations/s3_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
httpxSpecialProvider,
)
from litellm.types.integrations.s3_v2 import s3BatchLoggingElement
from litellm.types.utils import StandardLoggingPayload
from litellm.types.utils import StandardAuditLogPayload, StandardLoggingPayload

from .custom_batch_logger import CustomBatchLogger

Expand Down Expand Up @@ -248,6 +248,38 @@ async def async_log_failure_event(self, kwargs, response_obj, start_time, end_ti
)
pass

async def async_log_audit_log_event(
self, audit_log: StandardAuditLogPayload
) -> None:
"""Batch audit logs and upload to S3 under audit_logs/ prefix."""
try:
from datetime import timezone

Comment on lines +256 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant in-function import of timezone

datetime is already imported at the module level (from datetime import datetime). The from datetime import timezone inside the function body is redundant — timezone can be accessed directly since it lives in the same datetime module. Move this to the module-level import:

Suggested change
from datetime import timezone
from datetime import datetime, timezone

and remove the in-function import entirely.

now = datetime.now(timezone.utc)
audit_log_id = audit_log.get("id", "unknown")

s3_path = cast(Optional[str], self.s3_path) or ""
s3_path = s3_path.rstrip("/") + "/" if s3_path else ""

s3_object_key = (
f"{s3_path}audit_logs/"
f"{now.strftime('%Y-%m-%d')}/"
f"{now.strftime('%H-%M-%S')}_{audit_log_id}.json"
)
Comment on lines +264 to +268

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 S3 key uses dispatch time, not event time

datetime.now(timezone.utc) records when the callback fires, not when the audit event occurred. If callback dispatch is delayed (e.g., due to queue pressure or task scheduling), the directory path audit_logs/{date}/{HH-MM-SS}_... will reflect the wrong time. Audit events created near midnight could land in the next day's directory.

The actual event timestamp is already available in audit_log["updated_at"] as an ISO-8601 string. Parsing and using that field for the key makes the S3 layout consistent with the event timeline:

from datetime import datetime, timezone

event_time_str = audit_log.get("updated_at", "")
try:
    event_time = datetime.fromisoformat(event_time_str)
except (ValueError, TypeError):
    event_time = datetime.now(timezone.utc)

s3_object_key = (
    f"{s3_path}audit_logs/"
    f"{event_time.strftime('%Y-%m-%d')}/"
    f"{event_time.strftime('%H-%M-%S')}_{audit_log_id}.json"
)


element = s3BatchLoggingElement(
payload=dict(audit_log),
s3_object_key=s3_object_key,
s3_object_download_filename=f"audit-{audit_log_id}.json",
)

self.log_queue.append(element)

if len(self.log_queue) >= self.batch_size:
await self.flush_queue()
except Exception as e:
verbose_logger.exception("S3 audit log error: %s", e)

async def _async_log_event_base(self, kwargs, response_obj, start_time, end_time):
try:
verbose_logger.debug(
Expand Down
108 changes: 102 additions & 6 deletions litellm/proxy/management_helpers/audit_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,111 @@
Functions to create audit logs for LiteLLM Proxy
"""

import asyncio
import json
from litellm._uuid import uuid
from datetime import datetime, timezone
from typing import Dict

import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy._types import (
AUDIT_ACTIONS,
LiteLLM_AuditLogs,
LitellmTableNames,
Optional,
UserAPIKeyAuth,
)
from litellm.types.utils import StandardAuditLogPayload

_audit_log_callback_cache: Dict[str, CustomLogger] = {}


def _resolve_audit_log_callback(name: str) -> Optional[CustomLogger]:
"""Resolve a string callback name to a CustomLogger instance, with caching."""
if name in _audit_log_callback_cache:
return _audit_log_callback_cache[name]

from litellm.litellm_core_utils.litellm_logging import (
_init_custom_logger_compatible_class,
)

instance = _init_custom_logger_compatible_class(
logging_integration=name, # type: ignore
internal_usage_cache=None,
llm_router=None,
)

if instance is not None:
_audit_log_callback_cache[name] = instance
return instance


def _build_audit_log_payload(
request_data: LiteLLM_AuditLogs,
) -> StandardAuditLogPayload:
"""Convert LiteLLM_AuditLogs to StandardAuditLogPayload for callback dispatch."""
updated_at = ""
if request_data.updated_at is not None:
updated_at = request_data.updated_at.isoformat()

table_name = request_data.table_name
if isinstance(table_name, LitellmTableNames):
table_name = table_name.value

return StandardAuditLogPayload(
id=request_data.id,
updated_at=updated_at,
changed_by=request_data.changed_by or "",
changed_by_api_key=request_data.changed_by_api_key or "",
action=request_data.action,
table_name=str(table_name),
object_id=request_data.object_id,
before_value=request_data.before_value,
updated_values=request_data.updated_values,
)


def _audit_log_task_done_callback(task: asyncio.Task) -> None:
"""Log exceptions from audit log callback tasks so they don't slip through silently."""
try:
exc = task.exception()
except asyncio.CancelledError:
return
if exc is not None:
verbose_proxy_logger.error(
"Audit log callback task failed: %s", exc, exc_info=exc
)


async def _dispatch_audit_log_to_callbacks(
request_data: LiteLLM_AuditLogs,
) -> None:
"""Dispatch audit log to all registered audit_log_callbacks."""
if not litellm.audit_log_callbacks:
return

payload = _build_audit_log_payload(request_data)

for callback in litellm.audit_log_callbacks:
try:
resolved = callback
if isinstance(callback, str):
resolved = _resolve_audit_log_callback(callback)
if resolved is None:
verbose_proxy_logger.warning(
"Could not resolve audit log callback: %s", callback
)
continue

if isinstance(resolved, CustomLogger):
task = asyncio.create_task(resolved.async_log_audit_log_event(payload))
task.add_done_callback(_audit_log_task_done_callback)
except Exception as e:
verbose_proxy_logger.error(
"Failed dispatching audit log to callback: %s", e
)
Comment thread
ryan-crabbe marked this conversation as resolved.
Comment on lines +92 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-CustomLogger callback instances are silently dropped

If litellm.audit_log_callbacks contains an entry that is neither a str nor a CustomLogger (e.g., a custom callable or a misconfigured object from get_instance_fn), the loop falls through both isinstance branches without logging a warning. The user's callback is silently ignored with no diagnostics.

Consider adding an else branch to warn when a callback is not usable:

if isinstance(resolved, CustomLogger):
    task = asyncio.create_task(
        resolved.async_log_audit_log_event(payload)
    )
    task.add_done_callback(_audit_log_task_done_callback)
else:
    verbose_proxy_logger.warning(
        "Audit log callback %r is not a CustomLogger instance and will be skipped.",
        resolved,
    )



async def create_object_audit_log(
Expand Down Expand Up @@ -81,9 +173,6 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
if premium_user is not True:
return

if prisma_client is None:
raise Exception("prisma_client is None, no DB connected")

verbose_proxy_logger.debug("creating audit log for %s", request_data)

if isinstance(request_data.updated_values, dict):
Expand All @@ -92,6 +181,15 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
if isinstance(request_data.before_value, dict):
request_data.before_value = json.dumps(request_data.before_value)

# Dispatch to external audit log callbacks regardless of DB availability
await _dispatch_audit_log_to_callbacks(request_data)

if prisma_client is None:
verbose_proxy_logger.error(
"prisma_client is None, cannot write audit log to DB"
)
return

_request_data = request_data.model_dump(exclude_none=True)

try:
Expand All @@ -103,5 +201,3 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
except Exception as e:
# [Non-Blocking Exception. Do not allow blocking LLM API call]
verbose_proxy_logger.error(f"Failed Creating audit log {e}")

return
23 changes: 23 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2961,6 +2961,29 @@ async def load_config( # noqa: PLR0915
print( # noqa
f"{blue_color_code} Initialized Failure Callbacks - {litellm.failure_callback} {reset_color_code}"
) # noqa
elif key == "audit_log_callbacks":
litellm.audit_log_callbacks = []

for callback in value:
if "." in callback:
litellm.audit_log_callbacks.append(
get_instance_fn(value=callback)
)
else:
litellm.audit_log_callbacks.append(callback)
Comment on lines +2967 to +2973

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No type guard before "." in callback

Every item in value is assumed to be a str before performing the "." in callback membership test. If the YAML config includes a non-string entry (e.g., an integer), Python will raise TypeError: argument of type 'int' is not iterable, which would crash config loading with a cryptic error.

The existing success_callback / failure_callback branches that also call get_instance_fn have the same assumption, but all other callback lists validate that the entry is a string before doing this check. Adding a guard keeps the error explicit:

Suggested change
for callback in value:
if "." in callback:
litellm.audit_log_callbacks.append(
get_instance_fn(value=callback)
)
else:
litellm.audit_log_callbacks.append(callback)
for callback in value:
if not isinstance(callback, str):
verbose_proxy_logger.warning(
"audit_log_callbacks entry %r is not a string and will be skipped.",
callback,
)
continue
if "." in callback:
litellm.audit_log_callbacks.append(
get_instance_fn(value=callback)
)
else:
litellm.audit_log_callbacks.append(callback)


_store_audit_logs = litellm_settings.get(
"store_audit_logs", litellm.store_audit_logs
)
if _store_audit_logs:
print( # noqa
f"{blue_color_code} Initialized Audit Log Callbacks - {litellm.audit_log_callbacks} {reset_color_code}"
) # noqa
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."
)
Comment on lines +2982 to +2986

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Warning does not account for LITELLM_STORE_AUDIT_LOGS env var

The warning check reads litellm_settings.get("store_audit_logs", litellm.store_audit_logs) — but the runtime check in create_audit_log_for_update also consults get_secret_bool("LITELLM_STORE_AUDIT_LOGS"). A user who sets LITELLM_STORE_AUDIT_LOGS=true via environment variable (without writing store_audit_logs: true into the config YAML) will see the "callbacks will not fire" warning, even though callbacks will actually fire at runtime. This can cause unnecessary alarm.

Additionally, the warning doesn't surface the other gate: audit log callbacks are also blocked for non-premium users (premium_user check in create_audit_log_for_update). If a non-premium user configures audit_log_callbacks, they receive a positive "Initialized Audit Log Callbacks" log (assuming store_audit_logs is set), but callbacks will silently never fire.

Consider updating the warning to cover both conditions:

_store_audit_logs = litellm_settings.get(
    "store_audit_logs", litellm.store_audit_logs
) or get_secret_bool("LITELLM_STORE_AUDIT_LOGS")
if not _store_audit_logs:
    verbose_proxy_logger.warning(
        "'audit_log_callbacks' is configured but 'store_audit_logs' is not enabled "
        "(set 'store_audit_logs: true' in litellm_settings or LITELLM_STORE_AUDIT_LOGS=true). "
        "Additionally, a premium license is required for audit log callbacks to fire."
    )

elif key == "cache_params":
# this is set in the cache branch
# see usage here: https://docs.litellm.ai/docs/proxy/caching
Expand Down
14 changes: 14 additions & 0 deletions litellm/types/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2804,6 +2804,20 @@ class StandardLoggingPayloadStatusFields(TypedDict, total=False):
"""


class StandardAuditLogPayload(TypedDict):
"""Payload for audit log events dispatched to external callbacks."""

id: str
updated_at: str # ISO-8601
changed_by: str
changed_by_api_key: str
action: str # "created" | "updated" | "deleted" | "blocked" | "rotated"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 action comment is missing "unblocked"

AUDIT_ACTIONS is defined as Literal["created", "updated", "deleted", "blocked", "unblocked", "rotated"], but the comment here only lists "created" | "updated" | "deleted" | "blocked" | "rotated""unblocked" is missing.

Suggested change
action: str # "created" | "updated" | "deleted" | "blocked" | "rotated"
action: str # "created" | "updated" | "deleted" | "blocked" | "unblocked" | "rotated"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 action typed as str instead of AUDIT_ACTIONS Literal

action is typed as str, but the existing AUDIT_ACTIONS Literal ("created" | "updated" | "deleted" | "blocked" | "unblocked" | "rotated") provides a precise contract. Using str means:

  • Callback implementors get no IDE/type-checker guidance on valid values
  • Invalid action strings can be dispatched to callbacks without any type error

AUDIT_ACTIONS can't be imported directly from litellm.proxy._types here without a potential circular import, but it can be redefined inline or imported lazily. A clean option is to use TYPE_CHECKING:

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from litellm.proxy._types import AUDIT_ACTIONS

and then type the field accordingly, or simply copy the Literal definition into types/utils.py:

Suggested change
action: str # "created" | "updated" | "deleted" | "blocked" | "rotated"
action: Literal["created", "updated", "deleted", "blocked", "unblocked", "rotated"] # AUDIT_ACTIONS

table_name: str
object_id: str
before_value: Optional[str]
updated_values: Optional[str]


class StandardLoggingPayload(TypedDict):
id: str
trace_id: str # Trace multiple LLM calls belonging to same overall request (e.g. fallbacks/retries)
Expand Down
Loading
Loading