Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c1b3640
feat: add audit log export to external callbacks (S3, Datadog, etc.)
yuneng-jiang Mar 9, 2026
364f5c6
fix: ensure audit log callbacks fire even when DB is unavailable
yuneng-jiang Mar 11, 2026
1d7cff2
feat(ui): add click-to-copy icon on User ID in internal users table
ryan-crabbe Mar 19, 2026
1da02b6
Merge branch 'main' into litellm_audit_log_s3_export
ryan-crabbe Mar 20, 2026
9c47d50
add startup messages
ryan-crabbe Mar 20, 2026
ae21527
fix black formatting
ryan-crabbe Mar 21, 2026
4e32db2
Merge pull request #23167 from BerriAI/litellm_audit_log_s3_export
ryan-crabbe Mar 21, 2026
8766004
fix(ui): use useTags hook so tag dropdown populates on key creation
ryan-crabbe Mar 21, 2026
e1d2ab3
test(ui): add unit test for tags dropdown populated via useTags hook
ryan-crabbe Mar 21, 2026
c68a8aa
Merge pull request #24273 from BerriAI/litellm_fix-create-key-tags-dr…
ryan-crabbe Mar 21, 2026
031b3d8
fix: add key-name-based secret redaction to catch secrets in config d…
ryan-crabbe Mar 21, 2026
41b1c44
Merge pull request #24305 from BerriAI/litellm_fix_secret_redaction_g…
ryan-crabbe Mar 21, 2026
b21948f
Merge pull request #24315 from BerriAI/litellm_copy_user_id_on_click
ryan-crabbe Mar 21, 2026
7738710
Remove required asterisks from v3 login form fields
ryan-crabbe Mar 21, 2026
d2a0f79
Merge pull request #24318 from BerriAI/litellm_login_remove_asterisks
ryan-crabbe Mar 21, 2026
9b90e80
fix: resolve mypy type errors in audit_logs.py
ryan-crabbe Mar 21, 2026
f494ab5
docs: add High Availability Control Plane documentation
ryan-crabbe Mar 21, 2026
3e27ff1
fix: resolve mypy type errors in audit_logs.py
ryan-crabbe Mar 21, 2026
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
13 changes: 12 additions & 1 deletion litellm/_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ def _build_secret_patterns() -> re.Pattern:
r"(?<=://)[^\s'\"]*:[^\s'\"@]+(?=@)",
# Databricks personal access tokens
r"dapi[0-9a-f]{32}",
# ── Key-name-based redaction ──
# Catches secrets inside dicts/config dumps by matching on the KEY name
# regardless of what the value looks like.
# e.g. 'master_key': 'any-value-here', "database_url": "postgres://..."
r"(?:master_key|database_url|db_url|connection_string|"
r"private_key|signing_key|encryption_key|"
r"auth_token|access_token|refresh_token|"
r"slack_webhook_url|webhook_url|"
r"database_connection_string|"
r"huggingface_token|jwt_secret)"
r"""['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]+""",
Comment on lines +59 to +65

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 Missing word boundaries — key-name patterns can over-redact

The key-name patterns (e.g. access_token, refresh_token, webhook_url) are not anchored with word boundaries or negative lookbehinds. This means a key such as oauth_access_token, last_access_token, or my_webhook_url would produce a partial match, e.g.:

"last_access_token": "my-value"
# becomes → "last_REDACTED"

The regex matches access_token" (consuming the trailing " of the key name), then : ", then my-value — leaving a dangling "last_ prefix and a broken closing " in the log line.

Consider adding a word-boundary or negative lookbehind to anchor each key name:

r"(?<!['\"\w])(?:master_key|database_url|db_url|connection_string|"
r"private_key|signing_key|encryption_key|"
r"auth_token|access_token|refresh_token|"
r"slack_webhook_url|webhook_url|"
r"database_connection_string|"
r"huggingface_token|jwt_secret)"
r"""['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]+""",

Note: the existing api[_-]?key pattern already sidesteps this by requiring {8,} characters for the value. The new patterns have no such minimum length guard either.

]
return re.compile("|".join(patterns), re.IGNORECASE)

Expand Down Expand Up @@ -272,7 +283,7 @@ def async_json_exception_handler(loop, context):
verbose_router_logger = logging.getLogger("LiteLLM Router")
verbose_logger = logging.getLogger("LiteLLM")

# Add the handler to the logger
# Add the handler to the loggers
verbose_router_logger.addHandler(handler)
verbose_proxy_logger.addHandler(handler)
verbose_logger.addHandler(handler)
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

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"
)

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
Comment on lines +23 to +43

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 Stale callback cache on config reload

_audit_log_callback_cache is a module-level dict that is never cleared. In proxy_server.py, when audit_log_callbacks is re-configured (e.g., on a hot-reload), litellm.audit_log_callbacks is reset to [] and rebuilt — but _audit_log_callback_cache is not invalidated. This means any string-named callback like "s3_v2" will continue resolving to the old cached instance (with stale bucket name, credentials, etc.) after a config reload.

To fix, add a cache-clearing step whenever litellm.audit_log_callbacks is reconfigured. One approach is to expose a _clear_audit_log_callback_cache() helper and call it from proxy_server.py:

def _clear_audit_log_callback_cache() -> None:
    """Invalidate the resolved-callback cache (call on config reload)."""
    _audit_log_callback_cache.clear()

And in proxy_server.py, before rebuilding audit_log_callbacks:

elif key == "audit_log_callbacks":
    from litellm.proxy.management_helpers.audit_logs import _clear_audit_log_callback_cache
    _clear_audit_log_callback_cache()
    litellm.audit_log_callbacks = []
    ...



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 on lines +90 to +107

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 Callable callbacks silently dropped without warning

CALLBACK_TYPES is defined as Union[str, Callable, "CustomLogger"] (see litellm/__init__.py), meaning a user can legally add a plain callable to audit_log_callbacks. However, _dispatch_audit_log_to_callbacks only handles CustomLogger instances and strings — if a callback is a Callable, resolved stays None and the entry is silently skipped with no log message whatsoever.

This is a silent failure: a user who registers a callable expecting it to be invoked will see no error and no invocation. At minimum, an else branch with a warning is needed:

for callback in litellm.audit_log_callbacks:
    try:
        resolved: Optional[CustomLogger] = callback if isinstance(callback, CustomLogger) else None
        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
        elif not isinstance(callback, CustomLogger):
            verbose_proxy_logger.warning(
                "Unsupported audit_log_callback type %s (%r) — only CustomLogger instances "
                "and string names are supported.",
                type(callback).__name__,
                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:
        ...



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)

_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 message omits the LITELLM_STORE_AUDIT_LOGS env var alternative

create_audit_log_for_update checks litellm.store_audit_logs or get_secret_bool("LITELLM_STORE_AUDIT_LOGS"), so users can enable audit logs via environment variable without touching litellm_settings. The current warning message only mentions store_audit_logs: true, which could cause confusion for users who rely on the env-var approach.

Suggested change
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."
)
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 "
"or the LITELLM_STORE_AUDIT_LOGS environment variable is set to 'true'."
)

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"
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