Skip to content

Commit db711cd

Browse files
GWealecopybara-github
authored andcommitted
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 <gweale@google.com> PiperOrigin-RevId: 843393874
1 parent 055dfc7 commit db711cd

File tree

2 files changed

+108
-0
lines changed

2 files changed

+108
-0
lines changed

src/google/adk/models/lite_llm.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import logging
2121
import os
2222
import re
23+
import sys
2324
from typing import Any
2425
from typing import AsyncGenerator
2526
from typing import cast
@@ -1369,6 +1370,30 @@ def _warn_gemini_via_litellm(model_string: str) -> None:
13691370
)
13701371

13711372

1373+
def _redirect_litellm_loggers_to_stdout() -> None:
1374+
"""Redirects LiteLLM loggers from stderr to stdout.
1375+
1376+
LiteLLM creates StreamHandlers that output to stderr by default. In cloud
1377+
environments like GCP, stderr output is treated as ERROR severity regardless
1378+
of the actual log level. This function redirects LiteLLM loggers to stdout
1379+
so that INFO-level logs are not incorrectly classified as errors.
1380+
"""
1381+
litellm_logger_names = ["LiteLLM", "LiteLLM Proxy", "LiteLLM Router"]
1382+
for logger_name in litellm_logger_names:
1383+
litellm_logger = logging.getLogger(logger_name)
1384+
for handler in litellm_logger.handlers:
1385+
if (
1386+
isinstance(handler, logging.StreamHandler)
1387+
and handler.stream is sys.stderr
1388+
):
1389+
handler.stream = sys.stdout
1390+
1391+
1392+
# Redirect LiteLLM loggers to stdout immediately after import to ensure
1393+
# INFO-level logs are not incorrectly treated as errors in cloud environments.
1394+
_redirect_litellm_loggers_to_stdout()
1395+
1396+
13721397
class LiteLlm(BaseLlm):
13731398
"""Wrapper around litellm.
13741399

tests/unittests/models/test_litellm.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the Licens
1414

15+
import contextlib
1516
import json
17+
import logging
18+
import os
19+
import sys
20+
import tempfile
21+
import unittest
1622
from unittest.mock import ANY
1723
from unittest.mock import AsyncMock
1824
from unittest.mock import Mock
@@ -29,6 +35,7 @@
2935
from google.adk.models.lite_llm import _model_response_to_chunk
3036
from google.adk.models.lite_llm import _model_response_to_generate_content_response
3137
from google.adk.models.lite_llm import _parse_tool_calls_from_text
38+
from google.adk.models.lite_llm import _redirect_litellm_loggers_to_stdout
3239
from google.adk.models.lite_llm import _schema_to_dict
3340
from google.adk.models.lite_llm import _split_message_content_and_tool_calls
3441
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):
31333140
assert "file_id" not in content[1]["file"]
31343141

31353142
mock_acreate_file.assert_not_called()
3143+
3144+
3145+
class TestRedirectLitellmLoggersToStdout(unittest.TestCase):
3146+
"""Tests for _redirect_litellm_loggers_to_stdout function."""
3147+
3148+
def test_redirects_stderr_handler_to_stdout(self):
3149+
"""Test that handlers pointing to stderr are redirected to stdout."""
3150+
test_logger = logging.getLogger("LiteLLM")
3151+
# Create a handler pointing to stderr
3152+
handler = logging.StreamHandler(sys.stderr)
3153+
test_logger.addHandler(handler)
3154+
3155+
try:
3156+
self.assertIs(handler.stream, sys.stderr)
3157+
3158+
_redirect_litellm_loggers_to_stdout()
3159+
3160+
self.assertIs(handler.stream, sys.stdout)
3161+
finally:
3162+
# Clean up
3163+
test_logger.removeHandler(handler)
3164+
3165+
def test_preserves_stdout_handler(self):
3166+
"""Test that handlers already pointing to stdout are not modified."""
3167+
test_logger = logging.getLogger("LiteLLM Proxy")
3168+
# Create a handler already pointing to stdout
3169+
handler = logging.StreamHandler(sys.stdout)
3170+
test_logger.addHandler(handler)
3171+
3172+
try:
3173+
_redirect_litellm_loggers_to_stdout()
3174+
3175+
self.assertIs(handler.stream, sys.stdout)
3176+
finally:
3177+
# Clean up
3178+
test_logger.removeHandler(handler)
3179+
3180+
def test_does_not_affect_non_stream_handlers(self):
3181+
"""Test that non-StreamHandler handlers are not affected."""
3182+
test_logger = logging.getLogger("LiteLLM Router")
3183+
# Create a FileHandler (not a StreamHandler)
3184+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
3185+
temp_file_name = temp_file.name
3186+
with contextlib.closing(
3187+
logging.FileHandler(temp_file_name)
3188+
) as file_handler:
3189+
test_logger.addHandler(file_handler)
3190+
3191+
try:
3192+
_redirect_litellm_loggers_to_stdout()
3193+
# FileHandler should not be modified (it doesn't point to stderr or stdout)
3194+
self.assertEqual(file_handler.baseFilename, temp_file_name)
3195+
finally:
3196+
# Clean up
3197+
test_logger.removeHandler(file_handler)
3198+
os.unlink(temp_file_name)
3199+
3200+
3201+
@pytest.mark.parametrize(
3202+
"logger_name",
3203+
["LiteLLM", "LiteLLM Proxy", "LiteLLM Router"],
3204+
ids=["LiteLLM", "LiteLLM Proxy", "LiteLLM Router"],
3205+
)
3206+
def test_handles_litellm_logger_names(logger_name):
3207+
"""Test that LiteLLM logger names are processed."""
3208+
test_logger = logging.getLogger(logger_name)
3209+
handler = logging.StreamHandler(sys.stderr)
3210+
test_logger.addHandler(handler)
3211+
3212+
try:
3213+
_redirect_litellm_loggers_to_stdout()
3214+
3215+
assert handler.stream is sys.stdout
3216+
finally:
3217+
# Clean up
3218+
test_logger.removeHandler(handler)

0 commit comments

Comments
 (0)