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.
This commit is contained in:
Chenglun Hu 2026-08-25 21:56:18 +08:00
parent 31a67561ab
commit 04eac6abab
3 changed files with 63 additions and 4 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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