From 12650bb8d931497a7660c72fa744cf2d13320384 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:03:35 -0700 Subject: [PATCH] feat(proxy): add suppress_startup_output to turn off startup prints --- litellm/__init__.py | 1 + litellm/_logging.py | 22 +-- litellm/proxy/proxy_cli.py | 16 ++- litellm/proxy/proxy_server.py | 9 +- .../configuration/test_startup_output.py | 128 ++++++++++++++++++ tests/unit/proxy/test_proxy_cli.py | 42 ++++-- ...test_proxy_server_endpoints_and_startup.py | 4 +- tests/unit/test_logging.py | 23 +++- 8 files changed, 213 insertions(+), 32 deletions(-) create mode 100644 tests/integration/configuration/test_startup_output.py diff --git a/litellm/__init__.py b/litellm/__init__.py index 1784deeb797..b823cf22fcc 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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, diff --git a/litellm/_logging.py b/litellm/_logging.py index 2039c3b4d12..02704cf1a15 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -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: diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index e4936ca5aac..529ac4c1805 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -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 ### diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 2f1f460fc8e..8476bc13fe9 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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 diff --git a/tests/integration/configuration/test_startup_output.py b/tests/integration/configuration/test_startup_output.py new file mode 100644 index 00000000000..d833dec525a --- /dev/null +++ b/tests/integration/configuration/test_startup_output.py @@ -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) diff --git a/tests/unit/proxy/test_proxy_cli.py b/tests/unit/proxy/test_proxy_cli.py index f8da4ce496b..697dbb503e1 100644 --- a/tests/unit/proxy/test_proxy_cli.py +++ b/tests/unit/proxy/test_proxy_cli.py @@ -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 diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index bcd010e3eed..0df308279ba 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -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") diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 16417981d55..aaedd17fe45 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -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