diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index 385522c2435..8f124cbedb1 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -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, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index fcc521cf6cf..094a61e1ffb 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -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 diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index e1fc725ae63..be61b5f8e77 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -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) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index e4492ee8992..43e02ab518e 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -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 diff --git a/litellm/router_strategy/budget_limiter.py b/litellm/router_strategy/budget_limiter.py index c65820b9892..427e726d05f 100644 --- a/litellm/router_strategy/budget_limiter.py +++ b/litellm/router_strategy/budget_limiter.py @@ -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, diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index 58fd03e0cbc..98e6d813181 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -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 diff --git a/tests/unit/proxy/spend_tracking/test_no_log_request_spend_body_privacy.py b/tests/unit/proxy/spend_tracking/test_no_log_request_spend_body_privacy.py new file mode 100644 index 00000000000..8c7a9db0297 --- /dev/null +++ b/tests/unit/proxy/spend_tracking/test_no_log_request_spend_body_privacy.py @@ -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