mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
feat(proxy): add suppress_startup_output to turn off startup prints
This commit is contained in:
parent
7d17f126e5
commit
12650bb8d9
8 changed files with 213 additions and 32 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
128
tests/integration/configuration/test_startup_output.py
Normal file
128
tests/integration/configuration/test_startup_output.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue