litellm/tests/unit/integrations/test_custom_logger.py
devin-ai-integration[bot] dfe4df8c8a
test: move whole-unit legacy test files into tests/unit and delete dead skips (#44807)
* test: delete unconditionally skipped legacy tests

* test: move whole-unit legacy test files into tests/unit

* test: keep moved legacy tests free of import-time global state

* test: keep the module-level invocation scan pointed at tests/local_testing

* ci: drop the agent_testing CircleCI job emptied by the move

* test: fix moved-test isolation and router coverage

* test: add Tinyfish search package marker

* test: isolate moved tests from logger state leaks

* test: isolate Helicone logging fixture state

* test: isolate Vertex pass-through credentials between moved tests

* test: cancel S3 periodic flush tasks started by moved tests

* ci: restore CircleCI assistant test selection after move

* test: prevent Datadog datetime import shadowing

* ci: drop the litellm_assistants_api_testing CircleCI job emptied by the move

* test: deduplicate imports in rebased unit tests

* test: remove duplicate passthrough router patch import

* test: remove moved legacy source files after rebase

* test: align moved tests with rebased main

* test: carry main's legacy-file edits into moved destinations

* test: make the moved cost map fallback tests assert the fetch and the backup

The four fallback cases only checked the result was non-empty, so they still
passed with integrity validation disabled. They now inject a mock client, assert
one fetch happened, and assert the result is exactly the local backup with the
fallback reason recorded.

---------

Co-authored-by: yuneng <yuneng@berri.ai>
2026-10-07 05:02:28 +00:00

481 lines
18 KiB
Python

"""
Tests for standard_logging_payload_excluded_fields feature.
This feature allows users to exclude specific fields from StandardLoggingPayload
before any callback receives it. This is useful for:
- Reducing log sizes (excluding large fields like 'response' or 'messages')
- Privacy compliance (excluding sensitive fields)
- Cost management (less data stored/transmitted)
Example config:
litellm_settings:
success_callback: ["s3"]
standard_logging_payload_excluded_fields: ["response", "messages"]
"""
import asyncio
import importlib
import os
from collections.abc import AsyncIterator
from copy import deepcopy
from typing import Dict, Final, Optional
import pytest
import pytest_asyncio
import litellm
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
def create_sample_standard_logging_payload() -> Dict:
"""Create a sample StandardLoggingPayload for testing."""
return {
"id": "test-id-123",
"trace_id": "trace-123",
"call_type": "completion",
"stream": False,
"response_cost": 0.001,
"cost_breakdown": None,
"response_cost_failure_debug_info": None,
"status": "success",
"status_fields": {},
"custom_llm_provider": "openai",
"total_tokens": 100,
"prompt_tokens": 50,
"completion_tokens": 50,
"startTime": 1234567890.0,
"endTime": 1234567891.0,
"completionStartTime": 1234567890.5,
"response_time": 1.0,
"model_map_information": {},
"model": "gpt-5.5",
"model_id": "model-123",
"model_group": None,
"api_base": "https://api.openai.com/v1",
"metadata": {},
"cache_hit": False,
"cache_key": None,
"saved_cache_cost": 0.0,
"request_tags": [],
"end_user": None,
"requester_ip_address": None,
"user_agent": None,
"messages": [{"role": "user", "content": "Hello, this is sensitive data!"}],
"response": {"choices": [{"message": {"content": "This is a sensitive response!"}}]},
"error_str": None,
"error_information": None,
"model_parameters": {},
"hidden_params": {},
"guardrail_information": None,
"standard_built_in_tools_params": None,
}
def create_model_call_details(
standard_logging_payload: Optional[Dict] = None,
) -> Dict:
"""Create model_call_details dict with standard_logging_object."""
if standard_logging_payload is None:
standard_logging_payload = create_sample_standard_logging_payload()
return {
"standard_logging_object": standard_logging_payload,
"other_key": "other_value",
}
class TestStandardLoggingPayloadExcludedFields:
"""Test suite for standard_logging_payload_excluded_fields feature."""
def setup_method(self):
"""Reset litellm settings before each test."""
litellm.standard_logging_payload_excluded_fields = None
def teardown_method(self):
"""Clean up after each test."""
litellm.standard_logging_payload_excluded_fields = None
def test_no_excluded_fields_no_change(self):
"""Test that payload is unchanged when no fields are excluded."""
logger = CustomLogger()
model_call_details = create_model_call_details()
original_keys = set(model_call_details["standard_logging_object"].keys())
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
result_keys = set(result["standard_logging_object"].keys())
assert result_keys == original_keys
def test_exclude_single_field(self):
"""Test excluding a single field (response)."""
litellm.standard_logging_payload_excluded_fields = ["response"]
logger = CustomLogger()
model_call_details = create_model_call_details()
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
assert "response" not in result["standard_logging_object"]
assert "messages" in result["standard_logging_object"]
assert "model" in result["standard_logging_object"]
def test_exclude_multiple_fields(self):
"""Test excluding multiple fields (response, messages)."""
litellm.standard_logging_payload_excluded_fields = ["response", "messages"]
logger = CustomLogger()
model_call_details = create_model_call_details()
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
assert "response" not in result["standard_logging_object"]
assert "messages" not in result["standard_logging_object"]
assert "model" in result["standard_logging_object"]
assert "model_parameters" in result["standard_logging_object"]
def test_exclude_metadata_field(self):
"""Test excluding the metadata field."""
litellm.standard_logging_payload_excluded_fields = ["metadata"]
logger = CustomLogger()
payload = create_sample_standard_logging_payload()
payload["metadata"] = {"sensitive_key": "sensitive_value"}
model_call_details = create_model_call_details(payload)
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
assert "metadata" not in result["standard_logging_object"]
def test_exclude_hidden_params(self):
"""Test excluding hidden_params field."""
litellm.standard_logging_payload_excluded_fields = ["hidden_params"]
logger = CustomLogger()
payload = create_sample_standard_logging_payload()
payload["hidden_params"] = {"api_key": "sk-secret-key"}
model_call_details = create_model_call_details(payload)
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
assert "hidden_params" not in result["standard_logging_object"]
def test_exclude_nonexistent_field_no_error(self):
"""Test that excluding a non-existent field doesn't cause an error."""
litellm.standard_logging_payload_excluded_fields = [
"nonexistent_field",
"response",
]
logger = CustomLogger()
model_call_details = create_model_call_details()
# Should not raise an exception
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
assert "response" not in result["standard_logging_object"]
assert "messages" in result["standard_logging_object"]
def test_original_payload_not_modified(self):
"""Test that the original model_call_details is not modified."""
litellm.standard_logging_payload_excluded_fields = ["response", "messages"]
logger = CustomLogger()
model_call_details = create_model_call_details()
original_payload = deepcopy(model_call_details)
logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
# Original should still have the fields
assert "response" in model_call_details["standard_logging_object"]
assert "messages" in model_call_details["standard_logging_object"]
assert model_call_details == original_payload
def test_combined_with_turn_off_message_logging(self):
"""Test that excluded_fields works together with turn_off_message_logging."""
litellm.standard_logging_payload_excluded_fields = ["metadata", "hidden_params"]
logger = CustomLogger(turn_off_message_logging=True)
model_call_details = create_model_call_details()
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
# excluded_fields should remove these
assert "metadata" not in result["standard_logging_object"]
assert "hidden_params" not in result["standard_logging_object"]
# turn_off_message_logging should redact these
redacted_str = "redacted-by-litellm"
assert result["standard_logging_object"]["messages"][0]["content"] == redacted_str
assert result["standard_logging_object"]["response"]["choices"][0]["message"]["content"] == redacted_str
def test_excluded_fields_takes_precedence_over_redaction(self):
"""Test that if a field is both excluded and would be redacted, it's excluded."""
litellm.standard_logging_payload_excluded_fields = ["response"]
logger = CustomLogger(turn_off_message_logging=True)
model_call_details = create_model_call_details()
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
# response should be excluded (not redacted)
assert "response" not in result["standard_logging_object"]
# messages should still be redacted
redacted_str = "redacted-by-litellm"
assert result["standard_logging_object"]["messages"][0]["content"] == redacted_str
def test_exclude_all_sensitive_fields(self):
"""Test excluding all potentially sensitive fields."""
litellm.standard_logging_payload_excluded_fields = [
"messages",
"response",
"metadata",
"hidden_params",
"model_parameters",
"error_str",
"error_information",
]
logger = CustomLogger()
model_call_details = create_model_call_details()
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
standard_obj = result["standard_logging_object"]
# All sensitive fields should be removed
assert "messages" not in standard_obj
assert "response" not in standard_obj
assert "metadata" not in standard_obj
assert "hidden_params" not in standard_obj
assert "model_parameters" not in standard_obj
assert "error_str" not in standard_obj
assert "error_information" not in standard_obj
# Non-sensitive fields should remain
assert "id" in standard_obj
assert "model" in standard_obj
assert "response_cost" in standard_obj
assert "total_tokens" in standard_obj
def test_empty_excluded_fields_list(self):
"""Test that an empty list doesn't affect the payload."""
litellm.standard_logging_payload_excluded_fields = []
logger = CustomLogger()
model_call_details = create_model_call_details()
original_keys = set(model_call_details["standard_logging_object"].keys())
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
result_keys = set(result["standard_logging_object"].keys())
assert result_keys == original_keys
def test_none_standard_logging_object(self):
"""Test handling when standard_logging_object is None."""
litellm.standard_logging_payload_excluded_fields = ["response"]
logger = CustomLogger()
model_call_details = {"other_key": "other_value"}
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
# Should return unchanged when no standard_logging_object
assert result == model_call_details
class TestExcludedFieldsIntegration:
"""Integration tests for excluded fields with actual callbacks."""
def setup_method(self):
"""Reset litellm settings before each test."""
litellm.standard_logging_payload_excluded_fields = None
litellm.callbacks = []
def teardown_method(self):
"""Clean up after each test."""
litellm.standard_logging_payload_excluded_fields = None
litellm.callbacks = []
def test_custom_callback_receives_filtered_payload(self):
"""Test that a custom callback receives the filtered payload."""
captured_payloads = []
class TestCallback(CustomLogger):
def log_success_event(self, kwargs, response_obj, start_time, end_time):
captured_payloads.append(kwargs.get("standard_logging_object", {}))
litellm.standard_logging_payload_excluded_fields = ["response", "messages"]
callback = TestCallback()
model_call_details = create_model_call_details()
# Simulate what litellm_logging.py does
filtered_details = callback.redact_standard_logging_payload_from_model_call_details(model_call_details)
callback.log_success_event(
kwargs=filtered_details,
response_obj=None,
start_time=None,
end_time=None,
)
assert len(captured_payloads) == 1
assert "response" not in captured_payloads[0]
assert "messages" not in captured_payloads[0]
assert "model" in captured_payloads[0]
class TestExcludedFieldsConfigLoading:
"""Test that the config is properly loaded from litellm_settings."""
def setup_method(self):
"""Reset litellm settings before each test."""
litellm.standard_logging_payload_excluded_fields = None
def teardown_method(self):
"""Clean up after each test."""
litellm.standard_logging_payload_excluded_fields = None
def test_config_attribute_exists(self):
"""Test that the config attribute exists on litellm module."""
assert hasattr(litellm, "standard_logging_payload_excluded_fields")
def test_config_default_is_none(self):
"""Test that the default value is None."""
# Reset to ensure we're testing the default
litellm.standard_logging_payload_excluded_fields = None
assert litellm.standard_logging_payload_excluded_fields is None
def test_config_can_be_set_to_list(self):
"""Test that the config can be set to a list."""
litellm.standard_logging_payload_excluded_fields = ["response", "messages"]
assert litellm.standard_logging_payload_excluded_fields == [
"response",
"messages",
]
def test_config_setattr_simulates_proxy_loading(self):
"""Test that setattr works as the proxy would use it."""
# Simulating how proxy_server.py sets litellm_settings
config_value = ["response", "messages", "metadata"]
setattr(litellm, "standard_logging_payload_excluded_fields", config_value)
assert litellm.standard_logging_payload_excluded_fields == config_value
# Test it actually works in the logger
logger = CustomLogger()
model_call_details = create_model_call_details()
result = logger.redact_standard_logging_payload_from_model_call_details(model_call_details)
assert "response" not in result["standard_logging_object"]
assert "messages" not in result["standard_logging_object"]
assert "metadata" not in result["standard_logging_object"]
@pytest.fixture(autouse=True)
def _vcr_outcome_gate(request, vcr):
install_live_call_probe(request, vcr)
yield
record_vcr_outcome(request, vcr)
@pytest_asyncio.fixture(loop_scope="function", autouse=True)
async def drain_logging_worker(isolate_litellm_state: None) -> AsyncIterator[None]:
yield
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=LOGGING_WORKER_DRAIN_TIMEOUT_SECONDS)
LOGGING_WORKER_DRAIN_TIMEOUT_SECONDS: Final = LOGGING_WORKER_MAX_TIME_PER_COROUTINE + 5.0
@pytest.fixture(scope="function", autouse=True)
def isolate_litellm_state():
"""
Per-function isolation fixture.
Resets litellm state to the true defaults captured at conftest import time,
then restores after the test. This prevents module-level mutations (e.g.
`litellm.num_retries = 3` at the top of test_langfuse_e2e_test.py) from
leaking across tests within the same xdist worker.
"""
from litellm.litellm_core_utils import litellm_logging as ll_logging
from litellm.proxy.management_helpers import audit_logs as ll_audit_logs
if hasattr(litellm, "in_memory_llm_clients_cache"):
litellm.in_memory_llm_clients_cache.flush_cache()
ll_logging._in_memory_loggers.clear()
ll_audit_logs._audit_log_callback_cache.clear()
for attr in _LIST_ATTRS:
if attr in _DEFAULTS:
default = _DEFAULTS[attr]
setattr(litellm, attr, default.copy() if isinstance(default, list) else default)
for attr in _SCALAR_ATTRS:
if attr in _DEFAULTS:
setattr(litellm, attr, _DEFAULTS[attr])
yield
if hasattr(litellm, "in_memory_llm_clients_cache"):
litellm.in_memory_llm_clients_cache.flush_cache()
ll_logging._in_memory_loggers.clear()
ll_audit_logs._audit_log_callback_cache.clear()
for attr in _LIST_ATTRS:
if attr in _DEFAULTS:
default = _DEFAULTS[attr]
setattr(litellm, attr, default.copy() if isinstance(default, list) else default)
for attr in _SCALAR_ATTRS:
if attr in _DEFAULTS:
setattr(litellm, attr, _DEFAULTS[attr])
_LIST_ATTRS = (
"callbacks",
"success_callback",
"failure_callback",
"_async_success_callback",
"_async_failure_callback",
"service_callback",
"pre_call_rules",
"post_call_rules",
)
_SCALAR_ATTRS = (
"set_verbose",
"cache",
"num_retries",
"num_retries_per_request",
"turn_off_message_logging",
"redact_messages_in_exceptions",
"redact_user_api_key_info",
"s3_callback_params",
"s3_audit_callback_params",
"datadog_params",
"vector_store_registry",
)
_DEFAULTS: dict = {}
@pytest.fixture(scope="module", autouse=True)
def setup_and_teardown():
"""
Module-scoped setup. Reloads litellm only in single-process mode
(skipped under xdist to avoid cross-worker interference).
"""
import litellm
worker_id = os.environ.get("PYTEST_XDIST_WORKER", None)
if worker_id is None:
importlib.reload(litellm)
try:
if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"):
import litellm.proxy.proxy_server
importlib.reload(litellm.proxy.proxy_server)
except Exception:
pass
if hasattr(litellm, "in_memory_llm_clients_cache"):
litellm.in_memory_llm_clients_cache.flush_cache()
yield