From db711cd789cfcdd29e963188b15ccca13061cd5a Mon Sep 17 00:00:00 2001 From: George Weale Date: Thu, 11 Dec 2025 15:13:40 -0800 Subject: [PATCH] fix: Redirect LiteLLM logs from stderr to stdout LiteLLM's StreamHandlers output to stderr by default. In cloud environments like GCP, stderr output is treated as ERROR severity regardless of actual log level, causing INFO-level logs to be incorrectly classified as errors. This change redirects LiteLLM loggers to stdout in two places: - In `lite_llm.py`: Immediately after litellm import - In `logs.py`: When `setup_adk_logger()` is called (with guard to check if litellm is imported) Close #3824 Co-authored-by: George Weale PiperOrigin-RevId: 843393874 --- src/google/adk/models/lite_llm.py | 25 ++++++++ tests/unittests/models/test_litellm.py | 83 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index fd74a5e3..4234b408 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -20,6 +20,7 @@ import json import logging import os import re +import sys from typing import Any from typing import AsyncGenerator from typing import cast @@ -1369,6 +1370,30 @@ def _warn_gemini_via_litellm(model_string: str) -> None: ) +def _redirect_litellm_loggers_to_stdout() -> None: + """Redirects LiteLLM loggers from stderr to stdout. + + LiteLLM creates StreamHandlers that output to stderr by default. In cloud + environments like GCP, stderr output is treated as ERROR severity regardless + of the actual log level. This function redirects LiteLLM loggers to stdout + so that INFO-level logs are not incorrectly classified as errors. + """ + litellm_logger_names = ["LiteLLM", "LiteLLM Proxy", "LiteLLM Router"] + for logger_name in litellm_logger_names: + litellm_logger = logging.getLogger(logger_name) + for handler in litellm_logger.handlers: + if ( + isinstance(handler, logging.StreamHandler) + and handler.stream is sys.stderr + ): + handler.stream = sys.stdout + + +# Redirect LiteLLM loggers to stdout immediately after import to ensure +# INFO-level logs are not incorrectly treated as errors in cloud environments. +_redirect_litellm_loggers_to_stdout() + + class LiteLlm(BaseLlm): """Wrapper around litellm. diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 1043d415..8d61ebe6 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -12,7 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the Licens +import contextlib import json +import logging +import os +import sys +import tempfile +import unittest from unittest.mock import ANY from unittest.mock import AsyncMock from unittest.mock import Mock @@ -29,6 +35,7 @@ from google.adk.models.lite_llm import _message_to_generate_content_response from google.adk.models.lite_llm import _model_response_to_chunk from google.adk.models.lite_llm import _model_response_to_generate_content_response from google.adk.models.lite_llm import _parse_tool_calls_from_text +from google.adk.models.lite_llm import _redirect_litellm_loggers_to_stdout from google.adk.models.lite_llm import _schema_to_dict from google.adk.models.lite_llm import _split_message_content_and_tool_calls from google.adk.models.lite_llm import _to_litellm_response_format @@ -3133,3 +3140,79 @@ async def test_get_completion_inputs_non_openai_no_file_upload(mocker): assert "file_id" not in content[1]["file"] mock_acreate_file.assert_not_called() + + +class TestRedirectLitellmLoggersToStdout(unittest.TestCase): + """Tests for _redirect_litellm_loggers_to_stdout function.""" + + def test_redirects_stderr_handler_to_stdout(self): + """Test that handlers pointing to stderr are redirected to stdout.""" + test_logger = logging.getLogger("LiteLLM") + # Create a handler pointing to stderr + handler = logging.StreamHandler(sys.stderr) + test_logger.addHandler(handler) + + try: + self.assertIs(handler.stream, sys.stderr) + + _redirect_litellm_loggers_to_stdout() + + self.assertIs(handler.stream, sys.stdout) + finally: + # Clean up + test_logger.removeHandler(handler) + + def test_preserves_stdout_handler(self): + """Test that handlers already pointing to stdout are not modified.""" + test_logger = logging.getLogger("LiteLLM Proxy") + # Create a handler already pointing to stdout + handler = logging.StreamHandler(sys.stdout) + test_logger.addHandler(handler) + + try: + _redirect_litellm_loggers_to_stdout() + + self.assertIs(handler.stream, sys.stdout) + finally: + # Clean up + test_logger.removeHandler(handler) + + def test_does_not_affect_non_stream_handlers(self): + """Test that non-StreamHandler handlers are not affected.""" + test_logger = logging.getLogger("LiteLLM Router") + # Create a FileHandler (not a StreamHandler) + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file_name = temp_file.name + with contextlib.closing( + logging.FileHandler(temp_file_name) + ) as file_handler: + test_logger.addHandler(file_handler) + + try: + _redirect_litellm_loggers_to_stdout() + # FileHandler should not be modified (it doesn't point to stderr or stdout) + self.assertEqual(file_handler.baseFilename, temp_file_name) + finally: + # Clean up + test_logger.removeHandler(file_handler) + os.unlink(temp_file_name) + + +@pytest.mark.parametrize( + "logger_name", + ["LiteLLM", "LiteLLM Proxy", "LiteLLM Router"], + ids=["LiteLLM", "LiteLLM Proxy", "LiteLLM Router"], +) +def test_handles_litellm_logger_names(logger_name): + """Test that LiteLLM logger names are processed.""" + test_logger = logging.getLogger(logger_name) + handler = logging.StreamHandler(sys.stderr) + test_logger.addHandler(handler) + + try: + _redirect_litellm_loggers_to_stdout() + + assert handler.stream is sys.stdout + finally: + # Clean up + test_logger.removeHandler(handler)