feat: add opt-in diagnostic message redaction

This commit is contained in:
moe-berri 2026-10-28 12:07:51 -07:00
parent 8c9a76859d
commit 8d9bd9045a
49 changed files with 942 additions and 99 deletions

View file

@ -141,6 +141,24 @@ response = client.chat.completions.create(
)
```
#### Debug logs without message content
To troubleshoot with detailed logging, add these settings to your existing proxy configuration and restart with `litellm --config config.yaml --detailed_debug`
```yaml
litellm_settings:
redact_messages_in_debug_logs: true
turn_off_message_logging: true
```
`redact_messages_in_debug_logs` defaults to `false`. When enabled, LiteLLM replaces free-form diagnostic messages, payloads, and exception text with `redacted-by-litellm`, retaining selected operational events. This also applies to payload diagnostics emitted at warning or error level. Raw tracebacks and caller-supplied correlation identifiers are omitted because they can contain message content
The setting affects diagnostics only. Requests sent to providers and responses returned to clients stay intact. `turn_off_message_logging` separately controls message content sent to logging integrations. Request-level callback overrides do not disable diagnostic redaction. Custom application loggers and independently configured third-party wire logging need their own privacy controls
The built-in plain and JSON formatters support this mode. Custom formatters must use standard log fields or supply defaults for extra fields, which are removed to prevent content disclosure. This Python SDK/proxy setting does not configure the standalone Rust gateway
Verify with a synthetic prompt: the client should receive its normal response while LiteLLM diagnostics show `redacted-by-litellm` instead of the prompt or reply. Remove the new setting or set it to `false`, then restart the proxy to restore existing diagnostics
[**Docs: LLM Providers**](https://docs.litellm.ai/docs/providers)
</details>

View file

@ -188,6 +188,7 @@ _async_failure_callback: List[Union[str, Callable[..., object], "CustomLogger"]]
pre_call_rules: List[Callable] = []
post_call_rules: List[Callable[..., object]] = []
turn_off_message_logging: Optional[bool] = False
redact_messages_in_debug_logs: bool = False
standard_logging_payload_excluded_fields: Optional[List[str]] = (
None # Fields to exclude from StandardLoggingPayload before callbacks receive it
)

View file

@ -7,10 +7,11 @@ import logging
import os
import re
import sys
import traceback
from collections.abc import Iterator, Mapping
from datetime import datetime
from logging import Formatter
from typing import Final, TextIO
from typing import Final, Literal, TextIO
from urllib.parse import unquote
from pydantic import TypeAdapter
@ -21,6 +22,7 @@ from litellm.constants import (
LITELLM_TRUNCATION_STDOUT_SAFEGUARD_NOTE,
MAX_BASE64_LENGTH_STDOUT_LOG,
MAX_STRING_LENGTH_STDOUT_LOG,
REDACTED_BY_LITELLM,
)
from litellm.litellm_core_utils.env_utils import get_env_int
from litellm.litellm_core_utils.safe_json_dumps import UNSERIALIZABLE_OBJECT, safe_dumps, safe_json_structure
@ -87,6 +89,88 @@ _redact_string = redact_string
_REDACTED_RECORD_ATTR: Final = "litellm_redacted"
_REDACTED_STAMP: Final = object()
_SAFE_DIAGNOSTIC_ATTR: Final = "litellm_safe_diagnostic"
_SAFE_DIAGNOSTIC_STAMP: Final = object()
def diagnostic_message_logging_disabled() -> bool:
return getattr(litellm, "redact_messages_in_debug_logs", False) is True
def _litellm_exception_record(record: logging.LogRecord) -> bool:
if not isinstance(record.exc_info, tuple) or record.exc_info[2] is None:
return False
return any(
isinstance(module := frame.f_globals.get("__name__"), str)
and (module == "litellm" or module.startswith("litellm."))
for frame, _ in traceback.walk_tb(record.exc_info[2])
)
def _redact_diagnostic_record(record: logging.LogRecord) -> None:
if (
not diagnostic_message_logging_disabled()
or getattr(record, _SAFE_DIAGNOSTIC_ATTR, None) is _SAFE_DIAGNOSTIC_STAMP
):
return
owned: Final = record.name in (
"LiteLLM",
"LiteLLM Router",
"LiteLLM Proxy",
"uvicorn.error",
) or record.name.startswith(("LiteLLM.", "LiteLLM Router.", "LiteLLM Proxy.", "litellm."))
if not owned and not (record.name == "asyncio" and _litellm_exception_record(record)):
return
record.msg = REDACTED_BY_LITELLM # rebind-ok: logging filters sanitize the diagnostic copy
record.args = () # rebind-ok: never render payload arguments
record.exc_info = None # rebind-ok: exception messages may echo request content
record.exc_text = None # rebind-ok: remove previously rendered exception content
record.stack_info = None # rebind-ok: stack text may contain payloads
for key in tuple(record.__dict__):
if key not in _STANDARD_RECORD_ATTRS:
delattr(record, key)
setattr(record, _REDACTED_RECORD_ATTR, _REDACTED_STAMP)
class MessageContentFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
_redact_diagnostic_record(record)
return True
def log_safe_diagnostic(
logger: logging.Logger,
event: Literal[
"request_sent",
"response_received",
"response_ready",
"request_completed",
"request_failed",
"stream_completed",
"retry",
"fallback",
],
*,
status_code: int | None = None,
elapsed_seconds: float | None = None,
attempt: int | None = None,
level: int = logging.DEBUG,
) -> None:
if not diagnostic_message_logging_disabled() or not logger.isEnabledFor(level):
return
logger.log(
level,
"event=%s status=%s elapsed_seconds=%s attempt=%s content=%s",
event,
status_code,
elapsed_seconds,
attempt,
REDACTED_BY_LITELLM,
extra={_SAFE_DIAGNOSTIC_ATTR: _SAFE_DIAGNOSTIC_STAMP},
stacklevel=2,
)
_DECODED_LOG_PAYLOAD: Final = TypeAdapter(object)
_UNREDACTED_SCALAR_TYPES: Final = (bool, int, float, type(None))
@ -162,6 +246,7 @@ class SecretRedactionFilter(logging.Filter):
_formatter = logging.Formatter()
def filter(self, record: logging.LogRecord) -> bool:
_redact_diagnostic_record(record)
if not _ENABLE_SECRET_REDACTION or _is_redacted(record):
return True
return _process_record(record, base64_limit=0, text_limit=0, redact=True)
@ -222,6 +307,23 @@ class AccessLogRedactionFilter(logging.Filter):
"""
def filter(self, record: logging.LogRecord) -> bool:
if diagnostic_message_logging_disabled():
safe_args: Final = (
tuple(value if isinstance(value, (int, float, bool)) else REDACTED_BY_LITELLM for value in record.args)
if isinstance(record.args, tuple) and len(record.args) == 5
else ()
)
record.msg = ( # rebind-ok: access diagnostic copy
'%s - "%s %s HTTP/%s" %s' if safe_args else REDACTED_BY_LITELLM
)
record.args = safe_args # rebind-ok: preserve the access formatter's tuple contract
record.exc_info = None # rebind-ok: exceptions can echo request content
record.exc_text = None # rebind-ok: discard cached exception content
record.stack_info = None # rebind-ok: discard stack content
for key in tuple(record.__dict__):
if key not in _STANDARD_RECORD_ATTRS:
delattr(record, key)
return True
if not _ENABLE_SECRET_REDACTION:
return True
if isinstance(record.args, tuple) and record.args:
@ -431,6 +533,7 @@ def _render_exception(record: logging.LogRecord) -> str | None:
def _process_record(record: logging.LogRecord, *, base64_limit: int, text_limit: int, redact: bool) -> bool:
_redact_diagnostic_record(record)
if _is_redacted(record):
return True
message: Final = _render_message(record)
@ -448,7 +551,7 @@ def _process_record(record: logging.LogRecord, *, base64_limit: int, text_limit:
)
for key, value in _record_attributes(record).items()
if key not in _STANDARD_RECORD_ATTRS
and key != _REDACTED_RECORD_ATTR
and key not in (_REDACTED_RECORD_ATTR, _SAFE_DIAGNOSTIC_ATTR)
and not isinstance(value, _UNREDACTED_SCALAR_TYPES)
)
if redact
@ -532,6 +635,7 @@ class StdoutLogTruncationFilter(logging.Filter):
_formatter = logging.Formatter()
def filter(self, record: logging.LogRecord) -> bool:
_redact_diagnostic_record(record)
try:
message: Final = record.getMessage()
except (TypeError, ValueError):
@ -580,7 +684,7 @@ class CorrelationContextFilter(logging.Filter):
"""
def filter(self, record: logging.LogRecord) -> bool:
if not litellm.request_correlation_in_logs:
if diagnostic_message_logging_disabled() or not litellm.request_correlation_in_logs:
return True
trace_id: Final = trace_id_var.get()
if trace_id:
@ -719,13 +823,13 @@ def _try_parse_embedded_python_dict(message: str) -> dict[str, object] | None:
# Standard LogRecord attribute names - used to identify 'extra' fields.
# Derived at runtime so we automatically include version-specific attrs (e.g. taskName).
def _get_standard_record_attrs() -> frozenset:
def _get_standard_record_attrs() -> frozenset[str]:
"""Standard LogRecord attribute names - excludes extra keys from logger.debug(..., extra={...})."""
return frozenset(logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys())
_STANDARD_RECORD_ATTRS: Final = _get_standard_record_attrs()
_NON_EXTRA_RECORD_ATTRS: Final = _STANDARD_RECORD_ATTRS | {_REDACTED_RECORD_ATTR}
_NON_EXTRA_RECORD_ATTRS: Final = _STANDARD_RECORD_ATTRS | {_REDACTED_RECORD_ATTR, _SAFE_DIAGNOSTIC_ATTR}
# CorrelationContextFilter is the only legitimate source for these two JSON fields;
# see JsonFormatter.format() for why they're excluded from the generic message-content
@ -743,6 +847,7 @@ class JsonFormatter(Formatter):
return dt.isoformat()
def format(self, record):
_redact_diagnostic_record(record)
message_str: Final = record.getMessage()
json_record: Final[dict[str, object]] = {
"message": message_str,
@ -833,7 +938,7 @@ def _setup_json_exception_handlers(formatter):
level=logging.ERROR,
pathname="",
lineno=0,
msg=str(exc_value),
msg=REDACTED_BY_LITELLM if diagnostic_message_logging_disabled() else str(exc_value),
args=(),
exc_info=(exc_type, exc_value, exc_traceback),
)
@ -854,7 +959,7 @@ def _setup_json_exception_handlers(formatter):
level=logging.ERROR,
pathname="",
lineno=0,
msg=str(exception),
msg=REDACTED_BY_LITELLM if diagnostic_message_logging_disabled() else str(exception),
args=(),
exc_info=(exc_type, exception, exception.__traceback__),
)
@ -1153,7 +1258,13 @@ def print_verbose(print_statement: object, *args: object) -> None:
try:
if not set_verbose:
return
message: Final = str(print_statement) % args if args else str(print_statement)
message: Final = (
REDACTED_BY_LITELLM
if diagnostic_message_logging_disabled()
else str(print_statement) % args
if args
else str(print_statement)
)
print(redact_secrets(message)) # noqa: T201
except Exception:
pass

View file

@ -47,7 +47,7 @@ class BudgetManager:
if litellm.set_verbose:
import logging
logging.info(print_statement)
logging.info("redacted-by-litellm" if litellm.redact_messages_in_debug_logs else print_statement)
except Exception:
pass

View file

@ -62,7 +62,7 @@ def print_verbose(print_statement):
try:
verbose_logger.debug(print_statement)
if litellm.set_verbose:
print(print_statement) # noqa: T201
print("redacted-by-litellm" if litellm.redact_messages_in_debug_logs else print_statement) # noqa: T201
except Exception:
pass

View file

@ -198,7 +198,9 @@ class DualCache(BaseCache):
result = redis_result
print_verbose(f"get cache: cache result: {result}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"get cache: cache result: {result}"
)
return result
except Exception as e:
log_redis_failure(
@ -262,7 +264,11 @@ class DualCache(BaseCache):
if self.in_memory_cache is not None:
in_memory_result: Final = await self.in_memory_cache.async_get_cache(key, **kwargs)
print_verbose(f"in_memory_result: {in_memory_result}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"in_memory_result: {in_memory_result}"
)
if in_memory_result is not None:
result = in_memory_result
@ -279,7 +285,9 @@ class DualCache(BaseCache):
result = redis_result
print_verbose(f"get cache: cache result: {result}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"get cache: cache result: {result}"
)
return result
except Exception as e:
log_redis_failure(

View file

@ -208,7 +208,11 @@ class QdrantSemanticCache(BaseCache):
},
)
if response.status_code not in (200, 201):
print_verbose(f"Qdrant semantic-cache could not create cache-key payload index: {response.text}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Qdrant semantic-cache could not create cache-key payload index: {response.text}"
)
except Exception as exc:
print_verbose(f"Qdrant semantic-cache could not create cache-key payload index: {exc}")
@ -284,7 +288,11 @@ class QdrantSemanticCache(BaseCache):
return await asyncio.wait_for(embedding_call, self.embedding_timeout)
def set_cache(self, key, value, **kwargs):
print_verbose(f"qdrant semantic-cache set_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"qdrant semantic-cache set_cache, kwargs: {kwargs}"
)
from litellm._uuid import uuid
# get the prompt
@ -324,7 +332,11 @@ class QdrantSemanticCache(BaseCache):
)
def get_cache(self, key, **kwargs):
print_verbose(f"sync qdrant semantic-cache get_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"sync qdrant semantic-cache get_cache, kwargs: {kwargs}"
)
# get the messages
messages: Final = kwargs["messages"]
@ -399,7 +411,11 @@ class QdrantSemanticCache(BaseCache):
async def async_set_cache(self, key, value, **kwargs):
from litellm._uuid import uuid
print_verbose(f"async qdrant semantic-cache set_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"async qdrant semantic-cache set_cache, kwargs: {kwargs}"
)
# get the prompt
messages: Final = kwargs["messages"]
@ -434,7 +450,11 @@ class QdrantSemanticCache(BaseCache):
)
async def async_get_cache(self, key, **kwargs):
print_verbose(f"async qdrant semantic-cache get_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"async qdrant semantic-cache get_cache, kwargs: {kwargs}"
)
# get the messages
messages: Final = kwargs["messages"]

View file

@ -1266,7 +1266,9 @@ class RedisCache(BaseCache):
async with _redis_client.pipeline(transaction=False) as pipe:
results: Final = await self._pipeline_helper(pipe, cache_list, ttl)
print_verbose(f"pipeline results: {results}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"pipeline results: {results}"
)
# Optionally, you could process 'results' to make sure that all set operations were successful.
## LOGGING ##
end_time = time.time()
@ -1870,7 +1872,9 @@ class RedisCache(BaseCache):
start_time: Final = time.time()
try:
response: Final[bool] = self.redis_client.ping()
print_verbose(f"Redis Cache PING: {response}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"Redis Cache PING: {response}"
)
## LOGGING ##
end_time = time.time()
_duration = end_time - start_time

View file

@ -419,7 +419,11 @@ class RedisSemanticCache(BaseCache):
**kwargs: Additional arguments including 'messages' for the prompt
and optional 'ttl' for time-to-live
"""
print_verbose(f"Redis semantic-cache set_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Redis semantic-cache set_cache, kwargs: {kwargs}"
)
value_str: str | None = None
try:
@ -456,7 +460,11 @@ class RedisSemanticCache(BaseCache):
Returns:
The cached response if a semantically similar prompt is found, else None
"""
print_verbose(f"Redis semantic-cache get_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Redis semantic-cache get_cache, kwargs: {kwargs}"
)
try:
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
@ -565,7 +573,11 @@ class RedisSemanticCache(BaseCache):
**kwargs: Additional arguments including 'messages' for the prompt
and optional 'ttl' for time-to-live
"""
print_verbose(f"Async Redis semantic-cache set_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Async Redis semantic-cache set_cache, kwargs: {kwargs}"
)
try:
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
@ -606,7 +618,11 @@ class RedisSemanticCache(BaseCache):
Returns:
The cached response if a semantically similar prompt is found, else None
"""
print_verbose(f"Async Redis semantic-cache get_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Async Redis semantic-cache get_cache, kwargs: {kwargs}"
)
try:
prompt: Final = self._get_prompt_from_kwargs(**kwargs)

View file

@ -28,6 +28,7 @@ from redis.commands.search.field import TagField, VectorField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
from redis.commands.search.query import Query
import litellm
from litellm._logging import print_verbose
from litellm._uuid import uuid
from litellm.llms.valkey.common_utils import build_valkey_url, pack_vector
@ -259,7 +260,11 @@ class ValkeySemanticCache(RedisSemanticCache):
return self._get_cache_logic(cached_response=hit.response)
def set_cache(self, key: str, value: object, **kwargs: object) -> None:
print_verbose(f"Valkey semantic-cache set_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Valkey semantic-cache set_cache, kwargs: {kwargs}"
)
try:
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
if prompt is None:
@ -278,7 +283,11 @@ class ValkeySemanticCache(RedisSemanticCache):
print_verbose(f"Error in Valkey semantic-cache set_cache: {e}")
def get_cache(self, key: str, **kwargs: object) -> object:
print_verbose(f"Valkey semantic-cache get_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Valkey semantic-cache get_cache, kwargs: {kwargs}"
)
try:
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
if prompt is None:
@ -298,7 +307,11 @@ class ValkeySemanticCache(RedisSemanticCache):
self._record_similarity(kwargs, 0.0)
async def async_set_cache(self, key: str, value: object, **kwargs: object) -> None:
print_verbose(f"Async Valkey semantic-cache set_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Async Valkey semantic-cache set_cache, kwargs: {kwargs}"
)
try:
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
if prompt is None:
@ -317,7 +330,11 @@ class ValkeySemanticCache(RedisSemanticCache):
print_verbose(f"Error in async Valkey semantic-cache set_cache: {e}")
async def async_get_cache(self, key: str, **kwargs: object) -> object:
print_verbose(f"Async Valkey semantic-cache get_cache, kwargs: {kwargs}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Async Valkey semantic-cache get_cache, kwargs: {kwargs}"
)
try:
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
if prompt is None:

View file

@ -1221,7 +1221,10 @@ Model Info:
if response.status_code == 200:
return True
else:
print("Error sending webhook alert. Error=", response.text) # noqa: T201
print( # noqa: T201 # legacy stdout diagnostic
"Error sending webhook alert. Error=",
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else response.text,
)
return False

View file

@ -27,6 +27,8 @@ import httpx
import polars as pl
from rich.console import Console
import litellm
class CloudZeroStreamer:
"""Stream CBF data to CloudZero AnyCost API with proper batching and timezone handling."""
@ -44,7 +46,11 @@ class CloudZeroStreamer:
try:
self.user_timezone = zoneinfo.ZoneInfo(user_timezone)
except zoneinfo.ZoneInfoNotFoundError:
self.console.print(f"[yellow]Warning: Unknown timezone '{user_timezone}', using UTC[/yellow]")
self.console.print(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"[yellow]Warning: Unknown timezone '{user_timezone}', using UTC[/yellow]"
)
self.user_timezone = timezone.utc
else:
self.user_timezone = timezone.utc
@ -62,7 +68,11 @@ class CloudZeroStreamer:
self.console.print("[yellow]No valid daily batches to send[/yellow]")
return
self.console.print(f"[blue]Sending {len(daily_batches)} daily batch(es) with operation '{operation}'[/blue]")
self.console.print(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"[blue]Sending {len(daily_batches)} daily batch(es) with operation '{operation}'[/blue]"
)
for batch_date, batch_data in daily_batches.items():
self._send_daily_batch(batch_date, batch_data, operation)
@ -94,7 +104,11 @@ class CloudZeroStreamer:
daily_batches[batch_date].append(row)
except Exception as e:
self.console.print(f"[yellow]Warning: Could not process timestamp '{timestamp_str}': {e}[/yellow]")
self.console.print(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"[yellow]Warning: Could not process timestamp '{timestamp_str}': {e}[/yellow]"
)
continue
# Convert lists back to DataFrames
@ -171,21 +185,33 @@ class CloudZeroStreamer:
try:
with httpx.Client(timeout=30.0) as client:
self.console.print(f"[blue]Sending batch for {batch_date} ({len(batch_data)} records)[/blue]")
self.console.print(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"[blue]Sending batch for {batch_date} ({len(batch_data)} records)[/blue]"
)
response: Final = client.post(url, headers=headers, json=payload)
response.raise_for_status()
self.console.print(
f"[green]✓ Successfully sent batch for {batch_date} ({len(batch_data)} records)[/green]"
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"[green]✓ Successfully sent batch for {batch_date} ({len(batch_data)} records)[/green]"
)
except httpx.RequestError as e:
self.console.print(f"[red]✗ Network error sending batch for {batch_date}: {e}[/red]")
self.console.print(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"[red]✗ Network error sending batch for {batch_date}: {e}[/red]"
)
raise
except httpx.HTTPStatusError as e:
self.console.print(
f"[red]✗ HTTP error sending batch for {batch_date}: {e.response.status_code} {e.response.text}[/red]"
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"[red]✗ HTTP error sending batch for {batch_date}: {e.response.status_code} {e.response.text}[/red]"
)
raise
@ -237,7 +263,11 @@ class CloudZeroStreamer:
return api_record
except Exception as e:
self.console.print(f"[yellow]Warning: Could not convert record to API format: {e}[/yellow]")
self.console.print(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"[yellow]Warning: Could not convert record to API format: {e}[/yellow]"
)
return None
def _ensure_utc_timestamp(self, timestamp_str: str) -> str:

View file

@ -5,6 +5,7 @@ from typing import Final
import httpx
import litellm
from litellm._logging import verbose_logger
DEEPEVAL_BASE_URL: Final = "https://deepeval.confident-ai.com"
@ -21,8 +22,8 @@ from litellm.llms.custom_httpx.http_handler import (
def log_retry_error(details):
exception: Final = details.get("exception")
tries: Final = details.get("tries")
exception: Final = "redacted-by-litellm" if litellm.redact_messages_in_debug_logs else details.get("exception")
tries: Final = "redacted-by-litellm" if litellm.redact_messages_in_debug_logs else details.get("tries")
if exception:
logging.error("Confident AI Error: %s. Retrying: %s time(s)...", exception, tries)
else:

View file

@ -20,7 +20,10 @@ from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import SpanExportResult
from opentelemetry.semconv.attributes.http_attributes import HTTP_RESPONSE_STATUS_CODE
from litellm._logging import MessageContentFilter
_logger: Final = logging.getLogger(__name__)
_logger.addFilter(MessageContentFilter())
JSON_CONTENT_TYPE: Final = "application/json"
_HEX_ID_KEYS: Final = frozenset({"traceId", "spanId", "parentSpanId"})

View file

@ -9,7 +9,10 @@ try:
from wandb.sdk.data_types import trace_tree
from litellm._logging import MessageContentFilter
logger: Final = logging.getLogger(__name__)
logger.addFilter(MessageContentFilter())
K = TypeVar("K", bound=str)
V = TypeVar("V")

View file

@ -5,6 +5,7 @@ import copy
import datetime
import functools
import json
import logging
import math
import os
import re
@ -25,7 +26,9 @@ from pydantic import BaseModel, JsonValue, TypeAdapter
import litellm
from litellm._internal_context import post_response_phase
from litellm._logging import (
diagnostic_message_logging_disabled,
is_debugging_on,
log_safe_diagnostic,
redact_string,
session_id_var,
set_session_id,
@ -1708,6 +1711,13 @@ class Logging(LiteLLMLoggingBaseClass):
Prints the RAW curl command sent from LiteLLM
"""
if diagnostic_message_logging_disabled():
log_safe_diagnostic(
verbose_logger,
"request_sent",
level=logging.WARNING if self.litellm_request_debug else logging.DEBUG,
)
return
if is_debugging_on() or self.litellm_request_debug:
if litellm.json_logs:
masked_headers: Final = self._get_masked_headers(headers or {})
@ -1788,11 +1798,18 @@ class Logging(LiteLLMLoggingBaseClass):
attr = "debug"
callattr: Final = verbose_logger.warning if attr == "warning" else verbose_logger.debug
callattr(
"RAW RESPONSE:\n{}\n\n".format(
self._model_call_details_view.get("original_response", self._model_call_details_view)
if diagnostic_message_logging_disabled():
log_safe_diagnostic(
verbose_logger,
"response_received",
level=logging.WARNING if self.litellm_request_debug else logging.DEBUG,
)
else:
callattr(
"RAW RESPONSE:\n{}\n\n".format(
self._model_call_details_view.get("original_response", self._model_call_details_view)
)
)
)
if getattr(self, "logger_fn", None) and callable(self.logger_fn):
try:
self.logger_fn(
@ -7648,7 +7665,12 @@ def get_standard_logging_object_payload(
def emit_standard_logging_payload(payload: StandardLoggingPayload):
if os.getenv("LITELLM_PRINT_STANDARD_LOGGING_PAYLOAD"):
try:
print(json.dumps(payload, indent=4, default=str), flush=True) # noqa: T201
print( # noqa: T201 # explicitly enabled diagnostic stdout
REDACTED_BY_LITELLM
if diagnostic_message_logging_disabled()
else json.dumps(payload, indent=4, default=str),
flush=True,
)
except Exception as e: # noqa: BLE001 # Safe catch-all for verbose logging
verbose_logger.exception("Error serializing standard logging payload for debug output: %s", e)

View file

@ -151,10 +151,16 @@ def is_async_iterable(obj: object) -> bool:
return isinstance(obj, collections.abc.AsyncIterable)
def print_verbose(print_statement: object):
def print_verbose(print_statement: object, *args: object):
try:
if litellm.set_verbose:
print(print_statement) # noqa: T201
print( # noqa: T201
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else str(print_statement) % args
if args
else print_statement
)
except Exception:
pass
@ -654,10 +660,10 @@ class CustomStreamWrapper:
text = ""
is_finished = False
finish_reason = ""
print_verbose(f"chunk: {chunk}")
print_verbose("chunk: %s", cast(object, chunk)) # cast-ok: logging accepts any object
if chunk.startswith("data:"):
data_json: Final[_PredibaseStreamData] = json.loads(chunk[5:])
print_verbose(f"data json: {data_json}")
print_verbose("data json: %s", data_json)
if "token" in data_json and "text" in data_json["token"]:
text = data_json["token"]["text"]
if data_json.get("details", False) and data_json["details"].get("finish_reason", False):
@ -847,7 +853,8 @@ class CustomStreamWrapper:
"completion_tokens": 0,
}
else:
print_verbose(f"chunk: {chunk} (Type: {type(chunk)})")
if not litellm.redact_messages_in_debug_logs:
print_verbose(f"chunk: {chunk} (Type: {type(chunk)})")
raise ValueError(f"Unable to parse response. Original response: {chunk}")
text: Final = parsed_response.get("text_output", "")
finish_reason: Final = parsed_response.get("stop_reason")
@ -1483,13 +1490,21 @@ class CustomStreamWrapper:
elif self.custom_llm_provider == "triton":
response_obj = self.handle_triton_stream(chunk)
completion_obj["content"] = response_obj["text"]
print_verbose(f"completion obj content: {completion_obj['content']}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"completion obj content: {completion_obj['content']}"
)
if response_obj["is_finished"]:
self.received_finish_reason = response_obj["finish_reason"]
elif self.custom_llm_provider == "text-completion-openai":
response_obj = self.handle_openai_text_completion_chunk(chunk)
completion_obj["content"] = response_obj["text"]
print_verbose(f"completion obj content: {completion_obj['content']}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"completion obj content: {completion_obj['content']}"
)
if response_obj["is_finished"]:
self.received_finish_reason = response_obj["finish_reason"]
if response_obj["usage"] is not None:
@ -1511,7 +1526,11 @@ class CustomStreamWrapper:
litellm.CodestralTextCompletionConfig().chunk_parser(chunk),
)
completion_obj["content"] = response_obj["text"]
print_verbose(f"completion obj content: {completion_obj['content']}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"completion obj content: {completion_obj['content']}"
)
if response_obj["is_finished"]:
self.received_finish_reason = response_obj["finish_reason"]
if "usage" in response_obj is not None:
@ -1528,7 +1547,11 @@ class CustomStreamWrapper:
elif self.custom_llm_provider == "azure_text":
response_obj = self.handle_azure_text_completion_chunk(chunk)
completion_obj["content"] = response_obj["text"]
print_verbose(f"completion obj content: {completion_obj['content']}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"completion obj content: {completion_obj['content']}"
)
if response_obj["is_finished"]:
self.received_finish_reason = response_obj["finish_reason"]
elif self.custom_llm_provider == "cached_response":
@ -1546,7 +1569,11 @@ class CustomStreamWrapper:
completion_obj["content"] = response_obj["text"]
if response_obj["tool_calls"] is not None:
completion_obj["tool_calls"] = response_obj["tool_calls"]
print_verbose(f"completion obj content: {completion_obj['content']}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"completion obj content: {completion_obj['content']}"
)
if hasattr(cached_chunk, "id"):
model_response.id = cached_chunk.id
self.response_id = cached_chunk.id
@ -2038,11 +2065,13 @@ class CustomStreamWrapper:
else:
chunk = next(self.completion_stream)
if chunk is not None and chunk != b"":
print_verbose(
f"PROCESSED CHUNK PRE CHUNK CREATOR: {chunk.decode('utf-8', errors='replace') if isinstance(chunk, bytes) else chunk}; custom_llm_provider: {self.custom_llm_provider}"
)
if not litellm.redact_messages_in_debug_logs:
print_verbose(
f"PROCESSED CHUNK PRE CHUNK CREATOR: {chunk.decode('utf-8', errors='replace') if isinstance(chunk, bytes) else chunk}; custom_llm_provider: {self.custom_llm_provider}"
)
response: ModelResponseStream | None = self.chunk_creator(chunk=chunk)
print_verbose(f"PROCESSED CHUNK POST CHUNK CREATOR: {response}")
if not litellm.redact_messages_in_debug_logs:
print_verbose(f"PROCESSED CHUNK POST CHUNK CREATOR: {response}")
if response is None:
continue

View file

@ -181,7 +181,11 @@ class CodestralTextCompletion:
original_response=response.text,
additional_args={"complete_input_dict": data},
)
print_verbose(f"codestral api: raw model_response: {response.text}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"codestral api: raw model_response: {response.text}"
)
## RESPONSE OBJECT
if response.status_code != 200:
raise TextCompletionCodestralError(

View file

@ -247,7 +247,9 @@ def completion(
original_response=response.text,
additional_args={"complete_input_dict": data},
)
print_verbose(f"raw model_response: {response.text}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"raw model_response: {response.text}"
)
## RESPONSE OBJECT
completion_response: Final = response.json()
if "error" in completion_response:

View file

@ -14,12 +14,14 @@ if TYPE_CHECKING:
else:
LoggingClass = Any
from litellm._logging import MessageContentFilter
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
from ..common_utils import HuggingFaceError, fetch_inference_provider_mapping
logger: Final = logging.getLogger(__name__)
logger.addFilter(MessageContentFilter())
BASE_URL: Final = "https://router.huggingface.co"

View file

@ -52,7 +52,9 @@ def handle_prediction_response_streaming(
headers=response.headers,
)
new_output = output_string[len(previous_output) :]
print_verbose(f"New chunk: {new_output}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"New chunk: {new_output}"
)
yield {"output": new_output, "status": status}
previous_output = output_string
status = response_data["status"]
@ -66,7 +68,9 @@ def handle_prediction_response_streaming(
else:
# this can fail temporarily but it does not mean the replicate request failed, replicate request fails when status=="failed"
print_verbose(
f"Replicate: Failed to fetch prediction status and output.{response.status_code}{response.text}"
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Replicate: Failed to fetch prediction status and output.{response.status_code}{response.text}"
)
@ -107,7 +111,9 @@ async def async_handle_prediction_response_streaming(
headers=response.headers,
)
new_output = output_string[len(previous_output) :]
print_verbose(f"New chunk: {new_output}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"New chunk: {new_output}"
)
yield {"output": new_output, "status": status}
previous_output = output_string
status = response_data.get("status", "")
@ -121,7 +127,9 @@ async def async_handle_prediction_response_streaming(
else:
# this can fail temporarily but it does not mean the replicate request failed, replicate request fails when status=="failed"
print_verbose(
f"Replicate: Failed to fetch prediction status and output.{response.status_code}{response.text}"
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Replicate: Failed to fetch prediction status and output.{response.status_code}{response.text}"
)

View file

@ -635,7 +635,9 @@ class SagemakerLLM(BaseAWSLLM):
additional_args={"complete_input_dict": data},
)
print_verbose(f"raw model_response: {response}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"raw model_response: {response}"
)
# Transform response based on model type
from httpx import Response as HttpxResponse

View file

@ -4,6 +4,7 @@ from typing import Final
import httpx
import litellm
from litellm.litellm_core_utils.prompt_templates.factory import (
prompt_factory,
render_custom_prompt_template,
@ -85,7 +86,9 @@ def completion(
original_response=outputs,
additional_args={"complete_input_dict": sampling_params},
)
print_verbose(f"raw model_response: {outputs}")
print_verbose(
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else f"raw model_response: {outputs}"
)
## RESPONSE OBJECT
model_response.choices[0].message.content = outputs[0].outputs[0].text

View file

@ -819,7 +819,11 @@ def _text_completion_response_from_cached_dict(cached_response_dict: Mapping[str
async def _async_streaming(response, model, custom_llm_provider, args):
try:
print_verbose(f"received response in _async_streaming: {response}")
print_verbose(
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"received response in _async_streaming: {response}"
)
if asyncio.iscoroutine(response):
response = await response
async for line in response:
@ -9219,7 +9223,7 @@ def print_verbose(print_statement: object):
try:
verbose_logger.debug(print_statement)
if litellm.set_verbose:
print(print_statement) # noqa: T201
print("redacted-by-litellm" if litellm.redact_messages_in_debug_logs else print_statement) # noqa: T201
except Exception:
pass

View file

@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable,
from datetime import datetime
from functools import lru_cache
from itertools import chain
from time import perf_counter
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@ -33,7 +34,7 @@ from starlette.types import Receive, Scope, Send
from typing_extensions import Never
import litellm
from litellm._logging import redact_internal_details_from_client_message, verbose_proxy_logger
from litellm._logging import log_safe_diagnostic, redact_internal_details_from_client_message, verbose_proxy_logger
from litellm._uuid import uuid
from litellm.anthropic_interface.exceptions import AnthropicErrorSseFrame, anthropic_error_sse_frame
from litellm.constants import (
@ -2775,7 +2776,7 @@ class ProxyBaseLLMRequestProcessing:
def _debug_log_request_payload(self) -> None:
"""Log request payload at DEBUG level, truncating if too large."""
if not verbose_proxy_logger.isEnabledFor(logging.DEBUG):
if litellm.redact_messages_in_debug_logs or not verbose_proxy_logger.isEnabledFor(logging.DEBUG):
return
_payload_str: Final = json.dumps(self.data, default=str)
if len(_payload_str) > MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG:
@ -2831,7 +2832,12 @@ class ProxyBaseLLMRequestProcessing:
dispatched_call_type=self.route_type,
)
return await open_sse_before_first_byte(
diagnostic_started: Final = (
perf_counter()
if litellm.redact_messages_in_debug_logs and verbose_proxy_logger.isEnabledFor(logging.DEBUG)
else None
)
response: Final = await open_sse_before_first_byte(
self._process_llm_request(
request=request,
fastapi_response=fastapi_response,
@ -2856,6 +2862,18 @@ class ProxyBaseLLMRequestProcessing:
ping_interval_seconds=ttft_keepalive_interval(self.data, llm_router),
on_late_failure=_audit_late_failure,
)
if diagnostic_started is not None:
log_safe_diagnostic(
verbose_proxy_logger,
"response_ready",
status_code=(
response.status_code
if isinstance(response, Response)
else fastapi_response.status_code or status.HTTP_200_OK
),
elapsed_seconds=perf_counter() - diagnostic_started,
)
return response
async def _process_llm_request(
self,
@ -4167,6 +4185,13 @@ class ProxyBaseLLMRequestProcessing:
version: str | None = None,
) -> Never:
"""Raises ProxyException (OpenAI API compatible) if an exception is raised"""
if litellm.redact_messages_in_debug_logs:
log_safe_diagnostic(
verbose_proxy_logger,
"request_failed",
status_code=error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR),
level=logging.ERROR,
)
log_llm_api_exception(e, self.litellm_call_id)
# Allow callbacks to transform the error response
transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook(
@ -4444,6 +4469,7 @@ class ProxyBaseLLMRequestProcessing:
cost_injection_enabled: Final = bool(getattr(litellm, "include_cost_in_streaming_usage", False))
fast_path = not caps.has_streaming_chunk_override and not caps.has_guardrail and not cost_injection_enabled
debug_enabled: Final = verbose_proxy_logger.isEnabledFor(logging.DEBUG)
diagnostic_started: Final = perf_counter() if litellm.redact_messages_in_debug_logs and debug_enabled else None
stream_completed = False
client_disconnected = False
delivered_chunk = False
@ -4458,7 +4484,7 @@ class ProxyBaseLLMRequestProcessing:
async for chunk in guarded_stream:
# ``.format(chunk)`` was previously evaluated for every chunk
# regardless of log level; gate it behind the level check.
if debug_enabled:
if debug_enabled and not litellm.redact_messages_in_debug_logs:
verbose_proxy_logger.debug("async_data_generator: received streaming chunk - %s", chunk)
if not fast_path:
@ -4501,6 +4527,11 @@ class ProxyBaseLLMRequestProcessing:
if held_tail:
yield serialize_chunk(held_tail)
stream_completed = True
log_safe_diagnostic(
verbose_proxy_logger,
"stream_completed",
elapsed_seconds=perf_counter() - diagnostic_started if diagnostic_started is not None else None,
)
except (asyncio.CancelledError, GeneratorExit):
# Client disconnected mid-stream. CancelledError / GeneratorExit
# are BaseException and bypass the success/failure logging
@ -4538,6 +4569,7 @@ class ProxyBaseLLMRequestProcessing:
if isinstance(e, HTTPException):
raise e
stream_error_status: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR)
log_safe_diagnostic(verbose_proxy_logger, "request_failed", status_code=stream_error_status)
proxy_exception: Final = ProxyException(
message=redact_internal_details_from_client_message(getattr(e, "message", str(e))),
type=openai_error_type(e, stream_error_status),

View file

@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import TypeAdapter
from typing_extensions import ReadOnly
import litellm
from litellm import get_secret_str
from litellm._logging import verbose_proxy_logger
from litellm.constants import PYTHON_GC_THRESHOLD
@ -853,7 +854,7 @@ async def get_otel_spans():
else:
recorded_spans = []
print("Spans: ", recorded_spans) # noqa: T201
print("Spans: ", "redacted-by-litellm" if litellm.redact_messages_in_debug_logs else recorded_spans) # noqa: T201
most_recent_parent = None
most_recent_start_time = 1000000
@ -948,4 +949,7 @@ def init_verbose_loggers():
except Exception as e:
import logging
logging.warning("Failed to init verbose loggers: %s", e)
logging.warning(
"Failed to init verbose loggers: %s",
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else e,
)

View file

@ -1770,7 +1770,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
try:
verbose_proxy_logger.debug(print_statement)
if litellm.set_verbose:
print(print_statement) # noqa: T201
print("redacted-by-litellm" if litellm.redact_messages_in_debug_logs else print_statement) # noqa: T201
except Exception:
pass

View file

@ -18,7 +18,10 @@ import litellm
if TYPE_CHECKING:
from litellm.router import Router
from litellm._logging import MessageContentFilter
logger: Final = logging.getLogger(__name__)
logger.addFilter(MessageContentFilter())
_DeploymentT: Final = TypeVar("_DeploymentT", bound=Mapping[str, object])
from litellm.constants import (
BACKGROUND_HEALTH_CHECK_MAX_TOKENS,

View file

@ -29,7 +29,7 @@ class _PROXY_BatchRedisRequests(CustomLogger):
if debug_level == "DEBUG" or debug_level == "INFO":
verbose_proxy_logger.debug(print_statement)
if litellm.set_verbose is True:
print(print_statement) # noqa: T201
print("redacted-by-litellm" if litellm.redact_messages_in_debug_logs else print_statement) # noqa: T201
async def async_pre_call_hook(
self,

View file

@ -75,7 +75,7 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger):
try:
verbose_proxy_logger.debug(print_statement)
if litellm.set_verbose:
print(print_statement) # noqa: T201
print("redacted-by-litellm" if litellm.redact_messages_in_debug_logs else print_statement) # noqa: T201
except Exception:
pass

View file

@ -83,7 +83,7 @@ class _OPTIONAL_PromptInjectionDetection(CustomLogger):
verbose_proxy_logger.debug(print_statement)
if litellm.set_verbose is True:
print(print_statement) # noqa: T201
print("redacted-by-litellm" if litellm.redact_messages_in_debug_logs else print_statement) # noqa: T201
def update_environment(self, router: Router | None = None) -> None:
self.llm_router = router

View file

@ -1,5 +1,14 @@
def post_response_rule(input): # receives the model response
print(f"post_response_rule:input={input}") # noqa: T201
from collections.abc import Mapping
import litellm
def post_response_rule(input: str) -> Mapping[str, bool | str]:
print( # noqa: T201 # legacy stdout diagnostic
"post_response_rule:input=redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"post_response_rule:input={input}"
)
if len(input) < 200:
return {
"decision": False,

View file

@ -1147,13 +1147,23 @@ def _redact_worker_config_for_logging(worker_config: str | dict[str, JsonValue]
(`LITELLM_DISABLE_REDACT_SECRETS=true`, an older log sink, a downstream
handler that captures records pre-filter). Redact at the source.
"""
if litellm.redact_messages_in_debug_logs:
return "redacted-by-litellm"
if worker_config is None:
return None
if isinstance(worker_config, dict):
return _redact_secret_values_in_obj(worker_config)
parsed: Final[JsonValue] = safe_json_loads(worker_config, default=None)
parsed: Final[JsonValue] = (
worker_config if isinstance(worker_config, dict) else safe_json_loads(worker_config, default=None)
)
if isinstance(parsed, dict):
return safe_dumps(_redact_secret_values_in_obj(parsed))
settings: Final = parsed.get("litellm_settings")
if isinstance(settings, dict) and settings.get("redact_messages_in_debug_logs") is not None:
try:
if TypeAdapter(bool).validate_python(settings["redact_messages_in_debug_logs"]):
return "redacted-by-litellm"
except ValidationError:
return "redacted-by-litellm"
redacted: Final = _redact_secret_values_in_obj(parsed)
return redacted if isinstance(worker_config, dict) else safe_dumps(redacted)
return worker_config
@ -5739,6 +5749,11 @@ class ProxyConfig:
}
def _apply_resolved_runtime_settings(self, config: Mapping[str, object]) -> None:
diagnostic_settings: Final = config.get("litellm_settings")
if isinstance(diagnostic_settings, Mapping) and "redact_messages_in_debug_logs" in diagnostic_settings:
litellm.redact_messages_in_debug_logs = TypeAdapter(bool).validate_python(
diagnostic_settings["redact_messages_in_debug_logs"]
)
for section, store in self._settings_stores.items():
if isinstance(config.get(section), Mapping):
store.apply_runtime_values(_as_settings_mapping(config[section]))
@ -6915,6 +6930,8 @@ class ProxyConfig:
parse_budget_reset_time(value)
setattr(litellm, key, value)
elif key == "redact_messages_in_debug_logs":
litellm.redact_messages_in_debug_logs = TypeAdapter(bool).validate_python(value)
elif key == "drop_params":
litellm.drop_params = drop_params_flag(value, "litellm_settings.drop_params", verbose_proxy_logger)
else:

View file

@ -435,9 +435,15 @@ def print_verbose(print_statement: object):
"""
import traceback
verbose_proxy_logger.debug("%s\n%s", print_statement, traceback.format_exc())
verbose_proxy_logger.debug(
"%s\n%s", print_statement, "" if litellm.redact_messages_in_debug_logs else traceback.format_exc()
)
if litellm.set_verbose:
print(f"LiteLLM Proxy: {redact_string(str(print_statement))}") # noqa: T201
print( # noqa: T201 # legacy stdout diagnostic
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"LiteLLM Proxy: {redact_string(str(print_statement))}"
)
def _get_email_logger_class():

View file

@ -1,6 +1,7 @@
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, cast
import litellm
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.agentic_loop_settings import validated_max_agentic_loops
@ -150,7 +151,8 @@ async def create_mcp_list_tools_events(
verbose_logger.error("Error creating MCP list tools events: %s", e)
import traceback
traceback.print_exc()
if not litellm.redact_messages_in_debug_logs:
traceback.print_exc()
# Emit failed event on error
failed_event: Final = MCPListToolsFailedEvent(
@ -816,7 +818,8 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
verbose_logger.error("Error creating initial response iterator: %s", e)
import traceback
traceback.print_exc()
if not litellm.redact_messages_in_debug_logs:
traceback.print_exc()
self.base_iterator = None
self._initial_creation_error = e
self._stream_error = e
@ -976,7 +979,8 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
verbose_logger.error("Error in tool execution: %s", e)
import traceback
traceback.print_exc()
if not litellm.redact_messages_in_debug_logs:
traceback.print_exc()
self.tool_results = []
self._tool_results_for_response = self.collected_response
@ -1052,7 +1056,8 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
verbose_logger.error("Error creating follow-up iterator: %s", e)
import traceback
traceback.print_exc()
if not litellm.redact_messages_in_debug_logs:
traceback.print_exc()
self.base_iterator = None
self._stream_error = e

View file

@ -53,7 +53,7 @@ import litellm
import litellm.litellm_core_utils.exception_mapping_utils
from litellm import get_secret_str
from litellm._internal_context import service_target, with_service_target
from litellm._logging import verbose_router_logger
from litellm._logging import log_safe_diagnostic, verbose_router_logger
from litellm._uuid import uuid
from litellm.caching.caching import (
DualCache,
@ -6774,6 +6774,8 @@ class Router:
trigger, healthy_deployments, all_deployments, initial_kwargs
):
return _StreamRetriesExhausted(last_error)
if litellm.redact_messages_in_debug_logs:
log_safe_diagnostic(verbose_router_logger, "retry", attempt=attempt + 1)
if get_metadata_variable_name_from_kwargs(retry_kwargs) in retry_kwargs:
self.log_retry(kwargs=retry_kwargs, e=trigger)
await asyncio.sleep(
@ -7627,7 +7629,8 @@ class Router:
except Exception as e:
import traceback
traceback.print_exc()
if not litellm.redact_messages_in_debug_logs:
traceback.print_exc()
receieved_exceptions.append(e)
return None
@ -9064,6 +9067,8 @@ class Router:
await asyncio.sleep(retry_after)
for current_attempt in range(num_retries):
if litellm.redact_messages_in_debug_logs:
log_safe_diagnostic(verbose_router_logger, "retry", attempt=current_attempt + 1)
try:
# Update retry tracking metadata before each retry attempt
_metadata["attempted_retries"] = current_attempt + 1

View file

@ -1,5 +1,6 @@
import hashlib
import json
import logging
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
@ -11,7 +12,7 @@ import httpx
from pydantic import TypeAdapter
import litellm
from litellm._logging import verbose_router_logger
from litellm._logging import log_safe_diagnostic, verbose_router_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs, safe_deep_copy
from litellm.litellm_core_utils.credential_accessor import reject_api_key_secret_reference
@ -870,7 +871,10 @@ async def run_async_fallback(
try:
# LOGGING
kwargs = litellm_router.log_retry(kwargs=kwargs, e=original_exception)
verbose_router_logger.info("Falling back to model_group = %s", mask_sensitive_structure(mg))
if litellm.redact_messages_in_debug_logs:
log_safe_diagnostic(verbose_router_logger, "fallback", attempt=fallback_depth + 1, level=logging.INFO)
else:
verbose_router_logger.info("Falling back to model_group = %s", mask_sensitive_structure(mg))
kwargs.pop("_target_order", None)
if isinstance(mg, str):
kwargs["model"] = mg

View file

@ -5,6 +5,7 @@ from typing import Final
from pydantic import JsonValue
import litellm
from litellm._logging import (
CorrelationContextFilter,
DiagnosticProcessingFilter,
@ -24,7 +25,7 @@ def context() -> tuple[str, str]:
def enabled(level: int) -> bool:
return verbose_logger.isEnabledFor(level)
return not getattr(litellm, "redact_messages_in_debug_logs", False) and verbose_logger.isEnabledFor(level)
def emit(

View file

@ -582,7 +582,7 @@ def print_verbose(
elif log_level == "ERROR":
verbose_logger.error(print_statement)
if litellm.set_verbose is True and logger_only is False:
print(print_statement) # noqa: T201
print("redacted-by-litellm" if litellm.redact_messages_in_debug_logs else print_statement) # noqa: T201
except Exception:
pass
@ -1948,7 +1948,9 @@ def client(original_function):
# [OPTIONAL] CHECK CACHE
print_verbose(
f"SYNC kwargs[caching]: {kwargs.get('caching', False)}; litellm.cache: {litellm.cache}; kwargs.get('cache')['no-cache']: {kwargs.get('cache', {}).get('no-cache', False)}"
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"SYNC kwargs[caching]: {kwargs.get('caching', False)}; litellm.cache: {litellm.cache}; kwargs.get('cache')['no-cache']: {kwargs.get('cache', {}).get('no-cache', False)}"
)
# if caching is false or cache["no-cache"]==True, don't run this
if (
@ -2232,7 +2234,9 @@ def client(original_function):
# [OPTIONAL] CHECK CACHE
if is_debugging_on():
print_verbose(
f"ASYNC kwargs[caching]: {kwargs.get('caching', False)}; litellm.cache: {litellm.cache}; kwargs.get('cache'): {kwargs.get('cache', None)}"
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"ASYNC kwargs[caching]: {kwargs.get('caching', False)}; litellm.cache: {litellm.cache}; kwargs.get('cache'): {kwargs.get('cache', None)}"
)
_caching_handler_response: CachingHandlerResponse | None = await _llm_caching_handler.async_get_cache(
model=model or "",
@ -8272,7 +8276,9 @@ def trim_messages(
#### Trimming messages if current_tokens > max_tokens
print_verbose(
f"Need to trim input messages: {messages}, current_tokens{current_tokens}, max_tokens: {max_tokens}"
"redacted-by-litellm"
if litellm.redact_messages_in_debug_logs
else f"Need to trim input messages: {messages}, current_tokens{current_tokens}, max_tokens: {max_tokens}"
)
system_message_event: dict | None = None
if system_message:
@ -8535,6 +8541,9 @@ def get_valid_models(
def print_args_passed_to_litellm(original_function, args, kwargs):
if not is_debugging_on():
return
if litellm.redact_messages_in_debug_logs:
print_verbose("redacted-by-litellm")
return
try:
# we've already printed this for acompletion, don't print for completion
if (

View file

@ -1,10 +1,14 @@
import zoneinfo
from datetime import datetime, timezone
from typing import Final
from unittest.mock import MagicMock, Mock, patch
import httpx
import polars as pl
import pytest
import respx
import litellm
from litellm.integrations.cloudzero.cz_stream_api import CloudZeroStreamer
@ -177,3 +181,20 @@ class TestCloudZeroStreamer:
# Should avoid scientific notation
assert "e" not in result["cost"].lower()
assert "e" not in result["large_float"].lower()
@pytest.mark.parametrize("redact", (False, True))
def test_cloudzero_http_error_diagnostics_respect_privacy_without_changing_exception(
redact: bool, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
marker: Final = "synthetic-private-upstream-content"
streamer: Final = CloudZeroStreamer("test-key", "test-connection")
with respx.mock:
respx.post(f"{streamer.base_url}/v2/connections/billing/anycost/test-connection/billing_drops").respond(
status_code=400, text=marker
)
with pytest.raises(httpx.HTTPStatusError) as captured:
streamer._send_daily_batch("2026-01-01", pl.DataFrame({"cost/cost": [1.0]}), "replace_hourly")
assert captured.value.response.text == marker
assert (marker in capsys.readouterr().out) is not redact

View file

@ -1,5 +1,10 @@
import os
import unittest
from typing import Final
import pytest
import litellm
from litellm._uuid import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
@ -118,3 +123,19 @@ class TestDeepEvalLogger(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
@pytest.mark.parametrize("redact", (False, True))
def test_retry_diagnostic_hides_exception_and_dynamic_details_only_when_opted_in(
redact: bool, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.integrations.deepeval.api import log_retry_error
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
marker: Final = "synthetic-private-retry-content"
failure: Final = ValueError(marker)
details: Final = {"exception": failure, "tries": 2}
log_retry_error(details)
assert (marker in caplog.text) is not redact
assert ("redacted-by-litellm" in caplog.text) is redact
assert details["exception"] is failure

View file

@ -13775,3 +13775,56 @@ def test_record_native_bedrock_guardrail_bills_nothing_for_a_response_served_fro
logging_obj.record_native_bedrock_guardrail({"trace": trace})
assert "standard_logging_guardrail_information" not in logging_obj.model_call_details["litellm_params"]["metadata"]
class _UnrenderableRequestBody:
def __repr__(self) -> str:
raise AssertionError("request body was formatted for diagnostics")
@pytest.mark.parametrize("json_logs", [False, True])
def test_private_debug_request_skips_body_formatting_and_keeps_transform_data(
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, json_logs: bool
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
monkeypatch.setattr(litellm, "json_logs", json_logs)
logging_obj.litellm_request_debug = True
body: Final = {"messages": _UnrenderableRequestBody()}
details: Final = {"api_base": "https://example.com", "complete_input_dict": body, "request_str": "PRIVATE_REQUEST"}
logging_obj._print_llm_call_debugging_log(api_base="https://example.com", headers={}, additional_args=details)
assert "event=request_sent" in caplog.text
assert "PRIVATE_REQUEST" not in caplog.text
assert details["complete_input_dict"] is body
@pytest.mark.parametrize("diagnostic_privacy", [False, True])
@pytest.mark.parametrize("callback_privacy", [False, True])
def test_debug_privacy_preserves_raw_transform_request_body(
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, diagnostic_privacy: bool, callback_privacy: bool
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", diagnostic_privacy)
monkeypatch.setattr(litellm, "turn_off_message_logging", callback_privacy)
metadata: Final = _pre_call_with_raw_request_logging(logging_obj)
if callback_privacy:
assert metadata["raw_request"] == REDACTED_BY_LITELLM
else:
assert "PRIVATE" in metadata["raw_request"]
assert logging_obj.model_call_details["raw_request_typed_dict"]["raw_request_body"] == _PRIVATE_RAW_REQUEST_ARGS["complete_input_dict"]
@pytest.mark.parametrize("enabled", [False, True])
def test_standard_payload_debug_stdout_obeys_diagnostic_privacy(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], enabled: bool
) -> None:
from litellm.litellm_core_utils.litellm_logging import emit_standard_logging_payload
monkeypatch.setenv("LITELLM_PRINT_STANDARD_LOGGING_PAYLOAD", "1")
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", enabled)
payload: Final = StandardLoggingPayload(messages=[{"role": "user", "content": "PRIVATE_PAYLOAD"}])
emit_standard_logging_payload(payload)
output: Final = capsys.readouterr().out
if enabled:
assert output.strip() == REDACTED_BY_LITELLM
else:
assert json.loads(output)["messages"] == payload["messages"]
assert payload["messages"] == [{"role": "user", "content": "PRIVATE_PAYLOAD"}]

View file

@ -7943,3 +7943,13 @@ def test_chunk_creator_usage_only_chunk_with_dict_usage_keeps_details(
assert usage.prompt_tokens_details.cached_tokens == 80
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 5
@pytest.mark.parametrize("enabled", [False, True])
def test_legacy_stream_verbose_output_obeys_diagnostic_privacy(monkeypatch, capsys, enabled):
from litellm.litellm_core_utils.streaming_handler import print_verbose
monkeypatch.setattr(litellm, "set_verbose", True)
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", enabled)
print_verbose("chunk: %s", "PRIVATE_STREAM_CONTENT")
assert capsys.readouterr().out.strip() == ("redacted-by-litellm" if enabled else "chunk: PRIVATE_STREAM_CONTENT")

View file

@ -198,3 +198,35 @@ def test_memory_routes_count_prompt_caching_pins(
assert response.status_code == 200
assert pin_count(response.json()) == expected
@pytest.mark.asyncio
async def test_otel_span_debug_privacy_preserves_api_result(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
from typing import Final
import litellm
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
from litellm.proxy.common_utils.debug_utils import get_otel_spans
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
exporter: Final = InMemorySpanExporter()
provider: Final = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
logger: Final = OpenTelemetry(config=OpenTelemetryConfig(exporter=exporter), tracer_provider=provider)
monkeypatch.setattr(proxy_server, "open_telemetry_logger", logger)
try:
with provider.get_tracer("privacy-test").start_as_current_span("PRIVATE_SPAN_42896") as span:
span.set_attribute("gen_ai.input.messages", "PRIVATE_MESSAGE_42896")
result: Final = await get_otel_spans()
output: Final = capsys.readouterr().out
assert result["otel_spans"] == ["PRIVATE_SPAN_42896"]
assert "redacted-by-litellm" in output
assert "PRIVATE" not in output
finally:
provider.shutdown()

View file

@ -12191,3 +12191,61 @@ def test_maybe_get_model_id_prefers_litellm_metadata_over_caller_metadata_withou
def test_maybe_get_model_id_returns_none_before_the_router_picked_a_deployment():
assert ProxyBaseLLMRequestProcessing(data={"metadata": {}}).maybe_get_model_id(None) is None
def test_debug_message_privacy_does_not_serialize_proxy_payload(monkeypatch: pytest.MonkeyPatch) -> None:
class Unrenderable:
def __str__(self) -> str:
raise AssertionError("diagnostics must not serialize request content")
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
content: Final = Unrenderable()
request: Final = ProxyBaseLLMRequestProcessing(data={"messages": [{"role": "user", "content": content}]})
request._debug_log_request_payload()
assert request.data["messages"][0]["content"] is content
@pytest.mark.asyncio
@pytest.mark.parametrize("privacy", [False, True])
async def test_debug_message_privacy_preserves_stream_content(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, privacy: bool
) -> None:
import logging
from litellm._logging import verbose_proxy_logger
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", privacy)
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(litellm, "include_cost_in_streaming_usage", False)
monkeypatch.setattr(verbose_proxy_logger, "level", logging.DEBUG)
ProxyLogging._callback_capabilities_cache.clear()
chunks: Final = ("data: PRIVATE_STREAM_INPUT_42896\n\n", "data: [DONE]\n\n")
async def upstream() -> AsyncGenerator[str, None]:
for chunk in chunks:
yield chunk
def serialize(chunk: object) -> str:
assert isinstance(chunk, str)
return chunk
stream: Final = ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
response=upstream(),
user_api_key_dict=ProxyUserAPIKeyAuth(),
request_data={"model": "example-model"},
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
serialize_chunk=serialize,
serialize_error=lambda error: str(error),
)
observed: Final = tuple([chunk async for chunk in stream])
assert observed == chunks
messages: Final = tuple(
record.getMessage() for record in caplog.records if record.name == verbose_proxy_logger.name
)
if privacy:
assert any("event=stream_completed" in message for message in messages)
assert all("PRIVATE_STREAM_INPUT_42896" not in message for message in messages)
else:
assert any("PRIVATE_STREAM_INPUT_42896" in message for message in messages)

View file

@ -3857,3 +3857,36 @@ async def test_model_list_return_wildcard_routes_defaults_to_general_setting(
assert "gpt-6.1-sol" in listed, listed
assert ("openai/*" in listed) is wildcard_listed, listed
@pytest.mark.parametrize("enabled", [True, False])
async def test_yaml_config_applies_diagnostic_privacy_before_model_initialization(
tmp_path, monkeypatch: pytest.MonkeyPatch, enabled: bool
) -> None:
from litellm.proxy.proxy_server import ProxyConfig
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", not enabled)
config_file: Final = tmp_path / "config.yaml"
config_file.write_text("litellm_settings:\n redact_messages_in_debug_logs: " + str(enabled).lower() + "\n")
config: Final = await ProxyConfig().get_config(str(config_file))
assert litellm.redact_messages_in_debug_logs is enabled
assert config["litellm_settings"]["redact_messages_in_debug_logs"] is enabled
@pytest.mark.parametrize("as_json", [False, True])
@pytest.mark.parametrize("enabled", [False, True])
def test_worker_config_privacy_applies_before_global_setting(
monkeypatch: pytest.MonkeyPatch, as_json: bool, enabled: bool
) -> None:
from litellm.proxy.proxy_server import _redact_worker_config_for_logging
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", False)
config: Final = {
"litellm_settings": {"redact_messages_in_debug_logs": enabled},
"model_list": [{"litellm_params": {"messages": [{"content": "PRIVATE_SYSTEM_PROMPT"}]}}],
}
result: Final = _redact_worker_config_for_logging(json.dumps(config) if as_json else config)
if enabled:
assert result == "redacted-by-litellm"
else:
assert "PRIVATE_SYSTEM_PROMPT" in str(result)

View file

@ -135,3 +135,14 @@ def test_missing_native_diagnostic_processor_falls_back_before_record_mutation(
def test_unsupported_unicode_uses_safe_python_redaction(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LITELLM_RUST", "1")
assert redact_secrets("broken\ud800 api_key=secret123") == "broken\ud800 REDACTED"
@pytest.mark.parametrize("level", (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR))
def test_native_diagnostics_do_not_format_or_emit_content_when_privacy_enabled(
level: int, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
assert not logger.enabled(level)
logger.emit(level, "private response", "native.rs", 1, "litellm_http", {"body": "private prompt"}, ("", ""))
assert caplog.records == []

View file

@ -2044,3 +2044,168 @@ def test_print_startup_message_blank_spacer_emits_no_json_record(
print_startup_message("\n\033[0m\n")
assert capfd.readouterr() == ("", "")
class _UnrenderableDiagnosticPayload:
def __str__(self) -> str:
raise AssertionError("diagnostic payload was rendered")
def __repr__(self) -> str:
raise AssertionError("diagnostic payload was rendered")
@pytest.mark.parametrize("level", [logging.DEBUG, logging.WARNING, logging.ERROR])
@pytest.mark.parametrize("credential_redaction", [False, True])
def test_message_privacy_removes_payload_before_rendering(
monkeypatch: pytest.MonkeyPatch, level: int, credential_redaction: bool
) -> None:
import litellm._logging as logging_module
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
monkeypatch.setattr(logging_module, "_ENABLE_SECRET_REDACTION", credential_redaction)
payload: Final = _UnrenderableDiagnosticPayload()
record: Final = logging.LogRecord("LiteLLM", level, __file__, 1, "payload=%s", (payload,), None)
record.exc_info = (ValueError, ValueError("PRIVATE_EXCEPTION"), None)
record.exc_text = "PRIVATE_CACHED_TRACEBACK"
record.stack_info = "PRIVATE_STACK"
record.payload = payload
record.color_message = "PRIVATE_COLOR"
record.message = "PRIVATE_CACHED_MESSAGE"
assert DiagnosticProcessingFilter().filter(record)
rendered: Final = JsonFormatter().format(record)
assert json.loads(rendered)["message"] == "redacted-by-litellm"
assert "PRIVATE" not in rendered
assert record.exc_info is None
assert record.args == ()
assert not hasattr(record, "payload")
@pytest.mark.parametrize("spoof", [True, "safe", "redacted-by-litellm"])
def test_message_privacy_cannot_be_disabled_by_log_extras(monkeypatch: pytest.MonkeyPatch, spoof: object) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
record: Final = logging.LogRecord("LiteLLM", logging.DEBUG, __file__, 1, "PRIVATE_PROMPT", (), None)
record.litellm_safe_diagnostic = spoof
assert DiagnosticProcessingFilter().filter(record)
assert record.getMessage() == "redacted-by-litellm"
def test_message_privacy_disabled_preserves_record(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm._logging import MessageContentFilter
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", False)
payload: Final = {"messages": [{"content": "PRIVATE_PROMPT"}]}
record: Final = logging.LogRecord("LiteLLM", logging.DEBUG, __file__, 1, "payload=%s", (payload,), None)
before: Final = dict(record.__dict__)
assert MessageContentFilter().filter(record)
assert record.__dict__ == before
assert record.args is before["args"]
assert "PRIVATE_PROMPT" in record.getMessage()
def test_message_privacy_keeps_safe_operational_events(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm._logging import MessageContentFilter, log_safe_diagnostic
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
stream: Final = StringIO()
handler: Final = logging.StreamHandler(stream)
handler.setFormatter(JsonFormatter())
handler.addFilter(MessageContentFilter())
logger: Final = logging.Logger("privacy-test", level=logging.DEBUG)
logger.addFilter(DiagnosticProcessingFilter())
logger.addHandler(handler)
log_safe_diagnostic(logger, "retry", status_code=429, elapsed_seconds=0.25, attempt=2)
result: Final = json.loads(stream.getvalue())
assert result["message"] == "event=retry status=429 elapsed_seconds=0.25 attempt=2 content=redacted-by-litellm"
assert "litellm_safe_diagnostic" not in result
def test_message_privacy_suppresses_caller_correlation_fields(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
trace_token: Final = set_trace_id("PRIVATE_TRACE")
session_token: Final = set_session_id("PRIVATE_SESSION")
try:
record: Final = logging.LogRecord("LiteLLM", logging.DEBUG, __file__, 1, "PRIVATE_PROMPT", (), None)
assert DiagnosticProcessingFilter().filter(record)
assert CorrelationContextFilter().filter(record)
assert "PRIVATE" not in JsonFormatter().format(record)
finally:
trace_id_var.reset(trace_token)
session_id_var.reset(session_token)
def test_json_formatter_alone_obeys_message_privacy(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
record: Final = logging.LogRecord(
"LiteLLM.child", logging.ERROR, __file__, 1, _UnrenderableDiagnosticPayload(), (), None
)
assert json.loads(JsonFormatter().format(record))["message"] == "redacted-by-litellm"
def test_message_privacy_precedes_custom_handler(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
stream: Final = StringIO()
handler: Final = logging.StreamHandler(stream)
verbose_logger.addHandler(handler)
try:
verbose_logger.warning("PRIVATE_PROMPT %s", _UnrenderableDiagnosticPayload())
assert stream.getvalue().strip() == "redacted-by-litellm"
finally:
verbose_logger.removeHandler(handler)
def test_message_privacy_does_not_change_application_records(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
record: Final = logging.LogRecord("application", logging.INFO, __file__, 1, "application says %s", ("hello",), None)
assert SecretRedactionFilter().filter(record)
assert json.loads(JsonFormatter().format(record))["message"] == "application says hello"
def _raise_private_diagnostic_exception() -> None:
raise ValueError("PRIVATE_ASYNC_EXCEPTION")
@pytest.mark.parametrize("module", ["litellm.background", "application.background"])
def test_asyncio_message_privacy_is_scoped_to_litellm_tracebacks(monkeypatch: pytest.MonkeyPatch, module: str) -> None:
from types import FunctionType
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
fail: Final = FunctionType(_raise_private_diagnostic_exception.__code__, {"__name__": module})
try:
fail()
except ValueError:
record: Final = logging.LogRecord("asyncio", logging.ERROR, __file__, 1, "PRIVATE_TASK", (), sys.exc_info())
assert SecretRedactionFilter().filter(record)
rendered: Final = JsonFormatter().format(record)
if module.startswith("litellm."):
assert "PRIVATE" not in rendered
else:
assert "PRIVATE_TASK" in rendered
assert "PRIVATE_ASYNC_EXCEPTION" in rendered
def test_private_diagnostics_support_custom_formatter_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm._logging import MessageContentFilter
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
record: Final = logging.LogRecord("LiteLLM", logging.ERROR, __file__, 1, "PRIVATE_PROMPT", (), None)
record.customer_metadata = "PRIVATE_METADATA"
assert MessageContentFilter().filter(record)
formatter: Final = logging.Formatter("%(levelname)s %(customer_metadata)s %(message)s", defaults={"customer_metadata": "-"})
assert formatter.format(record) == "ERROR - redacted-by-litellm"
def test_private_access_logs_keep_formatter_contract_without_request_content(monkeypatch: pytest.MonkeyPatch) -> None:
from uvicorn.logging import AccessFormatter
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
record: Final = logging.LogRecord(
"uvicorn.access", logging.INFO, __file__, 1, "PRIVATE_TEMPLATE",
("PRIVATE_CLIENT", "GET", "/v1/chat/completions?prompt=PRIVATE_PROMPT", "1.1", 200), None,
)
record.metadata = "PRIVATE_METADATA"
assert AccessLogRedactionFilter().filter(record)
formatted: Final = AccessFormatter('%(client_addr)s - "%(request_line)s" %(status_code)s', use_colors=False).format(record)
assert "PRIVATE" not in formatted
assert "200" in formatted
assert "PRIVATE" not in JsonFormatter().format(record)

View file

@ -10880,3 +10880,36 @@ def test_get_optional_params_forwards_a_tool_config_cache_point_to_a_downstream_
)
assert optional_params["extra_body"] == {"kept": True, "cache_control_injection_points": [point]}
@pytest.mark.parametrize("redact", (False, True))
@pytest.mark.parametrize("module_name", ("litellm.utils", "litellm.main", "litellm.caching.caching", "litellm.proxy.utils"))
def test_legacy_verbose_stdout_obeys_diagnostic_privacy(
redact: bool,
module_name: str,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
monkeypatch.setattr(litellm, "set_verbose", True)
module: Final = importlib.import_module(module_name)
module.print_verbose("synthetic-private-prompt-42896")
stdout: Final = capsys.readouterr().out
assert ("synthetic-private-prompt-42896" in stdout) is not redact
assert ("redacted-by-litellm" in stdout) is redact
def test_private_argument_diagnostic_does_not_render_request_objects(
capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.utils import print_args_passed_to_litellm
class UnrenderableRequest:
def __repr__(self) -> str:
raise AssertionError("diagnostic attempted to render a private request")
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", True)
monkeypatch.setattr(litellm, "set_verbose", True)
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
print_args_passed_to_litellm(litellm.completion, (UnrenderableRequest(),), {"messages": UnrenderableRequest()})
assert capsys.readouterr().out.endswith("redacted-by-litellm\n")