diff --git a/litellm/_logging.py b/litellm/_logging.py index a4d91e2bc11..34ae62154a8 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -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, diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 19d161b8aa4..9430dc3b50b 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -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]]: diff --git a/litellm/proxy/db/check_migration.py b/litellm/proxy/db/check_migration.py index 83cc4501466..d126f112f90 100644 --- a/litellm/proxy/db/check_migration.py +++ b/litellm/proxy/db/check_migration.py @@ -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, [] diff --git a/litellm/proxy/policy_engine/init_policies.py b/litellm/proxy/policy_engine/init_policies.py index 82e090a1522..3aad536396a 100644 --- a/litellm/proxy/policy_engine/init_policies.py +++ b/litellm/proxy/policy_engine/init_policies.py @@ -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() diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 529ac4c1805..09b5e750cb5 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -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 diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index df70b6f9f74..980866ccaab 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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", diff --git a/tests/unit/enterprise/integrations/test_prometheus.py b/tests/unit/enterprise/integrations/test_prometheus.py index 987cfa03a5e..fc4a729ec98 100644 --- a/tests/unit/enterprise/integrations/test_prometheus.py +++ b/tests/unit/enterprise/integrations/test_prometheus.py @@ -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 diff --git a/tests/unit/proxy/policy_engine/test_init_policies.py b/tests/unit/proxy/policy_engine/test_init_policies.py index 9a3a77f0c66..962f31dde64 100644 --- a/tests/unit/proxy/policy_engine/test_init_policies.py +++ b/tests/unit/proxy/policy_engine/test_init_policies.py @@ -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) diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index b4857257eae..07203138e75 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -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 = [ diff --git a/tests/unit/proxy/test_proxy_cli.py b/tests/unit/proxy/test_proxy_cli.py index 697dbb503e1..2571e4b21e2 100644 --- a/tests/unit/proxy/test_proxy_cli.py +++ b/tests/unit/proxy/test_proxy_cli.py @@ -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): diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index d1cd87d9c86..7b5c74735cc 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -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