feat(proxy): add suppress_startup_output to turn off startup prints

This commit is contained in:
mateo-berri 2026-10-20 18:03:35 -07:00
parent 7d17f126e5
commit 12650bb8d9
8 changed files with 213 additions and 32 deletions

View file

@ -39,6 +39,7 @@ from litellm._logging import (
turn_on_json,
log_level,
)
from litellm._logging import suppress_startup_output as suppress_startup_output
import re
from litellm.constants import (
DEFAULT_BATCH_SIZE,

View file

@ -642,8 +642,8 @@ class LevelRoutingStreamHandler(logging.StreamHandler):
super().emit(record)
def _parse_json_logs_env(value: str | None) -> bool:
"""Strict opt-in parse for the JSON_LOGS env var: only "true" (any case) enables JSON logs.
def _parse_env_flag(value: str | None) -> bool:
"""Strict opt-in parse for a boolean env var such as JSON_LOGS: only "true" (any case) enables it.
Matches the reader in litellm-proxy-extras/_logging.py. The previous
bool(os.getenv(...)) treated any non-empty value, including "false" and "0",
@ -656,7 +656,8 @@ def resolve_log_level(log_level: str) -> int:
return getattr(logging, log_level.upper())
json_logs: Final = _parse_json_logs_env(os.getenv("JSON_LOGS"))
json_logs: Final = _parse_env_flag(os.getenv("JSON_LOGS"))
suppress_startup_output: Final = _parse_env_flag(os.getenv("LITELLM_SUPPRESS_STARTUP_OUTPUT"))
# Create a handler for the logger (you may need to adapt this based on your needs)
log_level: Final = os.getenv("LITELLM_LOG", "DEBUG")
numeric_level: Final[int] = resolve_log_level(log_level)
@ -904,14 +905,17 @@ _ANSI_ESCAPE: Final = re.compile(r"\x1b\[[0-9;]*m")
def print_startup_message(message: str) -> None:
"""Write a boot-time message meant for the operator's terminal.
Plain mode prints it unchanged, colors and spacing included. With JSON logs on, every line of
stdout is read by a log pipeline, so the message goes out as one INFO record of the proxy logger
instead, on stdout like the print whatever handlers the logging config gave that logger, with the
ANSI color codes and surrounding blank lines removed, located at the caller and emitted at any log
level; a blank spacer produces no record.
LITELLM_SUPPRESS_STARTUP_OUTPUT or litellm_settings.suppress_startup_output turns every one of them
off. Plain mode prints it unchanged, colors and spacing included, and flushes it so a piped stdout
shows it at once. With JSON logs on, every line of stdout is read by a log pipeline, so the message
goes out as one INFO record of the proxy logger instead, on stdout like the print whatever handlers
the logging config gave that logger, with the ANSI color codes and surrounding blank lines removed,
located at the caller and emitted at any log level; a blank spacer produces no record.
"""
if litellm.suppress_startup_output is True:
return
if not litellm.json_logs:
print(message) # noqa: T201 # plain mode: this is the operator-terminal print it replaces
print(message, flush=True) # noqa: T201 # plain mode: this is the operator-terminal print it replaces
return
text: Final = _ANSI_ESCAPE.sub("", message).strip("\n")
if not text:

View file

@ -215,9 +215,10 @@ def _print_enforce_prisma_migration_check_notice() -> None:
def _print_or_hold_enforce_prisma_migration_check_notice(
*, reads_config_file: bool, returns_before_config: bool
) -> Callable[[], None] | None:
"""Print the notice now, or, while a config file can still switch json_logs on, return a printer
to call once it is read; a boot that fails before that prints the held notice on the way out."""
if not reads_config_file or litellm.json_logs or returns_before_config:
"""Print the notice now, or, while a config file can still switch json_logs or suppress_startup_output
on, return a printer to call once it is read; a boot that fails before that prints the held notice on
the way out."""
if not reads_config_file or returns_before_config:
_print_enforce_prisma_migration_check_notice()
return None
print_once: Final = functools.cache(_print_enforce_prisma_migration_check_notice)
@ -1329,6 +1330,15 @@ def run_server(
os.environ["JSON_LOGS"] = "true"
litellm.turn_on_json()
if (
litellm_settings is not None
and "suppress_startup_output" in litellm_settings
and litellm_settings["suppress_startup_output"] is True
):
import litellm
litellm.suppress_startup_output = True
os.environ["LITELLM_SUPPRESS_STARTUP_OUTPUT"] = "true"
if held_prisma_notice is not None:
held_prisma_notice()
### GENERAL SETTINGS ###

View file

@ -9625,14 +9625,9 @@ async def initialize(
master_key, \
user_custom_auth, \
prisma_client
from litellm.proxy.common_utils.banner import LITELLM_BANNER, show_banner
from litellm.proxy.common_utils.banner import LITELLM_BANNER
# show_banner() also serves the `lite` shell, which runs against an uninitialized SDK (#46803), so
# the JSON-mode choice stays out of it.
if litellm.json_logs:
print_startup_message(LITELLM_BANNER)
else:
show_banner()
print_startup_message(f"\n{LITELLM_BANNER}\n")
if os.getenv("LITELLM_DONT_SHOW_FEEDBACK_BOX", "").lower() != "true":
generate_feedback_box()
user_model = model

View file

@ -0,0 +1,128 @@
"""The messages the proxy prints at startup outside its logger, as a container's log pipeline reads them.
A container runtime reads the proxy's stdout through a pipe and keeps every line. This contract boots an owned
proxy with a search tool configured and a one-second config reload interval, serves a chat request, lets the
search tools reload job run several times and reads everything the proxy wrote. With
LITELLM_SUPPRESS_STARTUP_OUTPUT or litellm_settings.suppress_startup_output on, none of the startup messages
arrive, in plain and JSON mode alike.
"""
from __future__ import annotations
import uuid
from collections.abc import Mapping
from pathlib import Path
from typing import Final
import pytest
import yaml
from pydantic import JsonValue
from tests.integration._support.client import Gateway, eventually
from tests.integration._support.process import owned_proxy_process
STARTED_WORKER: Final = "Started server process"
RELOAD_LINE: Final = "search tool(s) into router"
RELOADS_PER_WORKER: Final = 3
RELOAD_WAIT_SECONDS: Final = 60
SEARCH_TOOLS_HEADER: Final = "Proxy initialized with Search Tools:"
STARTUP_MESSAGES: Final = (
"██╗",
"Give Feedback / Get Help",
"Proxy initialized with Config, Set models:",
"Setting Cache on Proxy",
SEARCH_TOOLS_HEADER,
"Postgres connection budget",
)
CACHE_SETTINGS: Final[Mapping[str, JsonValue]] = {"cache": True, "cache_params": {"type": "redis"}}
def _lines_with(log: str, text: str) -> int:
return sum(text in line for line in log.splitlines())
def _write_config(
directory: Path, upstream_url: str, model: str, search_tool: str, litellm_settings: Mapping[str, JsonValue]
) -> Path:
config: Final = directory / f"startup_output_{uuid.uuid4().hex}.yaml"
config.write_text(
yaml.safe_dump(
{
"model_list": [
{
"model_name": model,
"litellm_params": {
"model": f"openai/{model}",
"api_base": f"{upstream_url}/v1",
"api_key": "integration-provider-key",
},
}
],
"search_tools": [
{
"search_tool_name": search_tool,
"litellm_params": {"search_provider": "tavily", "api_key": "integration-search-key"},
}
],
"litellm_settings": dict(litellm_settings),
"general_settings": {
"master_key": "os.environ/LITELLM_MASTER_KEY",
"database_url": "os.environ/DATABASE_URL",
"proxy_config_reload_interval_seconds": 1,
},
},
sort_keys=False,
)
)
return config
def _boot_reload_and_stop(
gateway: Gateway,
directory: Path,
litellm_settings: Mapping[str, JsonValue],
overrides: Mapping[str, str],
workers: int,
) -> tuple[str, str]:
stamp: Final = uuid.uuid4().hex
model: Final = f"startup-output-{stamp}"
search_tool: Final = f"startup-output-search-{stamp}"
config: Final = _write_config(directory, gateway.upstream_url, model, search_tool, litellm_settings)
with owned_proxy_process(
gateway,
directory,
{"LITELLM_LOG": "INFO", **overrides},
config=config,
workers=workers,
) as owned:
reply: Final = owned.gateway.chat(model, text=f"startup output {stamp}")
assert reply["model"] == model, reply
reloads: Final = eventually(
lambda: _lines_with(owned.log.read_text(), RELOAD_LINE),
lambda count: count >= RELOADS_PER_WORKER * workers,
seconds=RELOAD_WAIT_SECONDS,
return_last_on_timeout=True,
)
assert reloads >= RELOADS_PER_WORKER * workers, reloads
log: Final = owned.log
return log.read_text(), search_tool
@pytest.mark.parametrize(
("litellm_settings", "overrides"),
[
pytest.param(CACHE_SETTINGS, {"LITELLM_SUPPRESS_STARTUP_OUTPUT": "true"}, id="env-plain"),
pytest.param({**CACHE_SETTINGS, "json_logs": True, "suppress_startup_output": True}, {}, id="config-json"),
],
)
def test_suppress_startup_output_leaves_out_every_startup_message(
gateway: Gateway, tmp_path: Path, litellm_settings: Mapping[str, JsonValue], overrides: Mapping[str, str]
) -> None:
"""Two workers, so the banner and the feedback box each worker prints before reading the config are covered
when the setting is only in the config."""
workers: Final = 2
log, search_tool = _boot_reload_and_stop(gateway, tmp_path, litellm_settings, overrides, workers)
assert _lines_with(log, STARTED_WORKER) == workers, log
printed: Final = tuple(message for message in (*STARTUP_MESSAGES, search_tool) if message in log)
assert printed == (), (printed, log)

View file

@ -3960,10 +3960,11 @@ class TestJsonLogsConfigReachesSpawnedWorkers:
"""`litellm_settings.json_logs: true` turns JSON on inside the CLI process, but the workers that
uvicorn or granian spawn are fresh interpreters that pick plain or JSON from the JSON_LOGS
environment variable at import. The CLI has to export it, or every worker logs plain text until
its own load_config reaches the key."""
its own load_config reaches the key. `suppress_startup_output` has the same problem with the banner
each worker prints before its load_config."""
@staticmethod
def _json_logs_env_after_boot(config_path: Path, litellm_settings: dict[str, object]) -> str | None:
def _env_after_boot(config_path: Path, litellm_settings: dict[str, object], name: str = "JSON_LOGS") -> str | None:
config_path.write_text(yaml.dump({"model_list": [], "litellm_settings": litellm_settings}))
mock_proxy_config: Final = MagicMock()
mock_proxy_config.return_value.get_config = AsyncMock(return_value=yaml.safe_load(config_path.read_text()))
@ -3973,7 +3974,11 @@ class TestJsonLogsConfigReachesSpawnedWorkers:
KeyManagementSettings=MagicMock(),
save_worker_config=MagicMock(),
)
clean_env: Final = {k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "DIRECT_URL", "JSON_LOGS")}
clean_env: Final = {
k: v
for k, v in os.environ.items()
if k not in ("DATABASE_URL", "DIRECT_URL", "JSON_LOGS", "LITELLM_SUPPRESS_STARTUP_OUTPUT")
}
with (
patch.dict(os.environ, clean_env, clear=True),
patch.dict(
@ -3984,21 +3989,30 @@ class TestJsonLogsConfigReachesSpawnedWorkers:
patch("litellm.turn_on_json"), # test-quality-ok: it swaps every logger handler in this pytest worker
):
run_server.main(["--config", str(config_path), "--local", "--skip_server_startup"], standalone_mode=False)
return os.environ.get("JSON_LOGS")
return os.environ.get(name)
def test_json_logs_true_exports_json_logs_to_spawned_workers(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(litellm, "json_logs", False) # run_server flips it on; undone at teardown
assert self._json_logs_env_after_boot(tmp_path / "config.yaml", {"json_logs": True}) == "true"
assert self._env_after_boot(tmp_path / "config.yaml", {"json_logs": True}) == "true"
def test_plain_logs_config_leaves_json_logs_unset(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(litellm, "json_logs", False)
assert self._json_logs_env_after_boot(tmp_path / "config.yaml", {}) is None
assert self._env_after_boot(tmp_path / "config.yaml", {}) is None
@pytest.mark.parametrize(("setting", "exported"), [(True, "true"), ("true", None), (False, None)])
def test_only_a_boolean_suppress_startup_output_reaches_spawned_workers(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, setting: object, exported: str | None
) -> None:
monkeypatch.setattr(litellm, "suppress_startup_output", False)
settings: Final = {"suppress_startup_output": setting}
assert self._env_after_boot(tmp_path / "config.yaml", settings, "LITELLM_SUPPRESS_STARTUP_OUTPUT") == exported
class TestEnforcePrismaMigrationCheckNoticeAroundTheConfigRead:
"""With a config file, the `--enforce_prisma_migration_check` deprecation notice waits until the config
is read, so a `json_logs: true` set only there makes it a JSON record. It is printed once either way,
and a boot whose config read fails still prints it before the error, as it did before the wait."""
is read, so a `json_logs: true` set only there makes it a JSON record and a `suppress_startup_output: true`
set only there silences it. Otherwise it is printed once, and a boot whose config read fails still prints
it before the error, as it did before the wait."""
NOTICE: Final = "--enforce_prisma_migration_check is deprecated and has no effect"
@ -4055,6 +4069,18 @@ class TestEnforcePrismaMigrationCheckNoticeAroundTheConfigRead:
assert len(notice_lines) == 1, notice_lines
assert self.NOTICE in json.loads(notice_lines[0])["message"]
@pytest.mark.parametrize("json_logs", [False, True])
def test_a_config_only_suppress_startup_output_silences_the_notice(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], json_logs: bool
) -> None:
monkeypatch.setattr(litellm, "json_logs", json_logs)
monkeypatch.setattr(litellm, "suppress_startup_output", False)
config: Final[dict[str, object]] = {"model_list": [], "litellm_settings": {"suppress_startup_output": True}}
self._boot(tmp_path / "config.yaml", AsyncMock(return_value=config))
assert self.NOTICE not in capsys.readouterr().out
def test_granian_flag_notices_are_json_records_in_json_mode(monkeypatch, capfd):
"""The yellow notices for the flags Granian ignores (`--max_requests_before_restart`, `--ciphers`) are

View file

@ -15609,8 +15609,8 @@ def test_feedback_box_is_one_json_record_in_json_mode(monkeypatch, capfd):
@pytest.mark.asyncio
async def test_initialize_prints_the_banner_between_blank_lines_in_plain_mode(monkeypatch, capfd):
"""Operators without JSON logs see the banner exactly as before, through show_banner()'s flushing
click.echo."""
"""Operators without JSON logs see the banner exactly as before, flushed at once as show_banner()'s
click.echo did."""
monkeypatch.setattr(litellm, "json_logs", False)
monkeypatch.setenv("LITELLM_DONT_SHOW_FEEDBACK_BOX", "true")

View file

@ -31,7 +31,7 @@ from litellm._logging import (
StdoutLogTruncationFilter,
_get_uvicorn_json_log_config,
_initialize_loggers_with_handler,
_parse_json_logs_env,
_parse_env_flag,
_plain_log_format,
_stdout_truncation_marker,
print_startup_message,
@ -1442,9 +1442,9 @@ def test_level_routing_handler_falls_back_to_stderr_when_stdout_is_unusable(monk
(None, False),
],
)
def test_parse_json_logs_env_enables_only_on_true(value, want):
def test_parse_env_flag_enables_only_on_true(value: str | None, want: bool):
"""JSON_LOGS=false / 0 must not enable JSON logs (LIT-5558)."""
assert _parse_json_logs_env(value) is want
assert _parse_env_flag(value) is want
def test_plain_log_format_survives_none_streams():
@ -1838,6 +1838,23 @@ def test_print_startup_message_prints_plain_mode_text_unchanged(monkeypatch, cap
assert err == ""
@pytest.mark.parametrize("json_logs", [False, True])
def test_print_startup_message_writes_nothing_when_suppressed(
monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], json_logs: bool
):
"""suppress_startup_output turns the startup messages off in plain and JSON mode alike, for operators
whose log pipeline wants only the logger's own lines."""
monkeypatch.setattr(litellm, "json_logs", json_logs)
monkeypatch.setattr(litellm, "suppress_startup_output", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
if json_logs:
turn_on_json()
print_startup_message("\033[32mLiteLLM: Proxy initialized with Search Tools:\033[0m\n\033[32m web-search (tavily)\033[0m")
assert capfd.readouterr() == ("", "")
def test_print_startup_message_is_one_json_record_in_json_mode(monkeypatch, capfd):
"""With JSON logs on, a multi-line colored startup message is one INFO record with the colors and the
surrounding blank lines removed, so a log pipeline parses it like every other line. The logger sits