From 04eac6ababfd12a818063372dff07600fc38085b Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Tue, 25 Aug 2026 21:56:18 +0800 Subject: [PATCH] fix(logging): honor NO_COLOR / FORCE_COLOR / tty for non-JSON formatter The plain (non-JSON) formatter hardcoded ANSI color codes, so redirected logs and NO_COLOR-respecting environments got raw escape sequences. Add _should_use_color(): NO_COLOR (no-color.org) wins, FORCE_COLOR overrides, otherwise fall back to isatty() on the handler stream. Applied to the CorrelationPlainFormatter selection. Adds tests for the toggle. --- litellm/_logging.py | 20 ++++++++-- tests/documentation_tests/test_env_keys.py | 2 + tests/test_litellm/test_logging.py | 45 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/litellm/_logging.py b/litellm/_logging.py index 36fd51206c2..fa77a33871c 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -244,6 +244,17 @@ handler.addFilter(_secret_filter) handler.addFilter(_correlation_filter) +def _should_use_color(stream: object = None) -> bool: + # NO_COLOR (https://no-color.org/) wins. FORCE_COLOR overrides. Otherwise tty-detect. + if os.getenv("NO_COLOR"): + return False + if os.getenv("FORCE_COLOR"): + return True + if stream is None: + stream = sys.stderr + return bool(getattr(stream, "isatty", lambda: False)()) + + def _try_parse_json_message(message: str) -> dict[str, Any] | None: """ Try to parse a log message as JSON. Returns parsed dict if valid, else None. @@ -446,10 +457,11 @@ if json_logs: handler.setFormatter(JsonFormatter()) _setup_json_exception_handlers(JsonFormatter()) else: - formatter: Final = CorrelationPlainFormatter( - "\033[92m%(asctime)s - %(name)s:%(levelname)s\033[0m: %(filename)s:%(lineno)s - %(message)s", - datefmt="%H:%M:%S", - ) + if _should_use_color(handler.stream): + fmt = "\033[92m%(asctime)s - %(name)s:%(levelname)s\033[0m: %(filename)s:%(lineno)s - %(message)s" + else: + fmt = "%(asctime)s - %(name)s:%(levelname)s: %(filename)s:%(lineno)s - %(message)s" + formatter: Final = CorrelationPlainFormatter(fmt, datefmt="%H:%M:%S") handler.setFormatter(formatter) diff --git a/tests/documentation_tests/test_env_keys.py b/tests/documentation_tests/test_env_keys.py index b91c404b2eb..487c5e0952e 100644 --- a/tests/documentation_tests/test_env_keys.py +++ b/tests/documentation_tests/test_env_keys.py @@ -48,6 +48,8 @@ EXCLUDED_TERMINAL_VARS = { "WT_SESSION", "GNOME_TERMINAL_SCREEN", "ALACRITTY_SOCKET", + "NO_COLOR", + "FORCE_COLOR", } EXCLUDED_KEYS = frozenset(EXCLUDED_TERMINAL_VARS | EXCLUDED_GUARD_ONLY_VARS | EXCLUDED_ROLLOUT_FLAGS) diff --git a/tests/test_litellm/test_logging.py b/tests/test_litellm/test_logging.py index db8dfaa3ad6..e016a4fc4db 100644 --- a/tests/test_litellm/test_logging.py +++ b/tests/test_litellm/test_logging.py @@ -831,3 +831,48 @@ def test_set_session_id_bounds_length(): assert len(session_id_var.get()) == 256 finally: session_id_var.reset(token) + + +# --- color toggle (NO_COLOR / FORCE_COLOR / tty) ----------------------------- + + +class _FakeStream: + def __init__(self, is_tty: bool): + self._is_tty = is_tty + + def isatty(self) -> bool: + return self._is_tty + + +@pytest.fixture +def clean_color_env(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + +def test_should_use_color_no_color_env_disables(monkeypatch, clean_color_env): + from litellm._logging import _should_use_color + + monkeypatch.setenv("NO_COLOR", "1") + # Even on a tty + FORCE_COLOR, NO_COLOR must win. + monkeypatch.setenv("FORCE_COLOR", "1") + assert _should_use_color(_FakeStream(is_tty=True)) is False + + +def test_should_use_color_force_color_enables_on_pipe(monkeypatch, clean_color_env): + from litellm._logging import _should_use_color + + monkeypatch.setenv("FORCE_COLOR", "1") + assert _should_use_color(_FakeStream(is_tty=False)) is True + + +def test_should_use_color_tty_default_on(clean_color_env): + from litellm._logging import _should_use_color + + assert _should_use_color(_FakeStream(is_tty=True)) is True + + +def test_should_use_color_pipe_default_off(clean_color_env): + from litellm._logging import _should_use_color + + assert _should_use_color(_FakeStream(is_tty=False)) is False