This commit is contained in:
moe-berri 2026-10-30 01:32:37 +00:00 • committed by GitHub
commit 70ebef737e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1647 additions and 128 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
</details>
<details>

View file

@ -189,6 +189,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,11 +7,12 @@ 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 types import FrameType
from typing import Final, TextIO
from typing import Final, Literal, TextIO
from urllib.parse import unquote
from pydantic import TypeAdapter
@ -23,6 +24,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
@ -91,6 +93,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))
@ -192,6 +276,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)
@ -252,6 +337,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:
@ -461,6 +563,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)
@ -478,7 +581,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
@ -562,6 +665,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):
@ -610,7 +714,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:
@ -774,13 +878,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
@ -798,6 +902,7 @@ class JsonFormatter(Formatter):
return dt.isoformat()
def format(self, record):
_redact_diagnostic_record(record)
message_str: Final = _ANSI_ESCAPE.sub("", record.getMessage())
json_record: Final[dict[str, object]] = {
"message": message_str,
@ -890,7 +995,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),
)
@ -911,7 +1016,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__),
)
@ -987,7 +1092,7 @@ def _write_startup_message(message: str, caller: FrameType | None, level: int) -
if not litellm.json_logs:
import click
click.echo(terminal_text(message))
click.echo(REDACTED_BY_LITELLM if diagnostic_message_logging_disabled() else terminal_text(message))
return
text: Final = _ANSI_ESCAPE.sub("", message).strip("\n")
if not text:
@ -1227,7 +1332,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

@ -61,7 +61,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
@ -321,7 +329,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"]
@ -393,7 +405,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"]
@ -428,7 +444,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

@ -425,7 +425,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:
@ -462,7 +466,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)
@ -571,7 +579,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)
@ -612,7 +624,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"
@ -22,9 +23,13 @@ from litellm.llms.custom_httpx.http_handler import (
def log_retry_error(details):
exception: Final = details.get("exception")
tries: Final = details.get("tries")
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)
logging.error(
"Confident AI Error: %s. Retrying: %s time(s)...",
"redacted-by-litellm" if litellm.redact_messages_in_debug_logs else exception,
tries,
)
else:
logging.error("Retrying: %s time(s)...", tries)

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

@ -6,6 +6,7 @@ import copy
import datetime
import functools
import json
import logging
import math
import os
import re
@ -26,7 +27,9 @@ from pydantic import BaseModel, JsonValue, TypeAdapter
import litellm
from litellm._internal_context import in_mirrored_call, outside_mirrored_call, 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,
@ -1724,6 +1727,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 {})
@ -1804,11 +1814,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(
@ -7767,7 +7784,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

@ -163,10 +163,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
@ -666,10 +672,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):
@ -859,7 +865,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")
@ -1500,13 +1507,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:
@ -1528,7 +1543,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:
@ -1545,7 +1564,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":
@ -1563,7 +1586,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
@ -2083,11 +2110,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

@ -182,7 +182,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

@ -946,11 +946,16 @@ 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:
print_verbose(f"line in async streaming: {line}")
if not litellm.redact_messages_in_debug_logs:
print_verbose(f"line in async streaming: {line}")
yield line
except Exception as e:
custom_llm_provider = custom_llm_provider or "openai"
@ -9403,7 +9408,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

@ -8,6 +8,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
from itertools import chain
from time import perf_counter
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@ -34,7 +35,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 (
@ -2877,7 +2878,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:
@ -2933,7 +2934,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,
@ -2958,6 +2964,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,
@ -4315,6 +4333,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)
billing_logging_obj: Final = _REQUEST_FIELDS.validate_python(self.data).get("litellm_logging_obj")
# Allow callbacks to transform the error response
@ -4601,6 +4626,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
@ -4619,7 +4645,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:
@ -4662,6 +4688,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
@ -4704,9 +4735,12 @@ class ProxyBaseLLMRequestProcessing:
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, level=logging.ERROR
)
if isinstance(e, HTTPException):
raise e
stream_error_status: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR)
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
@ -868,7 +869,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
@ -963,4 +964,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

@ -1163,13 +1163,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
@ -4990,27 +5000,45 @@ def sentry_is_configured(general_settings: Mapping[str, object]) -> bool:
)
def _scrub_guardrail_inner(inner: dict[str, JsonValue]) -> None:
_DbConfigWarning: TypeAlias = tuple[str, tuple[object, ...]]
def _warn_db_config(
message: str, *args: object, warning_sink: Callable[[_DbConfigWarning], None] | None = None
) -> None:
if warning_sink is None:
verbose_proxy_logger.warning(message, *args)
else:
warning_sink((message, args))
def _scrub_guardrail_inner(
inner: dict[str, JsonValue], warning_sink: Callable[[_DbConfigWarning], None] | None = None
) -> None:
"""Strip remote-URL entries from a guardrail's ``callbacks`` list
and ``guardrail`` (v2 module-path) field. Mutates in place."""
cbs: Final = inner.get("callbacks")
if isinstance(cbs, list):
cleaned: Final = [c for c in cbs if not _is_remote_module_url(c)]
if len(cleaned) != len(cbs):
verbose_proxy_logger.warning(
_warn_db_config(
"Refused %d remote-URL entries from DB-overlay litellm_settings.guardrails[...].callbacks",
len(cbs) - len(cleaned),
warning_sink=warning_sink,
)
inner["callbacks"] = cleaned
if _is_remote_module_url(inner.get("guardrail")):
verbose_proxy_logger.warning(
_warn_db_config(
"Refused remote-URL guardrail module from DB-overlay litellm_settings.guardrails[...].guardrail: %r",
inner.get("guardrail"),
warning_sink=warning_sink,
)
inner["guardrail"] = None
def _scrub_db_overlay_remote_module_loads(section: str, db_value: object) -> object:
def _scrub_db_overlay_remote_module_loads(
section: str, db_value: object, warning_sink: Callable[[_DbConfigWarning], None] | None = None
) -> object:
"""Strip ``s3://`` / ``gcs://`` entries from the DB-overlay value for
fields whose contents reach ``get_instance_fn``. The same scheme is
allowed from a YAML config (the documented operator flow) but a
@ -5026,12 +5054,13 @@ def _scrub_db_overlay_remote_module_loads(section: str, db_value: object) -> obj
for field in str_fields:
v = sanitized.get(field)
if _is_remote_module_url(v):
verbose_proxy_logger.warning(
_warn_db_config(
"Refused remote-URL value for DB-overlay %s.%s=%r; only "
"config.yaml entries may reference s3:// / gcs:// modules.",
section,
field,
v,
warning_sink=warning_sink,
)
sanitized[field] = None
for field in list_fields:
@ -5039,13 +5068,14 @@ def _scrub_db_overlay_remote_module_loads(section: str, db_value: object) -> obj
if isinstance(v, list):
cleaned = [item for item in v if not _is_remote_module_url(item)]
if len(cleaned) != len(v):
verbose_proxy_logger.warning(
_warn_db_config(
"Refused %d remote-URL entries from DB-overlay %s.%s; "
"only config.yaml entries may reference s3:// / gcs:// "
"modules.",
len(v) - len(cleaned),
section,
field,
warning_sink=warning_sink,
)
sanitized[field] = cleaned
# ``custom_provider_map`` is a list of dicts with ``custom_handler`` —
@ -5055,9 +5085,10 @@ def _scrub_db_overlay_remote_module_loads(section: str, db_value: object) -> obj
if isinstance(cpm, list):
for item in cpm:
if isinstance(item, dict) and _is_remote_module_url(item.get("custom_handler")):
verbose_proxy_logger.warning(
_warn_db_config(
"Refused remote-URL custom_handler from DB-overlay litellm_settings.custom_provider_map: %r",
item.get("custom_handler"),
warning_sink=warning_sink,
)
item["custom_handler"] = None
# ``litellm_settings.guardrails`` is a list of single-key dicts in
@ -5075,19 +5106,20 @@ def _scrub_db_overlay_remote_module_loads(section: str, db_value: object) -> obj
for inner in entry.values():
if not isinstance(inner, dict):
continue
_scrub_guardrail_inner(inner)
_scrub_guardrail_inner(inner, warning_sink=warning_sink)
lp = entry.get("litellm_params")
if isinstance(lp, dict):
_scrub_guardrail_inner(lp)
_scrub_guardrail_inner(lp, warning_sink=warning_sink)
# ``general_settings.litellm_jwtauth.custom_validate`` is a nested
# string field.
if section == "general_settings":
jwt: Final = sanitized.get("litellm_jwtauth")
if isinstance(jwt, dict) and _is_remote_module_url(jwt.get("custom_validate")):
verbose_proxy_logger.warning(
_warn_db_config(
"Refused remote-URL custom_validate from DB-overlay general_settings.litellm_jwtauth: %r",
jwt.get("custom_validate"),
warning_sink=warning_sink,
)
jwt["custom_validate"] = None
# ``pass_through_endpoints`` is a list of dicts whose ``target``
@ -5099,12 +5131,13 @@ def _scrub_db_overlay_remote_module_loads(section: str, db_value: object) -> obj
if isinstance(pte, list):
for entry in pte:
if isinstance(entry, dict) and _is_remote_module_url(entry.get("target")):
verbose_proxy_logger.warning(
_warn_db_config(
"Refused remote-URL target from DB-overlay "
"general_settings.pass_through_endpoints "
"(path=%r): %r",
entry.get("path"),
entry.get("target"),
warning_sink=warning_sink,
)
entry["target"] = None
return sanitized
@ -5794,6 +5827,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]))
@ -6403,12 +6441,16 @@ class ProxyConfig:
self._load_yaml_settings_stores(config)
pending_db_diagnostics: Final[list[tuple[str, JsonValue]]] = [] # mutable-ok: defer rows until privacy resolves
pending_db_warnings: Final[list[_DbConfigWarning]] = [] # mutable-ok: defer warnings until privacy resolves
## UPDATE CONFIG WITH DB
if prisma_client is not None and store_model_in_db is True:
config = await self._update_config_from_db(
config=config,
prisma_client=prisma_client,
store_model_in_db=store_model_in_db,
diagnostic_sink=pending_db_diagnostics.append,
warning_sink=pending_db_warnings.append,
)
config = self._config_with_resolved_settings(config)
@ -6421,6 +6463,12 @@ class ProxyConfig:
config = self._check_for_os_environ_vars(config=config)
self._apply_resolved_runtime_settings(config)
for section, param_value in pending_db_diagnostics:
_log_db_config_param(section, param_value)
for message, args in pending_db_warnings:
verbose_proxy_logger.warning(message, *args)
if prisma_client is not None and store_model_in_db is True:
self._warn_about_shadowed_db_settings()
self.update_config_state(config=config)
@ -6973,6 +7021,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)
elif key == "suppress_startup_output":
@ -8462,6 +8512,8 @@ class ProxyConfig:
prisma_client: PrismaClient,
config: dict,
store_model_in_db: bool | None,
diagnostic_sink: Callable[[tuple[str, JsonValue]], None] | None = None,
warning_sink: Callable[[_DbConfigWarning], None] | None = None,
):
if store_model_in_db is not True:
verbose_proxy_logger.info("'store_model_in_db' is not True, skipping db updates")
@ -8479,13 +8531,14 @@ class ProxyConfig:
if param_value is None:
continue
verbose_proxy_logger.debug(
"param_name=%s, param_value=%s",
section,
_redact_config_param_value_for_logging(section, param_value),
)
if diagnostic_sink is None:
_log_db_config_param(section, param_value)
else:
diagnostic_sink((section, param_value))
if section == "litellm_settings":
self._apply_litellm_settings_db_values(self._prepared_db_settings_values(section, param_value))
self._apply_litellm_settings_db_values(
self._prepared_db_settings_values(section, param_value, warning_sink=warning_sink)
)
elif section == "environment_variables":
await self._apply_db_environment_variables_row(
prisma_client, response, environment_before_db, environment_read
@ -8493,10 +8546,11 @@ class ProxyConfig:
else:
self._settings_stores[section].apply_db_row(
section,
self._prepared_db_settings_values(section, param_value),
self._prepared_db_settings_values(section, param_value, warning_sink=warning_sink),
)
self._warn_about_shadowed_db_settings()
if warning_sink is None:
self._warn_about_shadowed_db_settings()
return self._config_with_resolved_settings(config)
async def _apply_db_environment_variables_row(
@ -8551,12 +8605,17 @@ class ProxyConfig:
)
for section, key in sorted(shadowed - self._warned_shadowed_keys):
verbose_proxy_logger.warning(
"%s", config_ownership_message(section=section, key=key, shadows_db_value=True)
"%s",
config_ownership_message(section=section, key=key, shadows_db_value=True),
)
self._warned_shadowed_keys = shadowed
def _prepared_db_settings_values(self, section: Section, value: object) -> Mapping[str, SettingsJsonValue]:
scrubbed: Final = _scrub_db_overlay_remote_module_loads(section=section, db_value=value)
def _prepared_db_settings_values(
self, section: Section, value: object, warning_sink: Callable[[_DbConfigWarning], None] | None = None
) -> Mapping[str, SettingsJsonValue]:
scrubbed: Final = _scrub_db_overlay_remote_module_loads(
section=section, db_value=value, warning_sink=warning_sink
)
return _as_settings_mapping(scrubbed)
def _apply_db_environment_variables(
@ -20139,6 +20198,14 @@ def _redact_config_param_value_for_logging(param_name: str | None, param_value:
return param_value
def _log_db_config_param(section: str, param_value: JsonValue) -> None:
verbose_proxy_logger.debug(
"param_name=%s, param_value=%s",
section,
_redact_config_param_value_for_logging(section, param_value),
)
def _redact_general_setting_value(field_name: str, value: JsonValue, is_full_admin: bool) -> JsonValue:
if is_full_admin:
return value

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

@ -75,7 +75,7 @@ import litellm
import litellm.litellm_core_utils.exception_mapping_utils
from litellm import get_secret_str
from litellm._internal_context import in_mirrored_call, mirrored_call, 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,
@ -7111,6 +7111,8 @@ class Router(DeprecatedRouterMembers):
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._core_log_retry(kwargs=retry_kwargs, e=trigger)
await asyncio.sleep(
@ -7965,7 +7967,8 @@ class Router(DeprecatedRouterMembers):
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
@ -9412,6 +9415,8 @@ class Router(DeprecatedRouterMembers):
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.batch_utils import get_router_metadata_variable_name
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs, safe_deep_copy
@ -873,7 +874,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

@ -587,7 +587,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
@ -2069,7 +2069,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 (
@ -2352,7 +2354,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 "",
@ -8618,7 +8622,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:
@ -8878,6 +8884,9 @@ def _debug_params_repr(params: Mapping[str, object]) -> str:
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,12 +1,15 @@
import json
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 +180,64 @@ 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
@pytest.mark.parametrize("redact", (False, True))
def test_cloudzero_skips_invalid_timestamp_without_logging_content_when_private(
redact: bool, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
marker: Final = "synthetic-private-invalid-timestamp"
streamer: Final = CloudZeroStreamer("test-key", "test-connection")
data: Final = pl.DataFrame(
{"time/usage_start": [marker, "2026-01-01T00:00:00Z"], "cost/cost": [9.0, 1.0]}
)
with respx.mock:
route: Final = respx.post(
f"{streamer.base_url}/v2/connections/billing/anycost/test-connection/billing_drops"
).respond(status_code=200)
streamer.send_batched(data)
assert len(route.calls) == 1
assert json.loads(route.calls[0].request.content) == {
"month": "2026-01",
"operation": "replace_hourly",
"data": [{"time/usage_start": "2026-01-01T00:00:00Z", "cost/cost": "1"}],
}
assert (marker in capsys.readouterr().out) is not redact
@pytest.mark.parametrize("redact", (False, True))
def test_cloudzero_network_error_preserved_without_logging_content_when_private(
redact: bool, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
marker: Final = "synthetic-private-network-error"
streamer: Final = CloudZeroStreamer("test-key", "test-connection")
failure: Final = httpx.ConnectError(marker)
with respx.mock:
respx.post(
f"{streamer.base_url}/v2/connections/billing/anycost/test-connection/billing_drops"
).mock(side_effect=failure)
with pytest.raises(httpx.ConnectError) as captured:
streamer.send_batched(
pl.DataFrame({"time/usage_start": ["2026-01-01T00:00:00Z"], "cost/cost": [1.0]})
)
assert captured.value is failure
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,22 @@ class TestDeepEvalLogger(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
@pytest.mark.parametrize("redact", (False, True))
@pytest.mark.parametrize("exception_kind", ("missing", "none", "error"))
def test_retry_diagnostic_hides_exception_and_dynamic_details_only_when_opted_in(
redact: bool, exception_kind: str, 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) if exception_kind == "error" else None
details: Final = {"tries": 2} if exception_kind == "missing" else {"exception": failure, "tries": 2}
log_retry_error(details)
assert ("Confident AI Error:" in caplog.text) is (failure is not None)
assert (marker in caplog.text) is (failure is not None and not redact)
assert ("redacted-by-litellm" in caplog.text) is redact
assert details.get("exception") is failure
assert ("exception" in details) is (exception_kind != "missing")

View file

@ -13993,6 +13993,59 @@ def test_record_native_bedrock_guardrail_bills_nothing_for_a_response_served_fro
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"}]
def test_record_native_bedrock_guardrail_bills_each_call_its_own_trace_when_calls_share_one_metadata_dict() -> None:
import datetime

View file

@ -3758,11 +3758,17 @@ def test_dispatch_petals_empty_stream_after_finish_raises(
_run_dispatch(initialized_custom_stream_wrapper, chunk=None)
@pytest.mark.parametrize("redact", (False, True))
def test_dispatch_cached_response_extracts_delta(
initialized_custom_stream_wrapper: CustomStreamWrapper,
redact: bool,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
):
"""cached_response replays a stored ModelResponseStream; the dispatch lifts
its delta content, finish_reason and id back onto the live response."""
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
monkeypatch.setattr(litellm, "set_verbose", True)
initialized_custom_stream_wrapper.custom_llm_provider = "cached_response"
chunk = ModelResponseStream(
id="chatcmpl-cache-1",
@ -3784,6 +3790,7 @@ def test_dispatch_cached_response_extracts_delta(
assert initialized_custom_stream_wrapper.received_finish_reason == "stop"
assert model_response.id == "chatcmpl-cache-1"
assert initialized_custom_stream_wrapper.response_id == "chatcmpl-cache-1"
assert ("cached text" in capsys.readouterr().out) is not redact
def test_dispatch_cached_response_without_choices_is_an_empty_chunk(
@ -3955,11 +3962,17 @@ def test_dispatch_custom_provider_finish_only_returns_none_early(
assert initialized_custom_stream_wrapper.received_finish_reason == "stop"
@pytest.mark.parametrize("redact", (False, True))
def test_dispatch_text_completion_codestral_parses_chunk(
initialized_custom_stream_wrapper: CustomStreamWrapper,
redact: bool,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
):
"""text-completion-codestral streams raw SSE JSON strings that the dispatch
routes through CodestralTextCompletionConfig to extract content/finish."""
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
monkeypatch.setattr(litellm, "set_verbose", True)
initialized_custom_stream_wrapper.custom_llm_provider = "text-completion-codestral"
chunk = json.dumps(
{"choices": [{"delta": {"content": "codestral text"}, "finish_reason": "stop"}]}
@ -3970,6 +3983,7 @@ def test_dispatch_text_completion_codestral_parses_chunk(
assert isinstance(result, _ProviderChunkParsed)
assert completion_obj["content"] == "codestral text"
assert initialized_custom_stream_wrapper.received_finish_reason == "stop"
assert ("codestral text" in capsys.readouterr().out) is not redact
def test_dispatch_text_completion_codestral_requires_string(
@ -3983,10 +3997,16 @@ def test_dispatch_text_completion_codestral_requires_string(
_run_dispatch(initialized_custom_stream_wrapper, {"not": "a string"})
@pytest.mark.parametrize("redact", (False, True))
def test_dispatch_triton_stream(
initialized_custom_stream_wrapper: CustomStreamWrapper,
redact: bool,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
):
"""triton stream chunks arrive as dicts keyed by text_output/stop_reason."""
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
monkeypatch.setattr(litellm, "set_verbose", True)
initialized_custom_stream_wrapper.custom_llm_provider = "triton"
chunk = {"text_output": "triton text", "is_finished": True, "stop_reason": "stop"}
@ -3995,13 +4015,20 @@ def test_dispatch_triton_stream(
assert isinstance(result, _ProviderChunkParsed)
assert completion_obj["content"] == "triton text"
assert initialized_custom_stream_wrapper.received_finish_reason == "stop"
assert ("triton text" in capsys.readouterr().out) is not redact
@pytest.mark.parametrize("redact", (False, True))
def test_dispatch_text_completion_openai_with_usage(
initialized_custom_stream_wrapper: CustomStreamWrapper,
redact: bool,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
):
"""text-completion-openai chunks expose choices[].text and an optional usage
block that the dispatch lifts onto the model response."""
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
monkeypatch.setattr(litellm, "set_verbose", True)
initialized_custom_stream_wrapper.custom_llm_provider = "text-completion-openai"
class _Choice:
@ -4026,6 +4053,7 @@ def test_dispatch_text_completion_openai_with_usage(
assert initialized_custom_stream_wrapper.received_finish_reason == "stop"
assert model_response.usage.prompt_tokens == 5
assert model_response.usage.total_tokens == 8
assert ("oai text" in capsys.readouterr().out) is not redact
@pytest.mark.parametrize("custom_llm_provider", ["text-completion-openai", "azure_text"])
@ -8248,3 +8276,48 @@ 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")
@pytest.mark.parametrize("redact", (False, True))
def test_predibase_stream_privacy_preserves_content_and_finish(
initialized_custom_stream_wrapper: CustomStreamWrapper,
redact: bool,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
monkeypatch.setattr(litellm, "set_verbose", True)
initialized_custom_stream_wrapper.custom_llm_provider = "predibase"
result, _, completion_obj = _run_dispatch(
initialized_custom_stream_wrapper,
b'data: {"token": {"text": "PRIVATE_PREDIBASE_REPLY"}, "details": {"finish_reason": "stop"}}',
)
assert isinstance(result, _ProviderChunkParsed)
assert completion_obj["content"] == "PRIVATE_PREDIBASE_REPLY"
assert initialized_custom_stream_wrapper.received_finish_reason == "stop"
assert ("PRIVATE_PREDIBASE_REPLY" in capsys.readouterr().out) is not redact
@pytest.mark.parametrize("redact", (False, True))
def test_triton_invalid_stream_chunk_keeps_error_out_of_private_diagnostics(
initialized_custom_stream_wrapper: CustomStreamWrapper,
redact: bool,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
monkeypatch.setattr(litellm, "set_verbose", True)
initialized_custom_stream_wrapper.custom_llm_provider = "triton"
with pytest.raises(ValueError, match=r"Unable to parse response.*PRIVATE_TRITON_CHUNK"):
_run_dispatch(initialized_custom_stream_wrapper, ["PRIVATE_TRITON_CHUNK"])
assert ("PRIVATE_TRITON_CHUNK" in capsys.readouterr().out) is not redact

View file

@ -48,3 +48,32 @@ async def test_fim_stream_keeps_unicode_line_separators_inside_a_delta(
assert texts == [_LINE_SEPARATED_TEXT, None]
assert json.loads(route.calls.last.request.content)["stream"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize("privacy", [False, True])
async def test_fim_response_content_is_preserved_when_diagnostics_are_private(
privacy: bool,
respx_mock: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", privacy)
monkeypatch.setattr(litellm, "set_verbose", True)
route: Final = respx_mock.post("https://codestral.mistral.ai/v1/fim/completions").mock(
return_value=httpx.Response(200, json={
"id": "fim-private", "object": "text_completion", "created": 1, "model": "codestral-latest",
"choices": [{"index": 0, "message": {"content": "PRIVATE_FIM_REPLY"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5},
})
)
response: Final = await litellm.atext_completion(
model="text-completion-codestral/codestral-latest",
prompt="PRIVATE_FIM_PROMPT", api_key="test-codestral-key", max_retries=0,
)
assert response.choices[0].text == "PRIVATE_FIM_REPLY"
assert json.loads(route.calls.last.request.content)["prompt"] == "PRIVATE_FIM_PROMPT"
assert ("PRIVATE_FIM_REPLY" in capsys.readouterr().out) is not privacy

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

@ -6271,3 +6271,160 @@ async def test_proxy_config_validates_advertised_mcp_versions_at_load(tmp_path,
return
with pytest.raises(ValidationError):
await ProxyConfig().load_config(router=None, config_file_path=str(config))
@pytest.mark.asyncio
@pytest.mark.parametrize("privacy", [False, True])
async def test_proxy_config_load_applies_diagnostic_privacy(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, privacy: bool
) -> None:
config_file: Final = tmp_path / "privacy.yaml"
config_file.write_text(
"model_list: []\nlitellm_settings:\n redact_messages_in_debug_logs: " + str(privacy).lower() + "\n"
)
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", not privacy)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False)
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
await ProxyConfig().load_config(router=None, config_file_path=str(config_file))
assert litellm.redact_messages_in_debug_logs is privacy
@pytest.mark.asyncio
@pytest.mark.parametrize("file_privacy", [None, False, True])
@pytest.mark.parametrize("db_privacy", [False, True, "invalid-boolean"])
@pytest.mark.parametrize("env_reference", [False, True])
async def test_proxy_config_privacy_precedes_database_settings_diagnostics(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
file_privacy: bool | None,
db_privacy: bool | str,
env_reference: bool,
) -> None:
from litellm.caching.dual_cache import DualCache
from litellm.proxy import proxy_server
from litellm.proxy import utils as proxy_utils
monkeypatch.setenv("LITELLM_QA_DB_PRIVACY", str(db_privacy).lower())
monkeypatch.setenv("LITELLM_QA_FILE_PRIVACY", str(file_privacy).lower())
file_value: Final = "os.environ/LITELLM_QA_FILE_PRIVACY" if env_reference else file_privacy
remote_module: Final = "s3://example-bucket/PRIVATE_DB_CONTENT.py"
stored: Final = {
"general_settings": {
"alerting": ["PRIVATE_DB_CONTENT"],
"custom_auth": remote_module,
"litellm_jwtauth": {"custom_validate": remote_module},
"pass_through_endpoints": [{"path": "/PRIVATE_DB_CONTENT", "target": remote_module}],
"PRIVATE_DB_SETTING": "database-value",
},
"router_settings": {"model_group_alias": {"PRIVATE_DB_CONTENT": "qa-model"}},
"litellm_settings": {
"redact_messages_in_debug_logs": "os.environ/LITELLM_QA_DB_PRIVACY" if env_reference else db_privacy,
"model_alias_map": {"PRIVATE_DB_CONTENT": "qa-model"},
"callbacks": [remote_module, "prometheus"],
"custom_provider_map": [{"provider": "qa", "custom_handler": remote_module}],
"guardrails": [{"qa": {"callbacks": [remote_module], "guardrail": remote_module}}],
},
}
async def get_generic_data(key: str, value: str, table_name: str) -> SimpleNamespace | None:
assert (key, table_name) == ("param_name", "config")
row: Final = stored.get(value)
return SimpleNamespace(param_name=value, param_value=row) if row is not None else None
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", False)
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(get_generic_data=get_generic_data))
monkeypatch.setattr(proxy_server, "store_model_in_db", True)
monkeypatch.setattr(proxy_utils, "litellm_config_cache", DualCache())
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
config_file: Final = tmp_path / "db-privacy.yaml"
config_file.write_text(
json.dumps(
{
"litellm_settings": {} if file_privacy is None else {"redact_messages_in_debug_logs": file_value},
"general_settings": {"PRIVATE_DB_SETTING": "file-value"},
}
)
)
enabled: Final = db_privacy if file_privacy is None else file_privacy
if isinstance(enabled, str):
with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"), pytest.raises(ValidationError):
await ProxyConfig().get_config(str(config_file))
assert "PRIVATE_DB_CONTENT" not in caplog.text
assert litellm.redact_messages_in_debug_logs is False
return
with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"):
loaded: Final = await ProxyConfig().get_config(str(config_file))
assert ("PRIVATE_DB_CONTENT" in caplog.text) is not enabled
assert litellm.redact_messages_in_debug_logs is enabled
assert loaded["litellm_settings"]["redact_messages_in_debug_logs"] is enabled
assert loaded["litellm_settings"]["model_alias_map"] == {"PRIVATE_DB_CONTENT": "qa-model"}
assert loaded["general_settings"] == {
"alerting": ["PRIVATE_DB_CONTENT"],
"litellm_jwtauth": {"custom_validate": None},
"pass_through_endpoints": [{"path": "/PRIVATE_DB_CONTENT", "target": None}],
"PRIVATE_DB_SETTING": "file-value",
}
assert loaded["litellm_settings"]["callbacks"] == ["prometheus"]
assert loaded["litellm_settings"]["custom_provider_map"] == [{"provider": "qa", "custom_handler": None}]
assert loaded["litellm_settings"]["guardrails"] == [{"qa": {"callbacks": [], "guardrail": None}}]
assert stored["general_settings"]["custom_auth"] == remote_module
assert any(record.levelno == logging.WARNING for record in caplog.records)
assert loaded["router_settings"] == stored["router_settings"]
@pytest.mark.asyncio
@pytest.mark.parametrize("privacy", [False, True])
async def test_shadow_warning_survives_failed_config_load_and_emits_once(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, privacy: bool
) -> None:
from litellm.caching.dual_cache import DualCache
from litellm.proxy import proxy_server
from litellm.proxy import utils as proxy_utils
async def get_generic_data(key: str, value: str, table_name: str) -> SimpleNamespace | None:
assert (key, table_name) == ("param_name", "config")
return (
SimpleNamespace(param_name=value, param_value={"max_parallel_requests": 2})
if value == "general_settings"
else None
)
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", False)
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(get_generic_data=get_generic_data))
monkeypatch.setattr(proxy_server, "store_model_in_db", True)
monkeypatch.setattr(proxy_utils, "litellm_config_cache", DualCache())
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
monkeypatch.setenv("LITELLM_QA_SHADOW_PRIVACY", "invalid-boolean")
config_file: Final = tmp_path / "shadow-warning.yaml"
config_file.write_text(
json.dumps(
{
"litellm_settings": {"redact_messages_in_debug_logs": "os.environ/LITELLM_QA_SHADOW_PRIVACY"},
"general_settings": {"max_parallel_requests": 5},
}
)
)
config: Final = ProxyConfig()
caplog.set_level(logging.WARNING, logger="LiteLLM Proxy")
with pytest.raises(ValidationError):
await config.get_config(str(config_file))
assert caplog.records == []
monkeypatch.setenv("LITELLM_QA_SHADOW_PRIVACY", str(privacy).lower())
loaded: Final = await config.get_config(str(config_file))
assert loaded["general_settings"]["max_parallel_requests"] == 5
assert len(caplog.records) == 1
assert caplog.records[0].levelno == logging.WARNING
assert ("max_parallel_requests" in caplog.records[0].getMessage()) is not privacy
caplog.clear()
await config.get_config(str(config_file))
assert caplog.records == []

View file

@ -12519,6 +12519,218 @@ 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)
@pytest.mark.asyncio
@pytest.mark.parametrize("privacy", [False, True])
@pytest.mark.parametrize("http_error", [False, True])
async def test_stream_failure_retains_error_status_without_debug_logging(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, privacy: bool, http_error: 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.ERROR)
ProxyLogging._callback_capabilities_cache.clear()
failure: Final = (
HTTPException(status_code=429, detail="PRIVATE_STREAM_FAILURE_42896")
if http_error
else RuntimeError("PRIVATE_STREAM_FAILURE_42896")
)
async def upstream() -> AsyncGenerator[str, None]:
yield "data: started\n\n"
raise failure
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=str,
serialize_error=str,
)
assert await anext(stream) == "data: started\n\n"
if http_error:
with pytest.raises(HTTPException) as raised:
await anext(stream)
assert raised.value is failure
else:
remaining: Final = tuple([chunk async for chunk in stream])
assert len(remaining) == 1
assert "PRIVATE_STREAM_FAILURE_42896" in remaining[0]
messages: Final = tuple(record.getMessage() for record in caplog.records if record.name == verbose_proxy_logger.name)
if privacy:
assert any(f"event=request_failed status={429 if http_error else 500}" in message for message in messages)
assert all("PRIVATE_STREAM_FAILURE_42896" not in message for message in messages)
else:
assert any("PRIVATE_STREAM_FAILURE_42896" in message for message in messages)
assert all("event=request_failed" not in message for message in messages)
@pytest.mark.asyncio
@pytest.mark.parametrize("privacy", [False, True])
async def test_nonstream_response_keeps_content_with_private_ready_diagnostic(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, privacy: bool
) -> None:
import logging
import respx
from litellm._logging import verbose_proxy_logger
from litellm.proxy.proxy_server import ProxyConfig as ServerProxyConfig
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", privacy)
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setattr(litellm, "success_callback", [])
monkeypatch.setattr(litellm, "_async_success_callback", [])
monkeypatch.setattr(verbose_proxy_logger, "level", logging.DEBUG)
ProxyLogging._callback_capabilities_cache.clear()
content: Final = "PRIVATE_NONSTREAM_RESPONSE_42896"
processor: Final = ProxyBaseLLMRequestProcessing(
data={
"model": "openai/gpt-4o-mini",
"api_base": "https://example.com/v1",
"api_key": "test-key",
"messages": [{"role": "user", "content": "PRIVATE_NONSTREAM_INPUT_42896"}],
}
)
with respx.mock() as upstream:
upstream.post("https://example.com/v1/chat/completions").respond(
200,
json={
"id": "chatcmpl-example",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
},
)
response: Final = await processor.base_process_llm_request(
request=Request(
{"type": "http", "method": "POST", "path": "/v1/chat/completions", "headers": [], "query_string": b""}
),
fastapi_response=Response(),
user_api_key_dict=ProxyUserAPIKeyAuth(),
route_type="acompletion",
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
general_settings={},
proxy_config=ServerProxyConfig(),
)
assert isinstance(response, litellm.ModelResponse)
assert isinstance(response.choices[0], litellm.Choices)
assert response.choices[0].message.content == content
assert response.choices[0].finish_reason == "stop"
messages: Final = tuple(
record.getMessage() for record in caplog.records if record.name == verbose_proxy_logger.name
)
if privacy:
assert any("event=response_ready status=200 elapsed_seconds=" in message for message in messages)
assert all("PRIVATE_NONSTREAM_" not in message for message in messages)
else:
assert all("event=response_ready" not in message for message in messages)
@pytest.mark.asyncio
@pytest.mark.parametrize("privacy", [False, True])
async def test_nonstream_failure_preserves_client_error_and_safe_status_without_debug(
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(verbose_proxy_logger, "level", logging.ERROR)
ProxyLogging._callback_capabilities_cache.clear()
processor: Final = ProxyBaseLLMRequestProcessing(data={"model": "example-model"})
failure: Final = HTTPException(status_code=429, detail="PRIVATE_NONSTREAM_FAILURE_42896")
with pytest.raises(ProxyException) as raised:
await processor.handle_llm_api_exception(
e=failure,
user_api_key_dict=ProxyUserAPIKeyAuth(),
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
)
assert raised.value.code == "429"
assert raised.value.message == "PRIVATE_NONSTREAM_FAILURE_42896"
messages: Final = tuple(
record.getMessage() for record in caplog.records if record.name == verbose_proxy_logger.name
)
if privacy:
assert any("event=request_failed status=429" in message for message in messages)
assert all("PRIVATE_NONSTREAM_FAILURE_42896" not in message for message in messages)
else:
assert any("PRIVATE_NONSTREAM_FAILURE_42896" in message for message in messages)
assert all("event=request_failed" not in message for message in messages)
_DOWNSTREAM_GUARDRAIL_FIELDS: Final = MappingProxyType(
{"guardrail_name": "block-secret-project", "guardrail_mode": "pre_call", "pattern": "secret_project_codename"}
)

View file

@ -0,0 +1,26 @@
from typing import Final
import pytest
import litellm
from litellm.proxy.post_call_rules import post_response_rule
@pytest.mark.parametrize("redact", (False, True))
@pytest.mark.parametrize("long_response", (False, True))
def test_diagnostic_privacy_preserves_response_rule_decision(
redact: bool,
long_response: bool,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
marker: Final = "PRIVATE_RULE_INPUT"
response: Final = marker * (20 if long_response else 1)
expected: Final = (
{"decision": True}
if long_response
else {"decision": False, "message": "This violates LiteLLM Proxy Rules. Response too short"}
)
assert post_response_rule(response) == expected
assert (marker in capsys.readouterr().out) is not redact

View file

@ -4072,6 +4072,70 @@ async def test_model_list_return_wildcard_routes_defaults_to_general_setting(
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)
@pytest.mark.parametrize("as_json", [False, True])
def test_worker_config_invalid_privacy_setting_does_not_expose_content(
monkeypatch: pytest.MonkeyPatch, as_json: 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": "invalid-boolean"},
"model_list": [{"litellm_params": {"messages": [{"content": "PRIVATE_SYSTEM_PROMPT"}]}}],
}
original: Final = json.dumps(config)
result: Final = _redact_worker_config_for_logging(original if as_json else config)
assert result == "redacted-by-litellm"
assert json.dumps(config) == original
assert litellm.redact_messages_in_debug_logs is False
@pytest.mark.parametrize("worker_config", ['{"messages": "PRIVATE_SYSTEM_PROMPT"', None])
@pytest.mark.parametrize("enabled", [False, True])
def test_worker_config_global_privacy_covers_unparseable_config(
monkeypatch: pytest.MonkeyPatch, worker_config: str | None, enabled: bool
) -> None:
from litellm.proxy.proxy_server import _redact_worker_config_for_logging
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", enabled)
assert _redact_worker_config_for_logging(worker_config) == ("redacted-by-litellm" if enabled else worker_config)
@pytest.mark.asyncio
async def test_queue_request_labels_an_upstream_failure_a_server_error_not_an_auth_error(
monkeypatch: pytest.MonkeyPatch,

View file

@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from mcp.types import CallToolResult, TextContent
import litellm # noqa: F401 - ensures litellm.responses.main is registered in sys.modules
import litellm
from litellm.responses.mcp.mcp_streaming_iterator import (
MCPEnhancedStreamingIterator,
)
@ -293,14 +293,18 @@ async def test_continuation_id_is_final_round_not_interim_tool_call(monkeypatch)
assert completed[-1].response.id != "resp-interim"
@pytest.mark.parametrize("redact", (False, True))
@pytest.mark.asyncio
async def test_follow_up_call_failure_emits_terminal_error_event(monkeypatch):
async def test_follow_up_call_failure_emits_terminal_error_event(
monkeypatch: pytest.MonkeyPatch, redact: bool, capsys: pytest.CaptureFixture[str]
) -> None:
"""
Regression test for the silent-swallow path: when the follow-up LLM call
raises, the stream must emit a terminal `error` event instead of ending
silently after the tool events (which surfaced to clients as a successful
but empty completion).
"""
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
_mock_mcp_environment(monkeypatch)
aresponses_mock = AsyncMock(side_effect=RuntimeError("boom from provider"))
@ -319,16 +323,21 @@ async def test_follow_up_call_failure_emits_terminal_error_event(monkeypatch):
assert len(error_events) == 1
assert error_events[0].error.type == "mcp_gateway_error"
assert "boom from provider" in error_events[0].error.message
assert ("boom from provider" in capsys.readouterr().err) is not redact
@pytest.mark.parametrize("redact", (False, True))
@pytest.mark.asyncio
async def test_initial_call_failure_is_stashed_for_eager_reraise(monkeypatch):
async def test_initial_call_failure_is_stashed_for_eager_reraise(
monkeypatch: pytest.MonkeyPatch, redact: bool, capsys: pytest.CaptureFixture[str]
) -> None:
"""
Regression test: a failing initial LLM call must be stashed as
`_initial_creation_error` so `aresponses_api_with_mcp` can re-raise it as a
real 4xx before any SSE bytes are written, instead of returning HTTP 200
with an empty stream.
"""
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
_mock_mcp_environment(monkeypatch)
aresponses_mock = AsyncMock(side_effect=RuntimeError("initial boom"))
@ -339,6 +348,7 @@ async def test_initial_call_failure_is_stashed_for_eager_reraise(monkeypatch):
assert iterator._initial_creation_error is not None
assert "initial boom" in str(iterator._initial_creation_error)
assert ("initial boom" in capsys.readouterr().err) is not redact
def _reasoning_item(encrypted_content: str):
@ -581,3 +591,40 @@ async def test_stream_without_auto_execute_is_forwarded_unchanged(monkeypatch):
assert [c.output_index for c in chunks if hasattr(c, "output_index")] == [0, 0]
assert [c.sequence_number for c in chunks if hasattr(c, "sequence_number")] == [0, 1, 2]
aresponses_mock.assert_not_called()
@pytest.mark.parametrize("redact", (False, True))
@pytest.mark.asyncio
async def test_malformed_provider_tool_arguments_keep_error_diagnostics_private(
monkeypatch: pytest.MonkeyPatch, redact: bool, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact)
response: Final = ResponsesAPIResponse(
id="resp-malformed-arguments",
created_at=0,
output=[
{
"type": "function_call",
"call_id": "call-1",
"name": "read_wiki_contents",
"arguments": {"text": "PRIVATE_TOOL_ARGUMENT"},
}
],
)
iterator: Final = _make_iterator([])
iterator.collected_response = response
await iterator._generate_tool_execution_events()
assert iterator.tool_results == []
assert iterator._tool_results_for_response is response
assert iterator.collected_response.output == [
{
"type": "function_call",
"call_id": "call-1",
"name": "read_wiki_contents",
"arguments": {"text": "PRIVATE_TOOL_ARGUMENT"},
}
]
assert [event.type for event in iterator.tool_execution_events] == [ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED]
assert ("PRIVATE_TOOL_ARGUMENT" in capsys.readouterr().err) is not redact

View file

@ -132,3 +132,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

@ -1841,6 +1841,36 @@ def test_print_verbose_formats_args_only_when_set_verbose_is_on(monkeypatch, cap
assert capsys.readouterr().out == "key k1 value cached-vector\n"
@pytest.mark.parametrize("privacy", [False, True])
@pytest.mark.parametrize("json_logs", [False, True])
@pytest.mark.parametrize("warning", [False, True])
@pytest.mark.parametrize("suppressed", [False, True])
def test_startup_message_respects_diagnostic_privacy(
monkeypatch: pytest.MonkeyPatch,
capfd: pytest.CaptureFixture[str],
privacy: bool,
json_logs: bool,
warning: bool,
suppressed: bool,
) -> None:
from litellm._logging import print_startup_warning
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", privacy)
monkeypatch.setattr(litellm, "json_logs", json_logs)
monkeypatch.setattr(litellm, "suppress_startup_output", suppressed)
emit: Final = print_startup_warning if warning else print_startup_message
emit("PRIVATE_CONFIGURED_MODEL_42896")
out, err = capfd.readouterr()
assert err == ""
if suppressed and not warning:
assert out == ""
return
message: Final = json.loads(out)["message"] if json_logs else out.strip()
assert message == ("redacted-by-litellm" if privacy else "PRIVATE_CONFIGURED_MODEL_42896")
if json_logs:
assert json.loads(out)["level"] == ("WARNING" if warning else "INFO")
class _CapturedStream(StringIO):
def __init__(self, tty: bool) -> None:
super().__init__()
@ -2118,3 +2148,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

@ -10809,3 +10809,39 @@ async def test_bedrock_strict_tools_limit_rejection_is_not_sent_again_when_no_to
assert error is not None and "Too many strict tools" in str(error)
assert len(sent) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("privacy", [False, True])
async def test_async_streaming_keeps_chunks_without_rendering_private_content(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], privacy: bool
) -> None:
from collections.abc import AsyncIterator
class Chunk:
content: Final = "PRIVATE_ASYNC_STREAM_42896"
def __str__(self) -> str:
if privacy:
raise AssertionError("private chunks must not be rendered for diagnostics")
return self.content
monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", privacy)
monkeypatch.setattr(litellm, "set_verbose", True)
chunk: Final = Chunk()
async def upstream() -> AsyncIterator[Chunk]:
yield chunk
stream: Final = litellm_main._async_streaming(upstream(), "example-model", "openai", {})
observed: Final = tuple([item async for item in stream])
assert len(observed) == 1
assert observed[0] is chunk
assert chunk.content == "PRIVATE_ASYNC_STREAM_42896"
output: Final = capsys.readouterr().out
assert ("PRIVATE_ASYNC_STREAM_42896" in output) is not privacy
if privacy:
assert output.strip() == "redacted-by-litellm"
else:
assert "line in async streaming: PRIVATE_ASYNC_STREAM_42896" in output

View file

@ -11294,6 +11294,39 @@ 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")
@pytest.mark.parametrize(
("provider", "expected_warnings"),