fix(proxy): keep startup warnings when suppress_startup_output is on and apply it from CONFIG_FILE_PATH and before other settings

Warnings that name something the operator must fix now print through print_startup_warning, which
skips the suppress gate. The config key is read with the CLI's json_logs handling for --config and
CONFIG_FILE_PATH, applied before the rest of litellm_settings in load_config, and can only turn the
setting on. The Prometheus config panel obeys the setting, and startup prints use the plain log
format's color rule.
This commit is contained in:
mateo-berri 2026-10-20 20:30:28 -07:00
parent 19b928bf5e
commit bd16c04094
11 changed files with 230 additions and 70 deletions

View file

@ -10,10 +10,12 @@ import sys
from collections.abc import Iterator, Mapping
from datetime import datetime
from logging import Formatter
from types import FrameType
from typing import Final, TextIO
from urllib.parse import unquote
from pydantic import TypeAdapter
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm.constants import (
@ -622,9 +624,14 @@ def _color_enabled(stream: TextIO | None) -> bool:
return not os.environ.get("NO_COLOR") and _stream_is_tty(stream)
def _terminal_colors(stdout: TextIO | None, stderr: TextIO | None) -> bool:
"""True when both streams are an interactive color terminal, the one rule for log lines and startup prints."""
return _color_enabled(stdout) and _color_enabled(stderr)
def _plain_log_format(stdout: TextIO | None, stderr: TextIO | None) -> str:
"""The plain-text log format, colorized only when both streams are an interactive terminal."""
return _COLOR_LOG_FORMAT if _color_enabled(stdout) and _color_enabled(stderr) else _PLAIN_LOG_FORMAT
return _COLOR_LOG_FORMAT if _terminal_colors(stdout, stderr) else _PLAIN_LOG_FORMAT
class LevelRoutingStreamHandler(logging.StreamHandler):
@ -662,6 +669,16 @@ def resolve_log_level(log_level: str) -> int:
json_logs: Final = _parse_env_flag(os.getenv("JSON_LOGS"))
suppress_startup_output: Final = _parse_env_flag(os.getenv("LITELLM_SUPPRESS_STARTUP_OUTPUT"))
class StartupOutputSettings(TypedDict, total=False):
json_logs: ReadOnly[object]
suppress_startup_output: ReadOnly[object]
STARTUP_OUTPUT_SETTINGS: Final = TypeAdapter(StartupOutputSettings)
# 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)
@ -910,8 +927,9 @@ def print_startup_message(message: str) -> None:
"""Write a boot-time message meant for the operator's terminal.
LITELLM_SUPPRESS_STARTUP_OUTPUT or litellm_settings.suppress_startup_output turns every one of them
off. Plain mode prints it with its spacing, keeping the ANSI colors only when stdout is a color
terminal by the same check as the plain log format, so a container log never stores escape codes.
off; a warning goes through print_startup_warning instead. Plain mode prints it with its spacing,
keeping the ANSI colors only on a color terminal by the same check as the plain log format, so a
container log never stores escape codes.
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
@ -919,15 +937,25 @@ def print_startup_message(message: str) -> None:
"""
if litellm.suppress_startup_output is True:
return
frame: Final = inspect.currentframe()
_write_startup_message(message, frame.f_back if frame is not None else None)
def print_startup_warning(message: str) -> None:
"""Write a boot-time warning exactly as print_startup_message writes a message, even with
suppress_startup_output on, since it names something the operator has to fix."""
frame: Final = inspect.currentframe()
_write_startup_message(message, frame.f_back if frame is not None else None)
def _write_startup_message(message: str, caller: FrameType | None) -> None:
if not litellm.json_logs:
printed: Final = message if _color_enabled(sys.stdout) else _ANSI_ESCAPE.sub("", message)
printed: Final = message if _terminal_colors(sys.stdout, sys.stderr) else _ANSI_ESCAPE.sub("", message)
print(printed, 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:
return
frame: Final = inspect.currentframe()
caller: Final = frame.f_back if frame is not None else None
record: Final = verbose_proxy_logger.makeRecord(
verbose_proxy_logger.name,
logging.INFO,

View file

@ -1085,8 +1085,8 @@ class PrometheusLogger(CustomLogger):
# Build label filters from valid configurations
label_filters: Final = self._build_label_filters(parsed_configs)
# Pretty print the processed configuration
self._pretty_print_prometheus_config(label_filters)
if litellm.suppress_startup_output is not True:
self._pretty_print_prometheus_config(label_filters)
return label_filters
def _parse_exclude_config(self) -> tuple[frozenset[str], frozenset[str]]:

View file

@ -4,7 +4,7 @@ import os
import subprocess
from typing import Final
from litellm._logging import print_startup_message, verbose_logger
from litellm._logging import print_startup_message, print_startup_warning, verbose_logger
def extract_sql_commands(diff_output: str) -> list[str]:
@ -63,7 +63,7 @@ def check_prisma_schema_diff_helper(db_url: str) -> tuple[bool, list[str]]:
run_prisma,
)
except ImportError as e:
print_startup_message(
print_startup_warning(
f"Skipping the migration diff: litellm-proxy-extras has no Prisma runner. Error: {e}",
)
return False, []
@ -96,14 +96,14 @@ def check_prisma_schema_diff_helper(db_url: str) -> tuple[bool, list[str]]:
else:
return False, []
except subprocess.TimeoutExpired:
print_startup_message(
print_startup_warning(
f"Timed out after {timeout}s generating the migration diff. "
f"Raise {PRISMA_COMMAND_TIMEOUT_ENV_VAR} if this database needs longer.",
)
return False, []
except subprocess.CalledProcessError as e:
error_message: Final = f"Failed to generate migration diff. Error: {e.stderr}"
print_startup_message(error_message)
print_startup_warning(error_message)
return False, []

View file

@ -10,7 +10,7 @@ import sys
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Optional
from litellm._logging import print_startup_message, verbose_proxy_logger
from litellm._logging import print_startup_message, print_startup_warning, verbose_proxy_logger
from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry
from litellm.proxy.policy_engine.policy_registry import get_policy_registry
from litellm.proxy.policy_engine.policy_validator import PolicyValidator
@ -87,7 +87,7 @@ def _print_policies_on_startup(
print_startup_message("\n".join(lines))
else:
print_startup_message("\n".join(lines))
print_startup_message(
print_startup_warning(
f"\n{_yellow_color_code}Warning: No policy_attachments configured. Policies will not be applied to any requests.{_reset_color_code}\n",
)
sys.stdout.flush()

View file

@ -16,10 +16,11 @@ import click
import httpx
from click.core import ParameterSource
from dotenv import load_dotenv
from pydantic import ConfigDict
from pydantic import ConfigDict, TypeAdapter
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import print_startup_message
from litellm._logging import StartupOutputSettings, print_startup_message, print_startup_warning
from litellm.constants import DEFAULT_NUM_WORKERS_LITELLM_PROXY
from litellm.proxy.db.pgbouncer import (
PgBouncerError,
@ -205,7 +206,7 @@ def deprecated_v2_flag_passed_on_cli() -> bool:
def _print_enforce_prisma_migration_check_notice() -> None:
print_startup_message(
print_startup_warning(
"\033[1;33mLiteLLM Proxy: --enforce_prisma_migration_check is "
"deprecated and has no effect, because the proxy always exits "
"when database setup fails at startup. You can safely remove it.\033[0m",
@ -215,10 +216,9 @@ 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 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 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_enforce_prisma_migration_check_notice()
return None
print_once: Final = functools.cache(_print_enforce_prisma_migration_check_notice)
@ -228,6 +228,28 @@ def _print_or_hold_enforce_prisma_migration_check_notice(
return print_once
class _ConfigStartupOutputSettings(TypedDict, total=False):
litellm_settings: ReadOnly[StartupOutputSettings | None]
_CONFIG_STARTUP_OUTPUT_SETTINGS: Final = TypeAdapter(_ConfigStartupOutputSettings)
def _apply_config_logging_settings(litellm_settings: StartupOutputSettings | None) -> None:
"""Turn on the config's json_logs and suppress_startup_output before any worker starts. Workers that
uvicorn or granian spawn import litellm afresh and read both from the env at import, so they log JSON
and stay quiet from their first line instead of from when load_config reaches the key."""
if litellm_settings is None:
return
if litellm_settings.get("json_logs") is True:
litellm.json_logs = True
os.environ["JSON_LOGS"] = "true"
litellm.turn_on_json()
if litellm_settings.get("suppress_startup_output") is True:
litellm.suppress_startup_output = True
os.environ["LITELLM_SUPPRESS_STARTUP_OUTPUT"] = "true"
class ProxyInitializationHelpers:
@staticmethod
def echo_litellm_version():
@ -357,7 +379,7 @@ class ProxyInitializationHelpers:
if "timeout_worker_healthcheck" in inspect.signature(uvicorn.Config.__init__).parameters:
uvicorn_args["timeout_worker_healthcheck"] = timeout_worker_healthcheck
else:
print_startup_message(
print_startup_warning(
f"\033[1;33mLiteLLM Proxy: --timeout_worker_healthcheck "
f"requires uvicorn>=0.37.0, but installed uvicorn=={uvicorn.__version__}. "
f"Ignoring the flag.\033[0m",
@ -380,7 +402,7 @@ class ProxyInitializationHelpers:
import uvicorn
if max_requests_before_restart is None:
print_startup_message(
print_startup_warning(
"\033[1;33mLiteLLM Proxy: --max_requests_before_restart_jitter "
"has no effect without --max_requests_before_restart\033[0m\n",
)
@ -388,7 +410,7 @@ class ProxyInitializationHelpers:
if "limit_max_requests_jitter" in inspect.signature(uvicorn.Config.__init__).parameters:
uvicorn_args["limit_max_requests_jitter"] = jitter
else:
print_startup_message(
print_startup_warning(
f"\033[1;33mLiteLLM Proxy: --max_requests_before_restart_jitter "
f"requires uvicorn>=0.41.0, but installed uvicorn=={uvicorn.__version__}. "
f"Ignoring the flag.\033[0m",
@ -555,12 +577,12 @@ class ProxyInitializationHelpers:
print_startup_message(f"\033[1;32mLiteLLM Proxy: Starting server on {host}:{port} using Granian\033[0m\n")
if max_requests_before_restart is not None:
print_startup_message(
print_startup_warning(
"\033[1;33mLiteLLM: --max_requests_before_restart is not supported by Granian "
"(Granian uses workers_lifetime in seconds, not a per-request limit).\033[0m\n",
)
if ciphers is not None:
print_startup_message("\033[1;33mLiteLLM: --ciphers is not applied when using --run_granian.\033[0m\n")
print_startup_warning("\033[1;33mLiteLLM: --ciphers is not applied when using --run_granian.\033[0m\n")
kwargs: Final[dict[str, object]] = {
"target": "litellm.proxy.proxy_server:app",
@ -682,7 +704,7 @@ class ProxyInitializationHelpers:
gunicorn_options["max_requests"] = max_requests_before_restart
if max_requests_before_restart_jitter is not None:
if max_requests_before_restart is None:
print_startup_message(
print_startup_warning(
"\033[1;33mLiteLLM Proxy: --max_requests_before_restart_jitter "
"has no effect without --max_requests_before_restart\033[0m\n",
)
@ -723,7 +745,7 @@ class ProxyInitializationHelpers:
with open(os.devnull, "w") as devnull:
subprocess.Popen(command, stdout=devnull, stderr=devnull)
except Exception as e:
print_startup_message(f"""
print_startup_warning(f"""
LiteLLM Warning: proxy started with `ollama` model\n`ollama serve` failed with Exception{e}. \nEnsure you run `ollama serve`
""")
@ -1202,9 +1224,11 @@ def run_server(
if validate_config is True:
ProxyInitializationHelpers.run_config_validation(config)
return
env_config_file_path: Final = (os.environ.get("CONFIG_FILE_PATH") or None) if config is None else None
held_prisma_notice: Final = (
_print_or_hold_enforce_prisma_migration_check_notice(
reads_config_file=config is not None, returns_before_config=health is True or test is True
reads_config_file=config is not None or env_config_file_path is not None,
returns_before_config=health is True or test is True,
)
if enforce_prisma_migration_check
else None
@ -1316,29 +1340,9 @@ def run_server(
### LITELLM SETTINGS ###
litellm_settings = _config.get("litellm_settings", None)
if (
litellm_settings is not None
and "json_logs" in litellm_settings
and litellm_settings["json_logs"] is True
):
import litellm
litellm.json_logs = True
# Workers that uvicorn or granian spawn import litellm afresh and read this at import,
# so they log JSON from their first record instead of plain text until load_config
# reaches the json_logs key.
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"
_apply_config_logging_settings(
_CONFIG_STARTUP_OUTPUT_SETTINGS.validate_python(_config).get("litellm_settings")
)
if held_prisma_notice is not None:
held_prisma_notice()
### GENERAL SETTINGS ###
@ -1402,6 +1406,16 @@ def run_server(
os.chdir(original_dir)
if database_url is not None and isinstance(database_url, str):
os.environ["DATABASE_URL"] = database_url
elif env_config_file_path is not None:
import asyncio
_apply_config_logging_settings(
_CONFIG_STARTUP_OUTPUT_SETTINGS.validate_python(
asyncio.run(ProxyConfig().get_config(config_file_path=env_config_file_path))
).get("litellm_settings")
)
if held_prisma_notice is not None:
held_prisma_notice()
# Handle database URL construction when no config file is used
if config is None and os.getenv("DATABASE_URL") is None:
@ -1539,7 +1553,7 @@ def run_server(
env_value=os.getenv("USE_V2_MIGRATION_RESOLVER"),
)
if deprecated_v2_flag_passed_on_cli() and use_v2_resolver:
print_startup_message(
print_startup_warning(
"\033[1;33mLiteLLM Proxy: --use_v2_migration_resolver is "
"deprecated and has no effect, because the v2 migration "
"resolver is now the default. You can safely remove it. To "
@ -1547,7 +1561,7 @@ def run_server(
"--use_legacy_migration_resolver.\033[0m",
)
if not use_v2_resolver:
print_startup_message(
print_startup_warning(
"\033[1;33mLiteLLM Proxy: Using the legacy (v1) migration "
"resolver. It performs the diff-and-force recovery that can "
"cause schema thrashing during rolling deploys where two "
@ -1661,7 +1675,7 @@ def run_server(
run_gunicorn=run_gunicorn is True,
)
if single_worker_warning is not None:
print_startup_message(f"\033[1;33m{single_worker_warning}\033[0m\n")
print_startup_warning(f"\033[1;33m{single_worker_warning}\033[0m\n")
if run_gunicorn is False and run_hypercorn is False and run_granian is False:
if limit_concurrency is not None:
uvicorn_args["limit_concurrency"] = limit_concurrency

View file

@ -304,6 +304,7 @@ import litellm._redis
from litellm import Router
from litellm._internal_context import service_target, with_service_target
from litellm._logging import (
STARTUP_OUTPUT_SETTINGS,
print_startup_message,
redact_string,
verbose_proxy_logger,
@ -6549,6 +6550,8 @@ class ProxyConfig:
from litellm.types.integrations.prometheus import validate_caller_identity_settings
validate_caller_identity_settings(litellm_settings)
if STARTUP_OUTPUT_SETTINGS.validate_python(litellm_settings).get("suppress_startup_output") is True:
litellm.suppress_startup_output = True
# ANSI escape code for blue text
blue_color_code: Final = "\033[94m"
@ -6870,6 +6873,8 @@ class ProxyConfig:
setattr(litellm, key, value)
elif key == "drop_params":
litellm.drop_params = drop_params_flag(value, "litellm_settings.drop_params", verbose_proxy_logger)
elif key == "suppress_startup_output":
continue
else:
verbose_proxy_logger.debug(
"%s setting litellm.%s=%s%s",

View file

@ -217,6 +217,26 @@ def test_future_metrics_with_end_user_are_filtered():
litellm.enable_end_user_cost_tracking_prometheus_only = original_setting
@pytest.mark.parametrize("suppressed", [False, True])
def test_prometheus_config_panel_obeys_suppress_startup_output(
monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], suppressed: bool
):
"""The processed-config panel is a startup print like the banner, so suppress_startup_output turns it off
while the label filters still load."""
clear_prometheus_registry()
monkeypatch.setattr(litellm, "suppress_startup_output", suppressed)
monkeypatch.setattr(
litellm,
"prometheus_metrics_config",
[{"group": "service_metrics", "metrics": ["litellm_deployment_total_requests"], "include_labels": ["team"]}],
)
label_filters = PrometheusLogger()._parse_prometheus_config()
assert label_filters["litellm_deployment_total_requests"] == ["team"]
assert ("Prometheus Configuration Processed" in capfd.readouterr().out) is not suppressed
def test_prometheus_config_parsing():
"""Test that prometheus metrics configuration is parsed correctly"""
# Clear registry before test

View file

@ -42,7 +42,7 @@ NO_ATTACHMENTS_WARNING: Final = (
)
class _Stdout(StringIO):
class _Stream(StringIO):
def __init__(self, terminal: bool) -> None:
super().__init__()
self._terminal = terminal
@ -75,8 +75,9 @@ def test_policy_table_prints_unchanged_in_plain_mode(
without color codes on a piped stdout."""
monkeypatch.setattr(litellm, "json_logs", False)
monkeypatch.delenv("NO_COLOR", raising=False)
stdout: Final = _Stdout(terminal)
stdout: Final = _Stream(terminal)
monkeypatch.setattr(sys, "stdout", stdout)
monkeypatch.setattr(sys, "stderr", _Stream(terminal))
_print_policies_on_startup(POLICIES, attachments)

View file

@ -2117,6 +2117,52 @@ async def test_ProxyConfig_lists_search_tools_once_at_startup_not_on_every_repar
assert out.count("Proxy initialized with Search Tools:") == 1, out
assert out.count("web-search (tavily)") == 1, out
@pytest.mark.asyncio
@pytest.mark.parametrize(
("already_on", "config_value"),
[
pytest.param(True, False, id="env-var-on-and-config-false"),
pytest.param(False, True, id="config-key-below-cache"),
],
)
async def test_ProxyConfig_load_config_keeps_startup_output_off_once_either_setting_turns_it_on(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capfd: pytest.CaptureFixture[str],
already_on: bool,
config_value: bool,
):
"""Only `true` turns suppress_startup_output on, from the env var or the config key, and a config `false`
never turns the env var back off. The key applies before any other litellm_settings key, so a `cache: true`
above it prints nothing either."""
config_file: Final = tmp_path / "suppress.yaml"
config_file.write_text(
"model_list: []\n"
"litellm_settings:\n"
" cache: true\n"
" cache_params:\n"
" type: local\n"
f" suppress_startup_output: {str(config_value).lower()}\n"
"search_tools:\n"
" - search_tool_name: web-search\n"
" litellm_params:\n"
" search_provider: tavily\n"
)
monkeypatch.setattr(litellm, "json_logs", False)
monkeypatch.setattr(litellm, "suppress_startup_output", already_on)
monkeypatch.setattr(litellm, "cache", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False)
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
await ProxyConfig().load_config(router=None, config_file_path=str(config_file))
out: Final = capfd.readouterr().out
assert litellm.suppress_startup_output is True
assert "Setting Cache on Proxy" not in out, out
assert "Proxy initialized with Search Tools:" not in out, out
def test_ProxyConfig_merge_config_and_db_search_tools_returns_superset():
config_tools = [

View file

@ -3964,7 +3964,12 @@ class TestJsonLogsConfigReachesSpawnedWorkers:
each worker prints before its load_config."""
@staticmethod
def _env_after_boot(config_path: Path, litellm_settings: dict[str, object], name: str = "JSON_LOGS") -> str | None:
def _env_after_boot(
config_path: Path,
litellm_settings: dict[str, object],
name: str = "JSON_LOGS",
from_config_file_path_env: bool = False,
) -> 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()))
@ -3977,10 +3982,12 @@ class TestJsonLogsConfigReachesSpawnedWorkers:
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")
if k not in ("DATABASE_URL", "DIRECT_URL", "JSON_LOGS", "LITELLM_SUPPRESS_STARTUP_OUTPUT", "CONFIG_FILE_PATH")
}
config_env: Final = {"CONFIG_FILE_PATH": str(config_path)} if from_config_file_path_env else {}
config_args: Final = [] if from_config_file_path_env else ["--config", str(config_path)]
with (
patch.dict(os.environ, clean_env, clear=True),
patch.dict(os.environ, {**clean_env, **config_env}, clear=True),
patch.dict(
"sys.modules",
{"proxy_server": mock_proxy_module, "litellm.proxy.proxy_server": mock_proxy_module},
@ -3988,7 +3995,7 @@ class TestJsonLogsConfigReachesSpawnedWorkers:
patch("atexit.register"),
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)
run_server.main([*config_args, "--local", "--skip_server_startup"], standalone_mode=False)
return os.environ.get(name)
def test_json_logs_true_exports_json_logs_to_spawned_workers(self, tmp_path: Path, monkeypatch) -> None:
@ -4007,12 +4014,21 @@ class TestJsonLogsConfigReachesSpawnedWorkers:
settings: Final = {"suppress_startup_output": setting}
assert self._env_after_boot(tmp_path / "config.yaml", settings, "LITELLM_SUPPRESS_STARTUP_OUTPUT") == exported
@pytest.mark.parametrize("name", ["JSON_LOGS", "LITELLM_SUPPRESS_STARTUP_OUTPUT"])
def test_a_config_named_by_CONFIG_FILE_PATH_reaches_spawned_workers_too(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str
) -> None:
monkeypatch.setattr(litellm, "json_logs", False)
monkeypatch.setattr(litellm, "suppress_startup_output", False)
settings: Final[dict[str, object]] = {"json_logs": True, "suppress_startup_output": True}
assert self._env_after_boot(tmp_path / "config.yaml", settings, name, from_config_file_path_env=True) == "true"
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 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."""
is read, so a `json_logs: true` set only there makes it a JSON record, while `suppress_startup_output: true`
leaves it, since it is a warning. 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"
@ -4070,7 +4086,7 @@ class TestEnforcePrismaMigrationCheckNoticeAroundTheConfigRead:
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(
def test_a_config_only_suppress_startup_output_keeps_the_notice(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], json_logs: bool
) -> None:
monkeypatch.setattr(litellm, "json_logs", json_logs)
@ -4079,7 +4095,7 @@ class TestEnforcePrismaMigrationCheckNoticeAroundTheConfigRead:
self._boot(tmp_path / "config.yaml", AsyncMock(return_value=config))
assert self.NOTICE not in capsys.readouterr().out
assert capsys.readouterr().out.count(self.NOTICE) == 1
def test_granian_flag_notices_are_json_records_in_json_mode(monkeypatch, capfd):

View file

@ -35,6 +35,7 @@ from litellm._logging import (
_plain_log_format,
_stdout_truncation_marker,
print_startup_message,
print_startup_warning,
turn_on_json,
format_base64_size,
session_id_var,
@ -1863,14 +1864,15 @@ class _CapturedStream(StringIO):
@pytest.mark.parametrize(
"stdout_tty, no_color, want_color",
[(True, None, True), (False, None, False), (True, "1", False)],
"stdout_tty, stderr_tty, no_color, want_color",
[(True, True, None, True), (False, True, None, False), (True, False, None, False), (True, True, "1", False)],
)
def test_print_startup_message_keeps_colors_only_on_a_color_terminal(
monkeypatch: pytest.MonkeyPatch, stdout_tty: bool, no_color: str | None, want_color: bool
monkeypatch: pytest.MonkeyPatch, stdout_tty: bool, stderr_tty: bool, no_color: str | None, want_color: bool
):
"""Operators without JSON logs keep the colored, spaced output on a terminal. A container runtime reads
stdout through a pipe and stores the escape codes as text, so there, and under NO_COLOR, the colors go."""
stdout through a pipe and stores the escape codes as text, so there, and under NO_COLOR, the colors go.
The rule is the plain log format's, so a `2>file` run drops the colors from prints and log lines alike."""
monkeypatch.setattr(litellm, "json_logs", False)
if no_color is None:
monkeypatch.delenv("NO_COLOR", raising=False)
@ -1878,6 +1880,8 @@ def test_print_startup_message_keeps_colors_only_on_a_color_terminal(
monkeypatch.setenv("NO_COLOR", no_color)
stdout: Final = _CapturedStream(stdout_tty)
monkeypatch.setattr(sys, "stdout", stdout)
monkeypatch.setattr(sys, "stderr", _CapturedStream(stderr_tty))
assert (_plain_log_format(sys.stdout, sys.stderr) == _COLOR_LOG_FORMAT) is want_color
print_startup_message("\n\033[1;32mLiteLLM: Proxy initialized with Config, Set models:\033[0m\n")
@ -1905,6 +1909,32 @@ def test_print_startup_message_writes_nothing_when_suppressed(
assert capfd.readouterr() == ("", "")
@pytest.mark.parametrize("json_logs", [False, True])
def test_print_startup_warning_still_prints_when_suppressed(
monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], json_logs: bool
):
"""A warning names something the operator has to fix, like an ignored flag or a failed migration diff,
so suppress_startup_output leaves it in place, at the caller's location in JSON mode."""
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_warning("\033[1;33mLiteLLM: --ciphers is not applied when using --run_granian.\033[0m\n")
out, err = capfd.readouterr()
assert err == ""
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert len(lines) == 1, lines
if json_logs:
record: Final = json.loads(lines[0])
assert record["message"] == "LiteLLM: --ciphers is not applied when using --run_granian."
assert str(record["logger"]).startswith("test_logging.py:"), record["logger"]
else:
assert lines[0] == "LiteLLM: --ciphers is not applied when using --run_granian."
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