This commit is contained in:
dingdangmao 2026-11-07 02:48:21 +00:00 • committed by GitHub
commit 17949f51c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 462 additions and 20 deletions

View file

@ -85,6 +85,21 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
consulted, so a subclass that does not override ``async_pre_call_hook`` inherits nothing.
"""
runs_on_no_log_requests: bool = False
"""
Whether this callback must still run for a request that asked for ``no-log: true``.
``no-log`` asks that the request's *content* not be logged; it is not a request to stop
accounting for it. A callback that only moves numbers -- the proxy's spend writer, the
router's budget limiter -- sets this True, so the request is still billed and still counts
against budgets. Exporters and hooks that ship the payload to a third party leave it False.
Opting in by attribute rather than by class name is deliberate: a class-name check breaks
silently when the class is renamed, which is how a ``no-log`` request started being served
without being charged. The attribute inherits to subclasses and reads as intent rather than
as a naming convention.
"""
def __init__(
self,
turn_off_message_logging: bool = False,

View file

@ -475,7 +475,6 @@ _INPUT_PRICING_KEY_PREFIXES: Final = (
_OUTPUT_PRICING_KEY_PREFIXES: Final = ("output_cost_per_token",)
_BATCH_PRICING_KEY_SUFFIX: Final = "_batches"
_NO_CARRIED_RATES: Final[Mapping[str, object]] = MappingProxyType({})
@ -2953,9 +2952,16 @@ class Logging(LiteLLMLoggingBaseClass):
request_params: Final = require_attributes(litellm_params, _ParamsLookup, "get")
if request_params.get("no-log", False) is True:
# proxy cost tracking cal backs should run
if not (isinstance(callback, CustomLogger) and "_PROXY_" in callback.__class__.__name__):
# A `no-log` request still has to be accounted for: billing, budgets and rate
# limits all read the same success path. Callbacks that only move numbers opt in
# through `runs_on_no_log_requests`; the internal `_PROXY_` handler prefix stays
# as a back-compat signal for hooks that predate the attribute. Exporters stay
# filtered, which is the point of `no-log` in the first place.
callback_class_name: Final = callback.__class__.__name__
runs_on_no_log: Final = isinstance(callback, CustomLogger) and (
callback.runs_on_no_log_requests or callback_class_name.startswith("_PROXY_")
)
if not runs_on_no_log:
verbose_logger.debug("no-log request, skipping logging for %s event", event_hook)
return False

View file

@ -395,6 +395,9 @@ def _attempt_spend_row_kwargs(
class _ProxyDBLogger(CustomLogger):
# The proxy still bills a `no-log` request; only its content is withheld.
runs_on_no_log_requests = True
def __init__(
self,
spend_event_producer: SpendEventProducer | None = None,
@ -425,7 +428,11 @@ class _ProxyDBLogger(CustomLogger):
response_obj,
start_time,
end_time,
store_bodies=should_store_prompts_and_responses_in_spend_logs(),
store_bodies=should_store_prompts_and_responses_in_spend_logs(
cast( # cast-ok: kwargs mapping asserted for basedpyright; no runtime effect
"Mapping[str, object] | None", kwargs.get("litellm_params")
)
),
)
if isinstance(event, SpendEventBuildError):
verbose_proxy_logger.warning("collector: tracking cost in-process, event not buildable: %s", event.reason)

View file

@ -221,6 +221,7 @@ def _stored_user_email(metadata: Mapping[str, object]) -> str | None:
def _get_spend_logs_metadata(
metadata: dict | None,
request_litellm_params: Mapping[str, object] | None = None,
applied_guardrails: list[str] | None = None,
batch_models: list[str] | None = None,
batch_successful_requests: int | None = None,
@ -336,9 +337,11 @@ def _get_spend_logs_metadata(
clean_metadata["batch_failed_requests"] = batch_failed_requests
clean_metadata["mcp_tool_call_metadata"] = mcp_tool_call_metadata
clean_metadata["vector_store_request_metadata"] = _get_vector_store_request_for_spend_logs_payload(
vector_store_request_metadata
vector_store_request_metadata, request_litellm_params
)
clean_metadata["guardrail_information"] = _sanitize_guardrail_information_for_spend_logs(
guardrail_information, request_litellm_params
)
clean_metadata["guardrail_information"] = _sanitize_guardrail_information_for_spend_logs(guardrail_information)
clean_metadata["usage_object"] = usage_object
clean_metadata["model_map_information"] = model_map_information
clean_metadata["cold_storage_object_key"] = cold_storage_object_key
@ -784,6 +787,9 @@ def get_logging_payload(
# clean up litellm metadata
clean_metadata = _get_spend_logs_metadata(
persisted_metadata,
request_litellm_params=cast( # cast-ok: kwargs mapping asserted for basedpyright; no runtime effect
"Mapping[str, object] | None", litellm_params
),
is_silent_experiment=(
standard_logging_payload is not None
and "is_silent_experiment" in standard_logging_payload["metadata"]
@ -906,7 +912,9 @@ def get_logging_payload(
if drop_requester_ip_address:
clean_metadata["requester_ip_address"] = None
clean_metadata["error_information"] = sanitize_error_information_for_spend_logs(
clean_metadata["error_information"], original_exception=failure_exception
clean_metadata["error_information"],
original_exception=failure_exception,
request_litellm_params=cast("Mapping[str, object] | None", litellm_params),
)
if litellm.cache is None:
@ -960,9 +968,19 @@ def get_logging_payload(
requester_ip_address=clean_metadata.get("requester_ip_address", None),
custom_llm_provider=custom_llm_provider or "",
messages=_get_messages_for_spend_logs_payload(
standard_logging_payload=standard_logging_payload, metadata=metadata
standard_logging_payload=standard_logging_payload,
metadata=metadata,
request_litellm_params=cast( # cast-ok: kwargs mapping asserted for basedpyright; no runtime effect
"Mapping[str, object] | None", litellm_params
),
),
response=_get_response_for_spend_logs_payload(
payload=standard_logging_payload,
kwargs=kwargs,
request_litellm_params=cast( # cast-ok: kwargs mapping asserted for basedpyright; no runtime effect
"Mapping[str, object] | None", litellm_params
),
),
response=_get_response_for_spend_logs_payload(payload=standard_logging_payload, kwargs=kwargs),
proxy_server_request=_get_proxy_server_request_for_spend_logs_payload(
metadata=metadata,
litellm_params=(
@ -1247,8 +1265,9 @@ async def get_spend_by_team_and_customer(
def _get_messages_for_spend_logs_payload(
standard_logging_payload: StandardLoggingPayload | None,
metadata: dict | None = None,
request_litellm_params: Mapping[str, object] | None = None,
) -> str:
if should_store_prompts_and_responses_in_spend_logs():
if should_store_prompts_and_responses_in_spend_logs(request_litellm_params):
if standard_logging_payload is not None:
call_type: Final = standard_logging_payload.get("call_type", "")
if call_type == "_arealtime":
@ -1291,8 +1310,13 @@ def stores_latest_turn_only() -> bool:
return general_settings_view().get("spend_logs_prompt_storage") == _LATEST_TURN_STORAGE
def stores_full_prompts_in_spend_logs() -> bool:
return should_store_prompts_and_responses_in_spend_logs() and not stores_latest_turn_only()
def stores_full_prompts_in_spend_logs(
request_litellm_params: Mapping[str, object] | None = None,
) -> bool:
return (
should_store_prompts_and_responses_in_spend_logs(request_litellm_params)
and not stores_latest_turn_only()
)
def _block_type(block: object) -> str | None:
@ -1651,6 +1675,7 @@ def _redact_prompt_leaks_in_error_string(text: str) -> str:
def _sanitize_guardrail_information_for_spend_logs(
guardrail_information: list[StandardLoggingGuardrailInformation] | None,
request_litellm_params: Mapping[str, object] | None = None,
) -> list[StandardLoggingGuardrailInformation] | None:
"""
When ``store_prompts_in_spend_logs`` is False, redact prompt-carrying fields
@ -1671,7 +1696,7 @@ def _sanitize_guardrail_information_for_spend_logs(
here to match OTEL's defensive read pattern; otherwise iteration would
yield the dict's keys and crash the whole spend-log write.
"""
if guardrail_information is None or stores_full_prompts_in_spend_logs():
if guardrail_information is None or stores_full_prompts_in_spend_logs(request_litellm_params):
return guardrail_information
entries: Final = [guardrail_information] if isinstance(guardrail_information, dict) else guardrail_information
return [_redact_prompt_fields_in_guardrail_entry(entry) for entry in entries if isinstance(entry, dict)]
@ -1721,6 +1746,7 @@ def _redact_prompt_fields_in_guardrail_entry(
def sanitize_error_information_for_spend_logs(
error_information: StandardLoggingPayloadErrorInformation | None,
original_exception: BaseException | None = None,
request_litellm_params: Mapping[str, object] | None = None,
) -> StandardLoggingPayloadErrorInformation | None:
"""
Sanitize ``error_information`` before it lands in ``LiteLLM_SpendLogs.metadata``.
@ -1731,7 +1757,8 @@ def sanitize_error_information_for_spend_logs(
- Always: cap ``error_message`` and ``traceback`` with the existing
``MAX_STRING_LENGTH_PROMPT_IN_DB`` DB-storage safeguard.
- When ``store_prompts_in_spend_logs`` is False: additionally redact
- When the request asked for ``no-log``, or ``store_prompts_in_spend_logs`` is False,
additionally redact
``'input'`` / ``'messages'`` / ``'prompt'`` values *and* Pydantic v2
``input_value=...`` assignments inside both ``error_message`` and
``traceback`` so prompts cannot leak through either field.
@ -1749,7 +1776,7 @@ def sanitize_error_information_for_spend_logs(
)
sanitized = cast(dict, {**persisted})
if not stores_full_prompts_in_spend_logs():
if not stores_full_prompts_in_spend_logs(request_litellm_params):
for field in ("error_message", "traceback"):
value = sanitized.get(field)
if isinstance(value, str):
@ -2057,7 +2084,7 @@ def _get_proxy_server_request_for_spend_logs_payload(
If turn_off_message_logging is enabled, redact messages in the request body.
"""
if should_store_prompts_and_responses_in_spend_logs():
if should_store_prompts_and_responses_in_spend_logs(litellm_params):
_proxy_server_request: Final = cast(dict | None, litellm_params.get("proxy_server_request", EMPTY_MAPPING))
if _proxy_server_request is not None:
_request_body = _proxy_server_request.get("body", EMPTY_MAPPING) or EMPTY_MAPPING
@ -2098,11 +2125,12 @@ def _get_proxy_server_request_for_spend_logs_payload(
def _get_vector_store_request_for_spend_logs_payload(
vector_store_request_metadata: list[StandardLoggingVectorStoreRequest] | None,
request_litellm_params: Mapping[str, object] | None = None,
) -> list[StandardLoggingVectorStoreRequest] | None:
"""
If user does not want to store prompts and responses, then remove the content from the vector store request metadata
"""
if stores_full_prompts_in_spend_logs():
if stores_full_prompts_in_spend_logs(request_litellm_params):
return vector_store_request_metadata
if vector_store_request_metadata is None:
@ -2133,10 +2161,11 @@ def _redacted_vector_store_request(request: StandardLoggingVectorStoreRequest) -
def _get_response_for_spend_logs_payload(
payload: StandardLoggingPayload | None,
kwargs: dict | None = None,
request_litellm_params: Mapping[str, object] | None = None,
) -> str:
if payload is None:
return "{}"
if should_store_prompts_and_responses_in_spend_logs():
if should_store_prompts_and_responses_in_spend_logs(request_litellm_params):
response_obj: object = payload.get("response")
if response_obj is None:
return "{}"
@ -2186,7 +2215,33 @@ def _get_response_for_spend_logs_payload(
return "{}"
def should_store_prompts_and_responses_in_spend_logs() -> bool:
def _request_is_no_log(request_litellm_params: Mapping[str, object] | None) -> bool:
"""`no-log: true` asks for this request's content not to be logged.
The proxy still bills such a request -- that is why the spend writer is let through the
no-log filter in `should_run_callback` -- so this suppresses the content-bearing fields
only, never the cost fields.
An administrator who set `litellm.global_disable_no_log_param` has turned the parameter off,
so it is ignored here exactly as `should_run_callback` ignores it.
"""
if litellm.global_disable_no_log_param:
return False
return isinstance(request_litellm_params, Mapping) and request_litellm_params.get("no-log") is True
def should_store_prompts_and_responses_in_spend_logs(
request_litellm_params: Mapping[str, object] | None = None,
) -> bool:
"""Whether prompt/response content may be stored, for this request when one is given.
A request that asked for `no-log: true` never has its content stored, whatever the
proxy-wide `store_prompts_in_spend_logs` setting says. Callers that have no per-request
context omit the argument and get the proxy-wide answer.
"""
if _request_is_no_log(request_litellm_params):
return False
from litellm.proxy.proxy_server import general_settings
from litellm.secret_managers.main import get_secret_bool

View file

@ -147,6 +147,9 @@ def budget_window_seconds(started_at: float, budget_duration: str) -> int:
class RouterBudgetLimiting(CustomLogger):
# Deployment, provider and tag budgets are accounting: a `no-log` request still spends.
runs_on_no_log_requests = True
def __init__(
self,
dual_cache: DualCache,

View file

@ -14690,3 +14690,168 @@ def test_standard_logging_payload_reports_the_per_call_prices_the_request_was_bi
model_map_value: Final = payload["model_map_information"]["model_map_value"]
assert model_map_value is not None
assert (model_map_value["input_cost_per_token"], model_map_value["output_cost_per_token"]) == (3e-06, 7e-06)
# --- no-log callback filtering (#44503) ---
class My_proxyAuditLogger(CustomLogger):
"""Unrelated logger whose name merely contains "proxy" (Greptile's bypass example)."""
def __init__(self) -> None:
super().__init__()
self.logged: list[object] = []
async def async_log_success_event(
self,
kwargs: dict[str, object],
response_obj: object,
start_time: datetime.datetime,
end_time: datetime.datetime,
) -> None:
self.logged.append(response_obj)
class _PROXY_HandlerStub(CustomLogger):
"""Stands in for the proxy's internal `_PROXY_*` handler family."""
async def _dispatch_async_success(
logging_obj: LitellmLogging, callbacks: list[CustomLogger], *, no_log: bool
) -> ModelResponse:
"""Drive the real async success dispatch with `callbacks` as the combined callback list."""
logging_obj.stream = False
logging_obj.model_call_details["litellm_params"] = {"acompletion": True, "no-log": no_log}
logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"]
result: Final = _success_response()
with patch.object(logging_obj, "get_combined_callback_list", return_value=callbacks):
await logging_obj.async_success_handler(
result=result,
start_time=datetime.datetime.now(datetime.timezone.utc),
end_time=datetime.datetime.now(datetime.timezone.utc),
)
return result
@pytest.mark.asyncio
async def test_no_log_request_still_runs_the_real_proxy_spend_logger(
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch
):
"""#44503: `no-log` must not stop the proxy from doing its own spend accounting.
Drives the real `_ProxyDBLogger` through the real async success dispatch; only the
DB-writing `_PROXY_track_cost_callback` is recorded.
"""
from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger
tracked: list[object] = []
proxy_spend_logger: Final = _ProxyDBLogger()
async def _record_spend(
kwargs: dict[str, object],
completion_response: object,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> None:
tracked.append(completion_response)
monkeypatch.setattr(proxy_spend_logger, "_PROXY_track_cost_callback", _record_spend)
monkeypatch.setattr(litellm, "global_disable_no_log_param", False)
result = await _dispatch_async_success(logging_obj, [proxy_spend_logger], no_log=True)
assert len(tracked) == 1 and tracked[0] is result, "no-log request must still execute the spend accounting"
@pytest.mark.asyncio
async def test_no_log_suppresses_logger_whose_name_mentions_proxy(
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch
):
"""An unrelated logger named like a proxy hook must not bypass the no-log filter."""
monkeypatch.setattr(litellm, "global_disable_no_log_param", False)
logger: Final = My_proxyAuditLogger()
await _dispatch_async_success(logging_obj, [logger], no_log=True)
assert logger.logged == [], "a name that merely contains 'proxy' must not bypass no-log"
@pytest.mark.asyncio
async def test_logger_whose_name_mentions_proxy_runs_without_no_log(
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch
):
"""Differential control: without `no-log`, the same logger runs (dispatch is not broken)."""
monkeypatch.setattr(litellm, "global_disable_no_log_param", False)
logger: Final = My_proxyAuditLogger()
result = await _dispatch_async_success(logging_obj, [logger], no_log=False)
assert len(logger.logged) == 1 and logger.logged[0] is result
def test_no_log_keeps_running_proxy_prefixed_handlers(logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch):
"""The proxy's `_PROXY_*` handler family must keep running on no-log requests."""
monkeypatch.setattr(litellm, "global_disable_no_log_param", False)
assert (
logging_obj.should_run_callback(
callback=_PROXY_HandlerStub(),
litellm_params={"no-log": True},
event_hook="success",
)
is True
)
class _AccountingStub(CustomLogger):
"""Stands in for an accounting callback (router budgets) that opts in by attribute."""
runs_on_no_log_requests = True
class _ExportingStub(CustomLogger):
"""Default: a callback that ships content stays filtered on `no-log`."""
def test_no_log_runs_callback_that_opts_in_by_attribute(logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch):
"""A callback that only moves numbers opts in with `runs_on_no_log_requests`."""
monkeypatch.setattr(litellm, "global_disable_no_log_param", False)
assert (
logging_obj.should_run_callback(
callback=_AccountingStub(),
litellm_params={"no-log": True},
event_hook="success",
)
is True
)
def test_no_log_filters_default_callback_without_the_attribute(
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch
):
"""Control: the attribute is an opt-in, so a default CustomLogger stays filtered."""
monkeypatch.setattr(litellm, "global_disable_no_log_param", False)
assert (
logging_obj.should_run_callback(
callback=_ExportingStub(),
litellm_params={"no-log": True},
event_hook="success",
)
is False
)
assert (
logging_obj.should_run_callback(
callback=_ExportingStub(),
litellm_params={"no-log": False},
event_hook="success",
)
is True
)
def test_accounting_callbacks_are_marked_in_the_real_classes():
"""#44503 follow-up: the spend writer and the router budget limiter must both opt in."""
from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
assert _ProxyDBLogger.runs_on_no_log_requests is True
assert RouterBudgetLimiting.runs_on_no_log_requests is True

View file

@ -0,0 +1,191 @@
"""A `no-log: true` request is still billed, but its content must never be persisted or published.
`_ProxyDBLogger` is deliberately let through the `should_run_callback` no-log filter so the proxy can
bill the request. These tests pin the other half of that contract: the spend row keeps the cost
fields and loses the prompt/response content, in both the DB write and the collector publish.
"""
import datetime
import json
from datetime import timezone
from typing import Final
import pytest
import litellm
from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
PROMPT_SECRET: Final = "PROMPT-SECRET-3f9a1c"
COMPLETION_SECRET: Final = "COMPLETION-SECRET-7b2d4e"
def _sl_object() -> dict:
return {
"messages": [{"role": "user", "content": PROMPT_SECRET}],
"response": {"choices": [{"message": {"role": "assistant", "content": COMPLETION_SECRET}}]},
"metadata": {"user_api_key": "hashed-key"},
"model_map_information": None,
"request_tags": [],
"response_cost": 0.001,
}
def _kwargs(no_log: bool) -> dict:
return {
"model": "gpt-4o-mini",
"call_type": "acompletion",
"litellm_params": {
"no-log": no_log,
"metadata": {"user_api_key": "hashed-key"},
"proxy_server_request": {
"url": "http://localhost:4000/v1/chat/completions",
"body": {"messages": [{"role": "user", "content": PROMPT_SECRET}]},
},
},
"standard_logging_object": _sl_object(),
"response_cost": 0.001,
}
def _response() -> litellm.ModelResponse:
return litellm.ModelResponse(
id="chatcmpl-no-log",
choices=[{"message": {"role": "assistant", "content": COMPLETION_SECRET}}],
usage=litellm.Usage(prompt_tokens=3, completion_tokens=4, total_tokens=7),
)
def _payload(monkeypatch, no_log: bool, store_prompts: bool) -> dict:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": store_prompts})
now: Final = datetime.datetime.now(timezone.utc)
return get_logging_payload(kwargs=_kwargs(no_log=no_log), response_obj=_response(), start_time=now, end_time=now)
@pytest.mark.parametrize("store_prompts", [True, False])
def test_no_log_request_persists_no_content(monkeypatch, store_prompts: bool):
payload: Final = _payload(monkeypatch, no_log=True, store_prompts=store_prompts)
stored: Final = json.dumps(payload, default=str)
assert PROMPT_SECRET not in stored, "prompt leaked into the no-log spend row"
assert COMPLETION_SECRET not in stored, "completion leaked into the no-log spend row"
def test_no_log_request_still_records_cost(monkeypatch):
"""The point of letting the spend writer through: the request is still billed."""
payload: Final = _payload(monkeypatch, no_log=True, store_prompts=True)
assert payload["request_id"]
assert payload["model"] == "gpt-4o-mini"
assert payload["total_tokens"] == 7
assert payload["spend"] == 0.001
def test_logged_request_still_persists_content_when_prompt_storage_is_on(monkeypatch):
"""Control: the suppression is scoped to no-log, it is not a blanket redaction."""
payload: Final = _payload(monkeypatch, no_log=False, store_prompts=True)
# The request body carries the prompt for any call type; the SL `response` column carries the
# completion. (`messages` is only populated for `_arealtime` calls, so it is not asserted here.)
assert PROMPT_SECRET in payload["proxy_server_request"]
assert COMPLETION_SECRET in payload["response"]
class _CapturingProducer:
def __init__(self) -> None:
self.published: list[bytes] = []
async def publish(self, line: bytes) -> None:
self.published.append(line)
@pytest.mark.asyncio
@pytest.mark.parametrize("no_log", [True, False])
async def test_collector_event_carries_no_content_for_no_log_requests(monkeypatch, no_log: bool):
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True})
producer: Final = _CapturingProducer()
logger: Final = _ProxyDBLogger(spend_event_producer=producer)
now: Final = datetime.datetime.now(timezone.utc)
await logger.async_log_success_event(_kwargs(no_log=no_log), _response(), now, now)
assert producer.published, "expected the collector (offload) path to be taken"
published: Final = producer.published[0].decode()
if no_log:
assert PROMPT_SECRET not in published
assert COMPLETION_SECRET not in published
else:
assert PROMPT_SECRET in published
VECTOR_STORE_SECRET: Final = "VECTOR-STORE-SECRET-5c8e0a"
@pytest.mark.parametrize("global_disable_no_log_param", [False, True])
@pytest.mark.parametrize("store_prompts", [True, False])
@pytest.mark.parametrize("no_log", [True, False])
def test_spend_row_content_follows_no_log_and_admin_settings(
monkeypatch, no_log: bool, store_prompts: bool, global_disable_no_log_param: bool
):
"""Content is stored only when prompt storage is on and the request's `no-log` is honoured.
`global_disable_no_log_param` lets an administrator turn the request parameter off; the
spend row must then follow `store_prompts_in_spend_logs` alone, as the callback filter does.
Cost is recorded in every combination.
"""
monkeypatch.setattr(litellm, "global_disable_no_log_param", global_disable_no_log_param)
payload: Final = _payload(monkeypatch, no_log=no_log, store_prompts=store_prompts)
content_expected: Final = store_prompts and not (no_log and not global_disable_no_log_param)
assert (PROMPT_SECRET in payload["proxy_server_request"]) is content_expected
assert (COMPLETION_SECRET in payload["response"]) is content_expected
assert payload["spend"] == 0.001
assert payload["total_tokens"] == 7
@pytest.mark.parametrize("global_disable_no_log_param", [False, True])
@pytest.mark.parametrize("no_log", [True, False])
def test_vector_store_content_follows_request_no_log(monkeypatch, no_log: bool, global_disable_no_log_param: bool):
"""Vector-store search results are request content too: a no-log row must not carry them."""
from litellm.proxy import proxy_server
monkeypatch.setattr(litellm, "global_disable_no_log_param", global_disable_no_log_param)
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True})
kwargs: Final = _kwargs(no_log=no_log)
kwargs["standard_logging_object"]["metadata"]["vector_store_request_metadata"] = [
{"vector_store_search_response": {"data": [{"content": [{"text": VECTOR_STORE_SECRET, "type": "text"}]}]}}
]
now: Final = datetime.datetime.now(timezone.utc)
payload: Final = get_logging_payload(kwargs=kwargs, response_obj=_response(), start_time=now, end_time=now)
assert (VECTOR_STORE_SECRET in json.dumps(payload, default=str)) is (not no_log or global_disable_no_log_param)
ERROR_SECRET: Final = "ERROR-SECRET-1d4b9f"
@pytest.mark.parametrize("global_disable_no_log_param", [False, True])
@pytest.mark.parametrize("no_log", [True, False])
def test_error_information_follows_request_no_log(monkeypatch, no_log: bool, global_disable_no_log_param: bool):
"""A provider error can echo the body, so the same per-request rule must redact it."""
from litellm.proxy import proxy_server
from litellm.proxy.spend_tracking.spend_tracking_utils import (
sanitize_error_information_for_spend_logs,
)
monkeypatch.setattr(litellm, "global_disable_no_log_param", global_disable_no_log_param)
monkeypatch.setattr(proxy_server, "general_settings", {"store_prompts_in_spend_logs": True})
error_information: Final = {
"error_message": "validation error [type=string_type, input_value=" + repr(ERROR_SECRET) + ", input_type=str]",
"traceback": "payload {'messages': [{'role': 'user', 'content': " + repr(ERROR_SECRET) + "}]}",
}
sanitized = sanitize_error_information_for_spend_logs(error_information, request_litellm_params={"no-log": no_log})
content_expected: Final = not (no_log and not global_disable_no_log_param)
assert (ERROR_SECRET in json.dumps(sanitized, default=str)) is content_expected