diff --git a/litellm/_logging.py b/litellm/_logging.py index 0ca3cfd730e..a65ba741edb 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -1016,7 +1016,9 @@ def print_startup_message(message: str) -> None: level, whole and unredacted as the print showed it; a blank spacer produces no record. """ if not litellm.json_logs: - print(message) # noqa: T201 # plain mode: this is the operator-terminal print it replaces + print( # noqa: T201 # plain mode: this is the operator-terminal print it replaces + REDACTED_BY_LITELLM if diagnostic_message_logging_disabled() else message + ) return text: Final = _ANSI_ESCAPE.sub("", message).strip("\n") if not text: diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index ad58809ff97..4420a351263 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -4566,10 +4566,12 @@ class ProxyBaseLLMRequestProcessing: e, ) + stream_error_status: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR) + log_safe_diagnostic( + verbose_proxy_logger, "request_failed", status_code=stream_error_status, level=logging.ERROR + ) if isinstance(e, HTTPException): raise e - stream_error_status: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR) - log_safe_diagnostic(verbose_proxy_logger, "request_failed", status_code=stream_error_status) proxy_exception: Final = ProxyException( message=redact_internal_details_from_client_message(getattr(e, "message", str(e))), type=openai_error_type(e, stream_error_status), diff --git a/tests/unit/integrations/cloudzero/test_cz_stream_api.py b/tests/unit/integrations/cloudzero/test_cz_stream_api.py index 89a4357494e..326daa3b368 100644 --- a/tests/unit/integrations/cloudzero/test_cz_stream_api.py +++ b/tests/unit/integrations/cloudzero/test_cz_stream_api.py @@ -1,3 +1,4 @@ +import json import zoneinfo from datetime import datetime, timezone from typing import Final @@ -9,8 +10,6 @@ import pytest import respx import litellm - - from litellm.integrations.cloudzero.cz_stream_api import CloudZeroStreamer @@ -198,3 +197,47 @@ def test_cloudzero_http_error_diagnostics_respect_privacy_without_changing_excep streamer._send_daily_batch("2026-01-01", pl.DataFrame({"cost/cost": [1.0]}), "replace_hourly") assert captured.value.response.text == marker assert (marker in capsys.readouterr().out) is not redact + + +@pytest.mark.parametrize("redact", (False, True)) +def test_cloudzero_skips_invalid_timestamp_without_logging_content_when_private( + redact: bool, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact) + marker: Final = "synthetic-private-invalid-timestamp" + streamer: Final = CloudZeroStreamer("test-key", "test-connection") + data: Final = pl.DataFrame( + {"time/usage_start": [marker, "2026-01-01T00:00:00Z"], "cost/cost": [9.0, 1.0]} + ) + with respx.mock: + route: Final = respx.post( + f"{streamer.base_url}/v2/connections/billing/anycost/test-connection/billing_drops" + ).respond(status_code=200) + streamer.send_batched(data) + assert len(route.calls) == 1 + assert json.loads(route.calls[0].request.content) == { + "month": "2026-01", + "operation": "replace_hourly", + "data": [{"time/usage_start": "2026-01-01T00:00:00Z", "cost/cost": "1"}], + } + assert (marker in capsys.readouterr().out) is not redact + + +@pytest.mark.parametrize("redact", (False, True)) +def test_cloudzero_network_error_preserved_without_logging_content_when_private( + redact: bool, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", redact) + marker: Final = "synthetic-private-network-error" + streamer: Final = CloudZeroStreamer("test-key", "test-connection") + failure: Final = httpx.ConnectError(marker) + with respx.mock: + respx.post( + f"{streamer.base_url}/v2/connections/billing/anycost/test-connection/billing_drops" + ).mock(side_effect=failure) + with pytest.raises(httpx.ConnectError) as captured: + streamer.send_batched( + pl.DataFrame({"time/usage_start": ["2026-01-01T00:00:00Z"], "cost/cost": [1.0]}) + ) + assert captured.value is failure + assert (marker in capsys.readouterr().out) is not redact diff --git a/tests/unit/proxy/test_common_request_processing.py b/tests/unit/proxy/test_common_request_processing.py index 34fd5f79a69..b79d13b5c59 100644 --- a/tests/unit/proxy/test_common_request_processing.py +++ b/tests/unit/proxy/test_common_request_processing.py @@ -12249,3 +12249,56 @@ async def test_debug_message_privacy_preserves_stream_content( assert all("PRIVATE_STREAM_INPUT_42896" not in message for message in messages) else: assert any("PRIVATE_STREAM_INPUT_42896" in message for message in messages) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("privacy", [False, True]) +@pytest.mark.parametrize("http_error", [False, True]) +async def test_stream_failure_retains_error_status_without_debug_logging( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, privacy: bool, http_error: bool +) -> None: + import logging + + from litellm._logging import verbose_proxy_logger + + monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", privacy) + monkeypatch.setattr(litellm, "callbacks", []) + monkeypatch.setattr(litellm, "success_callback", []) + monkeypatch.setattr(litellm, "_async_success_callback", []) + monkeypatch.setattr(litellm, "include_cost_in_streaming_usage", False) + monkeypatch.setattr(verbose_proxy_logger, "level", logging.ERROR) + ProxyLogging._callback_capabilities_cache.clear() + failure: Final = ( + HTTPException(status_code=429, detail="PRIVATE_STREAM_FAILURE_42896") + if http_error + else RuntimeError("PRIVATE_STREAM_FAILURE_42896") + ) + + async def upstream() -> AsyncGenerator[str, None]: + yield "data: started\n\n" + raise failure + + stream: Final = ProxyBaseLLMRequestProcessing.async_streaming_data_generator( + response=upstream(), + user_api_key_dict=ProxyUserAPIKeyAuth(), + request_data={"model": "example-model"}, + proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()), + serialize_chunk=str, + serialize_error=str, + ) + assert await anext(stream) == "data: started\n\n" + if http_error: + with pytest.raises(HTTPException) as raised: + await anext(stream) + assert raised.value is failure + else: + remaining: Final = tuple([chunk async for chunk in stream]) + assert len(remaining) == 1 + assert "PRIVATE_STREAM_FAILURE_42896" in remaining[0] + messages: Final = tuple(record.getMessage() for record in caplog.records if record.name == verbose_proxy_logger.name) + if privacy: + assert any(f"event=request_failed status={429 if http_error else 500}" in message for message in messages) + assert all("PRIVATE_STREAM_FAILURE_42896" not in message for message in messages) + else: + assert any("PRIVATE_STREAM_FAILURE_42896" in message for message in messages) + assert all("event=request_failed" not in message for message in messages) diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index e147fc41f7a..b6c2c1926c7 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -1827,6 +1827,20 @@ def test_print_verbose_formats_args_only_when_set_verbose_is_on(monkeypatch, cap assert capsys.readouterr().out == "key k1 value cached-vector\n" +@pytest.mark.parametrize("privacy", [False, True]) +@pytest.mark.parametrize("json_logs", [False, True]) +def test_startup_message_respects_diagnostic_privacy( + monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], privacy: bool, json_logs: bool +) -> None: + monkeypatch.setattr(litellm, "redact_messages_in_debug_logs", privacy) + monkeypatch.setattr(litellm, "json_logs", json_logs) + print_startup_message("PRIVATE_CONFIGURED_MODEL_42896") + out, err = capfd.readouterr() + message: Final = json.loads(out)["message"] if json_logs else out.strip() + assert message == ("redacted-by-litellm" if privacy else "PRIVATE_CONFIGURED_MODEL_42896") + assert err == "" + + def test_print_startup_message_prints_plain_mode_text_unchanged( monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str] ) -> None: