mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Route records below WARNING to stdout (WARNING and above stay on stderr), emit ANSI color codes only when both streams are a TTY (honoring NO_COLOR), and parse JSON_LOGS strictly so JSON_LOGS=false no longer enables JSON logs.
970 lines
34 KiB
Python
970 lines
34 KiB
Python
import ast
|
|
import asyncio
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
import pytest
|
|
|
|
import logging
|
|
|
|
import litellm
|
|
from litellm._logging import (
|
|
_COLOR_LOG_FORMAT,
|
|
_PLAIN_LOG_FORMAT,
|
|
ALL_LOGGERS,
|
|
CorrelationContextFilter,
|
|
CorrelationPlainFormatter,
|
|
JsonFormatter,
|
|
LevelRoutingStreamHandler,
|
|
SecretRedactionFilter,
|
|
StdoutLogTruncationFilter,
|
|
_initialize_loggers_with_handler,
|
|
_parse_json_logs_env,
|
|
_plain_log_format,
|
|
_stdout_truncation_marker,
|
|
_turn_on_json,
|
|
session_id_var,
|
|
set_session_id,
|
|
set_trace_id,
|
|
trace_id_var,
|
|
verbose_logger,
|
|
verbose_proxy_logger,
|
|
verbose_router_logger,
|
|
)
|
|
from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.types.utils import StandardLoggingPayload
|
|
|
|
|
|
class CacheHitCustomLogger(CustomLogger):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.logged_standard_logging_payloads: List[StandardLoggingPayload] = []
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
standard_logging_payload = kwargs.get("standard_logging_object", None)
|
|
if standard_logging_payload:
|
|
self.logged_standard_logging_payloads.append(standard_logging_payload)
|
|
|
|
|
|
def test_json_mode_emits_one_record_per_logger(capfd):
|
|
# Turn on JSON logging
|
|
_turn_on_json()
|
|
# Make sure our loggers will emit INFO-level records
|
|
for lg in (verbose_logger, verbose_router_logger, verbose_proxy_logger):
|
|
lg.setLevel(logging.INFO)
|
|
|
|
# Log one message from each logger at different levels
|
|
verbose_logger.info("first info")
|
|
verbose_router_logger.info("second info from router")
|
|
verbose_proxy_logger.info("third info from proxy")
|
|
|
|
# All three records are INFO, so they must route to stdout and none to stderr
|
|
out, err = capfd.readouterr()
|
|
assert [raw for raw in err.splitlines() if raw.strip()] == []
|
|
lines = [raw for raw in out.splitlines() if raw.strip()]
|
|
|
|
# Expect exactly three JSON lines
|
|
assert len(lines) == 3, f"got {len(lines)} lines, want 3: {lines!r}"
|
|
|
|
# Each line must be valid JSON with the required fields
|
|
for line in lines:
|
|
obj = json.loads(line)
|
|
assert "message" in obj, "`message` key missing"
|
|
assert "level" in obj, "`level` key missing"
|
|
assert "timestamp" in obj, "`timestamp` key missing"
|
|
|
|
|
|
def test_json_formatter_parses_embedded_json_message():
|
|
"""
|
|
Test that JsonFormatter parses embedded JSON in the message field and promotes
|
|
sub-fields to first-class JSON properties for downstream querying.
|
|
"""
|
|
formatter = JsonFormatter()
|
|
record = logging.LogRecord(
|
|
name="LiteLLM",
|
|
level=logging.DEBUG,
|
|
pathname="",
|
|
lineno=0,
|
|
msg='{"event": "giveup", "exception": "Connection failed", "model_name": "gpt-4"}',
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
output = formatter.format(record)
|
|
obj = json.loads(output)
|
|
# Standard fields preserved
|
|
assert "message" in obj
|
|
assert obj["level"] == "DEBUG"
|
|
assert "timestamp" in obj
|
|
# Embedded JSON fields promoted to top-level for querying
|
|
assert obj["event"] == "giveup"
|
|
assert obj["exception"] == "Connection failed"
|
|
assert obj["model_name"] == "gpt-4"
|
|
|
|
|
|
def test_json_formatter_includes_extra_attributes():
|
|
"""
|
|
Test that JsonFormatter includes extra attributes from logger.debug("msg", extra={...}).
|
|
"""
|
|
formatter = JsonFormatter()
|
|
record = logging.LogRecord(
|
|
name="LiteLLM",
|
|
level=logging.DEBUG,
|
|
pathname="",
|
|
lineno=0,
|
|
msg="POST Request Sent from LiteLLM",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
record.api_base = "https://api.openai.com"
|
|
record.authorization = "Bearer sk-***"
|
|
output = formatter.format(record)
|
|
obj = json.loads(output)
|
|
assert obj["message"] == "POST Request Sent from LiteLLM"
|
|
assert obj["api_base"] == "https://api.openai.com"
|
|
assert obj["authorization"] == "Bearer sk-***"
|
|
|
|
|
|
def test_json_formatter_plain_message_unchanged():
|
|
"""
|
|
Test that non-JSON messages are passed through as-is in the message field.
|
|
"""
|
|
formatter = JsonFormatter()
|
|
record = logging.LogRecord(
|
|
name="LiteLLM",
|
|
level=logging.INFO,
|
|
pathname="",
|
|
lineno=0,
|
|
msg="Cache hit!",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
output = formatter.format(record)
|
|
obj = json.loads(output)
|
|
assert obj["message"] == "Cache hit!"
|
|
assert "event" not in obj
|
|
assert "exception" not in obj
|
|
|
|
|
|
def test_json_formatter_parses_embedded_python_dict_repr():
|
|
"""
|
|
Test that JsonFormatter parses Python dict repr (str/deployment) embedded in
|
|
plain text, e.g. from get_available_deployment logs.
|
|
Reproduces Roni's reported case.
|
|
"""
|
|
formatter = JsonFormatter()
|
|
msg = (
|
|
"get_available_deployment for model: text-embedding-3-large, "
|
|
"Selected deployment: {'model_name': 'text-embedding-3-large', "
|
|
"'litellm_params': {'api_key': 'sk**********', 'tpm': 1000000, 'rpm': 2000, "
|
|
"'use_in_pass_through': False, 'use_litellm_proxy': False, "
|
|
"'merge_reasoning_content_in_choices': False, 'model': 'text-embedding-3-large'}, "
|
|
"'model_info': {'id': 'a624b057aec64ada48311', 'db_model': False}} "
|
|
"for model: text-embedding-3-large"
|
|
)
|
|
record = logging.LogRecord(
|
|
name="LiteLLM Router",
|
|
level=logging.INFO,
|
|
pathname="",
|
|
lineno=0,
|
|
msg=msg,
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
output = formatter.format(record)
|
|
obj = json.loads(output)
|
|
assert "message" in obj
|
|
assert obj["level"] == "INFO"
|
|
# Python dict parsed and promoted to first-class properties
|
|
assert obj["model_name"] == "text-embedding-3-large"
|
|
assert "litellm_params" in obj
|
|
# Redacted, not passed through: SecretRedactionFilter already collapses this
|
|
# pair in the plain path before any formatter sees it, so the JSON path matching
|
|
# it is production parity. The key survives because redaction is per-value here.
|
|
assert obj["litellm_params"]["api_key"] == "REDACTED"
|
|
assert obj["litellm_params"]["tpm"] == 1000000
|
|
assert obj["litellm_params"]["use_in_pass_through"] is False
|
|
assert "model_info" in obj
|
|
assert obj["model_info"]["id"] == "a624b057aec64ada48311"
|
|
assert obj["model_info"]["db_model"] is False
|
|
|
|
|
|
def test_json_formatter_output_stays_parseable_when_a_secret_is_redacted():
|
|
"""Redaction must collapse the value only, never the surrounding JSON member.
|
|
|
|
Redacting the serialized document turned '"api_key": "sk-..."' into a bare
|
|
REDACTED token, so the line stopped being valid JSON entirely.
|
|
"""
|
|
formatter = JsonFormatter()
|
|
record = logging.LogRecord(
|
|
name="LiteLLM",
|
|
level=logging.INFO,
|
|
pathname="",
|
|
lineno=0,
|
|
msg="calling deployment",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
record.deployment = {
|
|
"api_key": "sk-abcdefghijklmnopqrstuvwxyz0123456789",
|
|
"aws_secret_access_key": "wJalrXUtnFEMIQAfakeKEYbPxRfiCYEXAMPLEKEY",
|
|
"aws_region_name": "us-east-1",
|
|
"nested": {"tokens": ["Bearer abcdefghijklmnop", "keep-me"]},
|
|
}
|
|
|
|
obj = json.loads(formatter.format(record))
|
|
|
|
assert obj["deployment"]["api_key"] == "REDACTED"
|
|
assert obj["deployment"]["aws_secret_access_key"] == "REDACTED"
|
|
# Non-secret siblings stay legible so the logs remain useful
|
|
assert obj["deployment"]["aws_region_name"] == "us-east-1"
|
|
assert obj["deployment"]["nested"]["tokens"] == ["REDACTED", "keep-me"]
|
|
|
|
|
|
def test_json_formatter_includes_component_field():
|
|
"""
|
|
Test that JsonFormatter always emits a 'component' field equal to the logger name.
|
|
This allows filtering by component (e.g. "LiteLLM Proxy") in Datadog / third-party log services.
|
|
"""
|
|
formatter = JsonFormatter()
|
|
for logger_name in ("LiteLLM Proxy", "LiteLLM Router", "LiteLLM"):
|
|
record = logging.LogRecord(
|
|
name=logger_name,
|
|
level=logging.ERROR,
|
|
pathname="proxy_server.py",
|
|
lineno=42,
|
|
msg="something went wrong",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
output = formatter.format(record)
|
|
obj = json.loads(output)
|
|
assert obj["component"] == logger_name, f"Expected component={logger_name!r}, got {obj.get('component')!r}"
|
|
|
|
|
|
def test_json_formatter_includes_logger_field():
|
|
"""
|
|
Test that JsonFormatter always emits a 'logger' field with filename:lineno.
|
|
This allows pinpointing the exact source of a log line in third-party services.
|
|
"""
|
|
formatter = JsonFormatter()
|
|
record = logging.LogRecord(
|
|
name="LiteLLM Proxy",
|
|
level=logging.INFO,
|
|
pathname="/app/litellm/proxy/proxy_server.py",
|
|
lineno=123,
|
|
msg="request received",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
output = formatter.format(record)
|
|
obj = json.loads(output)
|
|
assert obj["logger"] == "proxy_server.py:123", f"Expected logger='proxy_server.py:123', got {obj['logger']!r}"
|
|
|
|
|
|
def test_json_formatter_extra_component_not_overwritten():
|
|
"""
|
|
User-supplied extra={"component": "..."} must not be silently dropped.
|
|
"""
|
|
formatter = JsonFormatter()
|
|
record = logging.LogRecord(
|
|
name="LiteLLM Proxy",
|
|
level=logging.INFO,
|
|
pathname="proxy_server.py",
|
|
lineno=1,
|
|
msg="event",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
record.component = "auth-service"
|
|
obj = json.loads(formatter.format(record))
|
|
assert obj["component"] == "auth-service", f"User-supplied component was overwritten, got {obj['component']!r}"
|
|
|
|
|
|
def test_initialize_loggers_with_handler_sets_propagate_false():
|
|
"""
|
|
Test that the initialize_loggers_with_handler function sets propagate to False for all loggers
|
|
"""
|
|
# Initialize loggers with the test handler
|
|
_initialize_loggers_with_handler(logging.StreamHandler())
|
|
|
|
# Check that propagate is set to False for all loggers
|
|
for logger in ALL_LOGGERS:
|
|
assert logger.propagate is False, (
|
|
f"Logger {logger.name} has propagate set to {logger.propagate}, expected False"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_hit_includes_custom_llm_provider():
|
|
"""
|
|
Test that when there's a cache hit, the standard logging payload includes the custom_llm_provider
|
|
"""
|
|
# Set up caching and custom logger
|
|
litellm.cache = litellm.Cache()
|
|
test_custom_logger = CacheHitCustomLogger()
|
|
original_callbacks = litellm.callbacks.copy() if litellm.callbacks else []
|
|
litellm.callbacks = [test_custom_logger]
|
|
|
|
try:
|
|
# First call - should be a cache miss
|
|
response1 = await litellm.acompletion(
|
|
model="gpt-3.5-turbo",
|
|
messages=[{"role": "user", "content": "test cache hit message"}],
|
|
mock_response="test response",
|
|
caching=True,
|
|
)
|
|
|
|
# Wait for logging to complete
|
|
await asyncio.sleep(0.5)
|
|
|
|
# Second identical call - should be a cache hit
|
|
response2 = await litellm.acompletion(
|
|
model="gpt-3.5-turbo",
|
|
messages=[{"role": "user", "content": "test cache hit message"}],
|
|
mock_response="test response",
|
|
caching=True,
|
|
)
|
|
|
|
# Wait for logging to complete
|
|
await asyncio.sleep(0.5)
|
|
|
|
# Verify we have logged events
|
|
assert len(test_custom_logger.logged_standard_logging_payloads) >= 2, (
|
|
f"Expected at least 2 logged events, got {len(test_custom_logger.logged_standard_logging_payloads)}"
|
|
)
|
|
|
|
# Find the cache hit event (should be the second call)
|
|
cache_hit_payload = None
|
|
for payload in test_custom_logger.logged_standard_logging_payloads:
|
|
if payload.get("cache_hit") is True:
|
|
cache_hit_payload = payload
|
|
break
|
|
|
|
# Verify cache hit event was found
|
|
assert cache_hit_payload is not None, "No cache hit event found in logged payloads"
|
|
|
|
# Verify custom_llm_provider is included in the cache hit payload
|
|
assert "custom_llm_provider" in cache_hit_payload, (
|
|
"custom_llm_provider missing from cache hit standard logging payload"
|
|
)
|
|
|
|
# Verify custom_llm_provider has a valid value (should be "openai" for gpt-3.5-turbo)
|
|
custom_llm_provider = cache_hit_payload["custom_llm_provider"]
|
|
assert custom_llm_provider is not None and custom_llm_provider != "", (
|
|
f"custom_llm_provider should not be None or empty, got: {custom_llm_provider}"
|
|
)
|
|
|
|
print(
|
|
f"Cache hit standard logging payload with custom_llm_provider: {custom_llm_provider}",
|
|
json.dumps(cache_hit_payload, indent=2),
|
|
)
|
|
|
|
finally:
|
|
# Clean up
|
|
litellm.callbacks = original_callbacks
|
|
litellm.cache = None
|
|
|
|
|
|
LITELLM_LOGGER_NAMES = frozenset(
|
|
{"verbose_logger", "verbose_proxy_logger", "verbose_router_logger", "logger", "logging"}
|
|
)
|
|
LOG_LEVEL_METHODS = frozenset({"debug", "info", "warning", "error", "exception", "critical"})
|
|
LITELLM_PACKAGE_ROOT = Path(__file__).resolve().parents[2] / "litellm"
|
|
|
|
|
|
def _receiver_name(node: ast.expr) -> str:
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
if isinstance(node, ast.Attribute):
|
|
return node.attr
|
|
return ""
|
|
|
|
|
|
def _is_logging_call(node: ast.AST) -> bool:
|
|
return (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr in LOG_LEVEL_METHODS
|
|
and _receiver_name(node.func.value) in LITELLM_LOGGER_NAMES
|
|
)
|
|
|
|
|
|
def _has_format_spec(message: ast.JoinedStr) -> bool:
|
|
return any(isinstance(value, ast.FormattedValue) and value.format_spec is not None for value in message.values)
|
|
|
|
|
|
def _eager_logging_calls(source: str, path: Path) -> tuple[str, ...]:
|
|
return tuple(
|
|
f"{path}:{node.lineno}"
|
|
for node in ast.walk(ast.parse(source))
|
|
if _is_logging_call(node)
|
|
and node.args
|
|
and isinstance(node.args[0], ast.JoinedStr)
|
|
and not _has_format_spec(node.args[0])
|
|
)
|
|
|
|
|
|
def test_logging_calls_do_not_build_their_message_eagerly():
|
|
"""A discarded log record must not have cost anything to build.
|
|
|
|
`log.debug(f"payload: {body}")` interpolates before the call runs, so the message is
|
|
built and thrown away on every request the level filters out; `log.debug("payload: %s", body)`
|
|
defers that to `record.getMessage()`, which only runs once the record passes the level check.
|
|
|
|
f-strings carrying a format spec are exempt: `%`-style has no faithful equivalent for
|
|
specs like `{ratio:.1%}`, and those sites interpolate scalars rather than payloads.
|
|
"""
|
|
offenders = tuple(
|
|
offender
|
|
for path in sorted(LITELLM_PACKAGE_ROOT.rglob("*.py"))
|
|
for offender in _eager_logging_calls(
|
|
path.read_text(encoding="utf-8"), path.relative_to(LITELLM_PACKAGE_ROOT.parent)
|
|
)
|
|
)
|
|
|
|
assert offenders == (), (
|
|
"these logging calls build their message eagerly; pass the values as %-style arguments instead:\n"
|
|
+ "\n".join(offenders)
|
|
)
|
|
|
|
|
|
class _JsonCapture(logging.Handler):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.formatter = JsonFormatter()
|
|
self.records: list[dict] = []
|
|
self.addFilter(CorrelationContextFilter())
|
|
|
|
def emit(self, record):
|
|
self.records.append(json.loads(self.formatter.format(record)))
|
|
|
|
|
|
def _make_capture_logger(name: str) -> tuple[logging.Logger, _JsonCapture]:
|
|
lg = logging.getLogger(name)
|
|
cap = _JsonCapture()
|
|
lg.addHandler(cap)
|
|
lg.setLevel(logging.DEBUG)
|
|
return lg, cap
|
|
|
|
|
|
def test_trace_id_injected_into_json_record(monkeypatch):
|
|
"""trace_id set via set_trace_id() appears in every JSON record in that context."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
|
lg, cap = _make_capture_logger("test.trace_inject")
|
|
set_trace_id("trace-abc-123")
|
|
try:
|
|
lg.info("test message")
|
|
assert len(cap.records) == 1
|
|
assert cap.records[0]["trace_id"] == "trace-abc-123"
|
|
finally:
|
|
trace_id_var.set("")
|
|
|
|
|
|
def test_session_id_injected_when_set(monkeypatch):
|
|
"""session_id set via set_session_id() appears in JSON record."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
|
lg, cap = _make_capture_logger("test.session_inject")
|
|
set_session_id("sess-xyz-456")
|
|
try:
|
|
lg.info("another message")
|
|
assert cap.records[0]["session_id"] == "sess-xyz-456"
|
|
finally:
|
|
session_id_var.set("")
|
|
|
|
|
|
def test_trace_id_and_session_id_cannot_be_spoofed_by_message_content(monkeypatch):
|
|
"""A log message that happens to parse as JSON/dict with "trace_id"/"session_id"
|
|
keys (e.g. the proxy logging a raw request-header dict) must not override the
|
|
real correlation ids set via set_trace_id()/set_session_id()."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
|
lg, cap = _make_capture_logger("test.spoof_attempt")
|
|
set_trace_id("real-trace-id")
|
|
set_session_id("real-session-id")
|
|
try:
|
|
lg.info('{"trace_id": "attacker-supplied-trace", "session_id": "attacker-supplied-session"}')
|
|
assert cap.records[0]["trace_id"] == "real-trace-id"
|
|
assert cap.records[0]["session_id"] == "real-session-id"
|
|
finally:
|
|
trace_id_var.set("")
|
|
session_id_var.set("")
|
|
|
|
|
|
def test_trace_id_and_session_id_cannot_be_injected_with_no_active_context(monkeypatch):
|
|
"""A message that happens to parse as JSON/dict with "trace_id"/"session_id" keys
|
|
must not surface those fields at all when CorrelationContextFilter hasn't stamped
|
|
this record - e.g. a log line emitted before Logging.__init__() runs for a request
|
|
(request_correlation_in_logs on, but no genuine trace/session id active yet)."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
|
lg, cap = _make_capture_logger("test.no_context_spoof_attempt")
|
|
trace_id_var.set("")
|
|
session_id_var.set("")
|
|
lg.info('{"trace_id": "attacker-supplied-trace", "session_id": "attacker-supplied-session"}')
|
|
assert "trace_id" not in cap.records[0]
|
|
assert "session_id" not in cap.records[0]
|
|
|
|
|
|
def test_trace_id_and_session_id_are_redacted_when_credential_shaped(monkeypatch):
|
|
"""A caller-controlled trace_id/session_id (e.g. from x-litellm-trace-id or a W3C
|
|
baggage header) that happens to look like a real credential must not reach log
|
|
records unredacted. CorrelationContextFilter stamps trace_id/session_id onto the
|
|
record after SecretRedactionFilter has already run, so those two fields would
|
|
otherwise bypass credential redaction entirely - the fix redacts at set_trace_id()/
|
|
set_session_id() time instead, before the value ever reaches a log record."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
|
lg, cap = _make_capture_logger("test.credential_shaped_correlation_id")
|
|
poisoned_trace_id = "sk-ant-api03-" + "A" * 40
|
|
poisoned_session_id = "AKIA" + "B" * 16
|
|
set_trace_id(poisoned_trace_id)
|
|
set_session_id(poisoned_session_id)
|
|
try:
|
|
lg.info("some benign log line")
|
|
assert cap.records[0]["trace_id"] == "REDACTED"
|
|
assert cap.records[0]["session_id"] == "REDACTED"
|
|
assert poisoned_trace_id not in json.dumps(cap.records[0])
|
|
assert poisoned_session_id not in json.dumps(cap.records[0])
|
|
finally:
|
|
trace_id_var.set("")
|
|
session_id_var.set("")
|
|
|
|
|
|
def test_session_id_absent_when_not_set():
|
|
"""session_id must NOT appear in JSON record when not set for this context."""
|
|
lg, cap = _make_capture_logger("test.no_session")
|
|
session_id_var.set("")
|
|
lg.info("no session message")
|
|
assert "session_id" not in cap.records[0]
|
|
|
|
|
|
def test_trace_id_absent_when_not_set():
|
|
"""trace_id must NOT appear when not set."""
|
|
lg, cap = _make_capture_logger("test.no_trace")
|
|
trace_id_var.set("")
|
|
lg.info("no trace message")
|
|
assert "trace_id" not in cap.records[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_contextvar_isolation_between_tasks():
|
|
"""Two concurrent async tasks each see only their own trace_id."""
|
|
results: dict[str, str] = {}
|
|
|
|
async def task(task_id: str, trace_id: str) -> None:
|
|
set_trace_id(trace_id)
|
|
await asyncio.sleep(0)
|
|
results[task_id] = trace_id_var.get()
|
|
|
|
await asyncio.gather(
|
|
task("A", "trace-for-A"),
|
|
task("B", "trace-for-B"),
|
|
)
|
|
|
|
assert results["A"] == "trace-for-A"
|
|
assert results["B"] == "trace-for-B"
|
|
|
|
|
|
def test_trace_id_not_in_log_when_flag_disabled(monkeypatch):
|
|
"""When request_correlation_in_logs is False (default), trace_id must not appear in JSON records even when set."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", False)
|
|
lg, cap = _make_capture_logger("test.no_trace_gated")
|
|
set_trace_id("trace-should-not-appear")
|
|
try:
|
|
lg.info("message")
|
|
assert "trace_id" not in cap.records[0]
|
|
finally:
|
|
trace_id_var.set("")
|
|
|
|
|
|
def test_session_id_not_in_log_when_flag_disabled(monkeypatch):
|
|
"""When request_correlation_in_logs is False (default), session_id must not appear in JSON records even when set."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", False)
|
|
lg, cap = _make_capture_logger("test.no_session_gated")
|
|
set_session_id("sess-should-not-appear")
|
|
try:
|
|
lg.info("message")
|
|
assert "session_id" not in cap.records[0]
|
|
finally:
|
|
session_id_var.set("")
|
|
|
|
|
|
class _PlainCapture(logging.Handler):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.formatter = CorrelationPlainFormatter("%(message)s")
|
|
self.records: list[str] = []
|
|
self.addFilter(CorrelationContextFilter())
|
|
|
|
def emit(self, record):
|
|
self.records.append(self.formatter.format(record))
|
|
|
|
|
|
def _make_plain_capture_logger(name: str) -> tuple[logging.Logger, _PlainCapture]:
|
|
lg = logging.getLogger(name)
|
|
cap = _PlainCapture()
|
|
lg.addHandler(cap)
|
|
lg.setLevel(logging.DEBUG)
|
|
return lg, cap
|
|
|
|
|
|
def test_plain_formatter_appends_trace_id_and_session_id(monkeypatch):
|
|
"""CorrelationPlainFormatter must append trace_id/session_id to non-JSON log lines too."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
|
lg, cap = _make_plain_capture_logger("test.plain_trace_session")
|
|
set_trace_id("plain-trace-1")
|
|
set_session_id("plain-session-1")
|
|
try:
|
|
lg.info("plaintext message")
|
|
assert cap.records[0] == "plaintext message [trace_id=plain-trace-1 session_id=plain-session-1]"
|
|
finally:
|
|
trace_id_var.set("")
|
|
session_id_var.set("")
|
|
|
|
|
|
def test_plain_formatter_appends_only_trace_id_when_session_id_absent(monkeypatch):
|
|
"""Only trace_id is appended when session_id was never set."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
|
lg, cap = _make_plain_capture_logger("test.plain_trace_only")
|
|
set_trace_id("plain-trace-2")
|
|
session_id_var.set("")
|
|
try:
|
|
lg.info("plaintext message")
|
|
assert cap.records[0] == "plaintext message [trace_id=plain-trace-2]"
|
|
finally:
|
|
trace_id_var.set("")
|
|
|
|
|
|
def test_plain_formatter_unchanged_when_flag_disabled(monkeypatch):
|
|
"""When request_correlation_in_logs is False, plain log lines are unmodified even if the contextvars are set."""
|
|
monkeypatch.setattr(litellm, "request_correlation_in_logs", False)
|
|
lg, cap = _make_plain_capture_logger("test.plain_flag_off")
|
|
set_trace_id("should-not-appear")
|
|
set_session_id("should-not-appear")
|
|
try:
|
|
lg.info("plaintext message")
|
|
assert cap.records[0] == "plaintext message"
|
|
finally:
|
|
trace_id_var.set("")
|
|
session_id_var.set("")
|
|
|
|
|
|
def test_set_trace_id_strips_control_characters():
|
|
"""set_trace_id() must strip \\r/\\n/escape sequences so a caller-controlled
|
|
trace id can't forge fake log entries when interpolated into plain-text logs."""
|
|
token = set_trace_id('evil\r\n{"level": "CRITICAL", "message": "forged"}')
|
|
try:
|
|
value = trace_id_var.get()
|
|
assert "\r" not in value
|
|
assert "\n" not in value
|
|
finally:
|
|
trace_id_var.reset(token)
|
|
|
|
|
|
_MARKER_RE = re.compile(rf"\.\.\. \({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped (\d+) chars\..*?\) \.\.\.", re.S)
|
|
|
|
|
|
def _extract_marker(text: str) -> "re.Match[str] | None":
|
|
return _MARKER_RE.search(text)
|
|
|
|
|
|
def _make_record(level: int, msg: str, args=(), exc_info=None) -> logging.LogRecord:
|
|
return logging.LogRecord(
|
|
name="LiteLLM Router",
|
|
level=level,
|
|
pathname="",
|
|
lineno=0,
|
|
msg=msg,
|
|
args=args,
|
|
exc_info=exc_info,
|
|
)
|
|
|
|
|
|
def test_oversized_info_record_is_truncated(monkeypatch):
|
|
"""An error string echoing a huge request payload must not reach stdout in full."""
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
|
|
payload = "p" * 100_000
|
|
record = _make_record(logging.INFO, "litellm.acompletion(model=%s) Exception %s", ("gpt-4", payload))
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
|
|
message = record.getMessage()
|
|
assert LITELLM_TRUNCATED_PAYLOAD_FIELD in message
|
|
assert len(message) <= 500
|
|
assert message.startswith("litellm.acompletion(model=gpt-4) Exception ppp")
|
|
assert message.endswith("ppp")
|
|
|
|
marker = _extract_marker(message)
|
|
assert marker is not None
|
|
kept, skipped = len(message) - len(marker.group(0)), int(marker.group(1))
|
|
assert kept + skipped == 43 + len(payload)
|
|
|
|
|
|
def test_truncated_message_fits_the_configured_cap(monkeypatch):
|
|
"""The cap is the whole point of the setting, so the marker has to be paid for out of
|
|
the budget instead of appended on top of a limit-sized head and tail."""
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
|
|
record = _make_record(logging.ERROR, "Exception %s", ("p" * 2000,))
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
|
|
message = record.getMessage()
|
|
assert _extract_marker(message) is not None
|
|
assert len(message) == 500
|
|
|
|
|
|
@pytest.mark.parametrize("payload_len", [501, 512, 1000, 9999, 100_000])
|
|
def test_truncated_message_never_exceeds_the_cap(monkeypatch, payload_len):
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
|
|
record = _make_record(logging.ERROR, "%s", ("p" * payload_len,))
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
|
|
assert len(record.getMessage()) <= 500
|
|
|
|
|
|
_NO_BUDGET_PAYLOAD = "p" * 2000
|
|
_MARKER_SIZED_CAP = len(_stdout_truncation_marker(len(_NO_BUDGET_PAYLOAD)))
|
|
|
|
|
|
@pytest.mark.parametrize("cap", [_MARKER_SIZED_CAP, _MARKER_SIZED_CAP - 1, 100])
|
|
def test_cap_leaving_no_room_for_the_marker_still_bounds_output(monkeypatch, cap):
|
|
"""An operator can set the cap at or below the marker's own length, leaving nothing to
|
|
spend on a head and tail, and the output still has to fit."""
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", str(cap))
|
|
record = _make_record(logging.ERROR, "%s", (_NO_BUDGET_PAYLOAD,))
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
|
|
assert len(record.getMessage()) == cap
|
|
|
|
|
|
def test_debug_record_is_not_truncated(monkeypatch):
|
|
"""--detailed_debug exists to dump full payloads, so DEBUG records pass through."""
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
|
|
payload = "p" * 100_000
|
|
record = _make_record(logging.DEBUG, "raw request %s", (payload,))
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
|
|
assert record.getMessage() == f"raw request {payload}"
|
|
|
|
|
|
def test_truncation_disabled_by_zero_limit(monkeypatch):
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "0")
|
|
payload = "p" * 100_000
|
|
record = _make_record(logging.ERROR, "Exception %s", (payload,))
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
|
|
assert record.getMessage() == f"Exception {payload}"
|
|
|
|
|
|
def test_oversized_traceback_is_truncated(monkeypatch):
|
|
"""verbose_proxy_logger.exception() re-logs the payload inside the traceback too."""
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
|
|
try:
|
|
raise ValueError("payload " + "p" * 100_000)
|
|
except ValueError:
|
|
exc_info = sys.exc_info()
|
|
record = _make_record(logging.ERROR, "Exception occured", exc_info=exc_info)
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
|
|
assert record.exc_text is not None
|
|
assert LITELLM_TRUNCATED_PAYLOAD_FIELD in record.exc_text
|
|
assert len(record.exc_text) <= 500
|
|
assert "Traceback (most recent call last)" in record.exc_text
|
|
|
|
|
|
def test_falsy_exc_info_is_not_formatted(monkeypatch):
|
|
"""Callers pass exc_info=False, which logging leaves on the record as a bool."""
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
|
|
record = _make_record(logging.WARNING, "skipping malformed endpoint %s", ("p" * 100_000,), exc_info=False)
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
|
|
assert record.exc_text is None
|
|
assert LITELLM_TRUNCATED_PAYLOAD_FIELD in record.getMessage()
|
|
|
|
|
|
def test_secret_filter_keeps_truncated_traceback(monkeypatch):
|
|
"""SecretRedactionFilter runs after truncation, so it must redact the capped
|
|
traceback instead of reformatting the full one from exc_info."""
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
|
|
try:
|
|
raise ValueError("sk-1234567890abcdefghij payload " + "p" * 100_000)
|
|
except ValueError:
|
|
exc_info = sys.exc_info()
|
|
record = _make_record(logging.ERROR, "Exception occured", exc_info=exc_info)
|
|
|
|
assert StdoutLogTruncationFilter().filter(record) is True
|
|
assert SecretRedactionFilter().filter(record) is True
|
|
|
|
assert record.exc_text is not None
|
|
assert len(record.exc_text) <= 500
|
|
assert "sk-1234567890abcdefghij" not in record.exc_text
|
|
|
|
|
|
def test_truncation_filter_survives_json_reconfiguration():
|
|
"""The cap lives on the loggers, so swapping handlers (JSON mode) can't drop it."""
|
|
_turn_on_json()
|
|
|
|
for lg in (verbose_logger, verbose_router_logger, verbose_proxy_logger):
|
|
assert any(isinstance(f, StdoutLogTruncationFilter) for f in lg.filters), f"{lg.name} lost stdout truncation"
|
|
|
|
|
|
def test_oversized_error_is_truncated_end_to_end(monkeypatch, caplog):
|
|
"""The router's own exception log line must come out bounded, not just the filter in isolation."""
|
|
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
|
|
|
|
with caplog.at_level(logging.INFO, logger="LiteLLM Router"):
|
|
verbose_router_logger.info("litellm.acompletion(model=%s) Exception %s", "gpt-4", "p" * 100_000)
|
|
|
|
emitted = "".join(record.getMessage() for record in caplog.records)
|
|
assert LITELLM_TRUNCATED_PAYLOAD_FIELD in emitted
|
|
assert len(emitted) <= 500
|
|
|
|
|
|
def test_set_session_id_bounds_length():
|
|
"""set_session_id() must bound length so an oversized caller-supplied value
|
|
isn't repeated across every log line for the request."""
|
|
token = set_session_id("a" * 1000)
|
|
try:
|
|
assert len(session_id_var.get()) == 256
|
|
finally:
|
|
session_id_var.reset(token)
|
|
|
|
|
|
class _FakeStream:
|
|
def __init__(self, tty: bool) -> None:
|
|
self._tty = tty
|
|
|
|
def isatty(self) -> bool:
|
|
return self._tty
|
|
|
|
|
|
def test_records_below_warning_go_to_stdout_and_the_rest_to_stderr(capsys):
|
|
logger = logging.getLogger("test_level_routing")
|
|
logger.handlers.clear()
|
|
logger.propagate = False
|
|
logger.setLevel(logging.DEBUG)
|
|
handler = LevelRoutingStreamHandler()
|
|
handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
|
|
logger.addHandler(handler)
|
|
|
|
try:
|
|
logger.debug("d")
|
|
logger.info("i")
|
|
logger.warning("w")
|
|
logger.error("e")
|
|
logger.critical("c")
|
|
finally:
|
|
logger.handlers.clear()
|
|
|
|
out, err = capsys.readouterr()
|
|
assert out.splitlines() == ["DEBUG d", "INFO i"]
|
|
assert err.splitlines() == ["WARNING w", "ERROR e", "CRITICAL c"]
|
|
|
|
|
|
def test_verbose_loggers_route_records_by_level():
|
|
for lg in (verbose_logger, verbose_router_logger, verbose_proxy_logger):
|
|
assert any(isinstance(h, LevelRoutingStreamHandler) for h in lg.handlers), lg.name
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"stdout_tty, stderr_tty, no_color, want_color",
|
|
[
|
|
(True, True, None, True),
|
|
(False, False, None, False),
|
|
(False, True, None, False),
|
|
(True, False, None, False),
|
|
(True, True, "1", False),
|
|
(True, True, "", True),
|
|
],
|
|
)
|
|
def test_plain_log_format_colorizes_only_for_a_terminal(monkeypatch, stdout_tty, stderr_tty, no_color, want_color):
|
|
if no_color is None:
|
|
monkeypatch.delenv("NO_COLOR", raising=False)
|
|
else:
|
|
monkeypatch.setenv("NO_COLOR", no_color)
|
|
|
|
fmt = _plain_log_format(_FakeStream(stdout_tty), _FakeStream(stderr_tty))
|
|
|
|
assert fmt == (_COLOR_LOG_FORMAT if want_color else _PLAIN_LOG_FORMAT)
|
|
assert ("\033[" in fmt) is want_color
|
|
|
|
|
|
def test_plain_format_carries_no_ansi_codes():
|
|
assert "\033[" not in _PLAIN_LOG_FORMAT
|
|
|
|
|
|
class _Brokenstream:
|
|
"""A write-only shim without isatty, like GUI log redirectors install."""
|
|
|
|
|
|
class _ClosedStream:
|
|
closed = True
|
|
|
|
def isatty(self) -> bool:
|
|
raise ValueError("I/O operation on closed file")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"stdout, stderr",
|
|
[
|
|
(None, None),
|
|
(_FakeStream(True), None),
|
|
(_Brokenstream(), _FakeStream(True)),
|
|
(_ClosedStream(), _FakeStream(True)),
|
|
],
|
|
)
|
|
def test_plain_log_format_survives_hostile_streams(stdout, stderr):
|
|
"""sys.stdout/sys.stderr can be None, shimmed, or closed; import must not crash."""
|
|
assert _plain_log_format(stdout, stderr) == _PLAIN_LOG_FORMAT
|
|
|
|
|
|
def test_level_routing_handler_falls_back_to_stderr_when_stdout_is_unusable(monkeypatch, capsys):
|
|
logger = logging.getLogger("test_level_routing_fallback")
|
|
logger.handlers.clear()
|
|
logger.propagate = False
|
|
logger.setLevel(logging.DEBUG)
|
|
handler = LevelRoutingStreamHandler()
|
|
handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
|
|
logger.addHandler(handler)
|
|
|
|
try:
|
|
monkeypatch.setattr(sys, "stdout", None)
|
|
logger.info("stdout is gone")
|
|
finally:
|
|
logger.handlers.clear()
|
|
|
|
err = capsys.readouterr().err
|
|
assert "INFO stdout is gone" in err
|
|
assert "--- Logging error ---" not in err
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, want",
|
|
[
|
|
("true", True),
|
|
("True", True),
|
|
("TRUE", True),
|
|
("false", False),
|
|
("False", False),
|
|
("0", False),
|
|
("1", False),
|
|
("", False),
|
|
(None, False),
|
|
],
|
|
)
|
|
def test_parse_json_logs_env_enables_only_on_true(value, want):
|
|
"""JSON_LOGS=false / 0 must not enable JSON logs (LIT-5558)."""
|
|
assert _parse_json_logs_env(value) is want
|
|
|
|
|
|
def test_plain_log_format_survives_none_streams():
|
|
"""sys.stdout/sys.stderr can be None in embedded interpreters; import must not crash."""
|
|
assert _plain_log_format(None, None) == _PLAIN_LOG_FORMAT
|
|
assert _plain_log_format(_FakeStream(True), None) == _PLAIN_LOG_FORMAT
|