This commit is contained in:
yucheng-berri 2026-10-26 18:48:55 -04:00 • committed by GitHub
commit b8db50f6d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1174 additions and 105 deletions

View file

@ -1,6 +1,7 @@
import ast
import contextvars
import functools
import inspect
import itertools
import logging
import os
@ -897,6 +898,61 @@ verbose_proxy_logger.addFilter(_diagnostic_filter)
verbose_proxy_stdout_logger.addFilter(_diagnostic_filter)
verbose_logger.addFilter(_diagnostic_filter)
_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.
"""
if not litellm.json_logs:
print(message) # 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,
caller.f_code.co_filename if caller is not None else __file__,
caller.f_lineno if caller is not None else 0,
text,
(),
None,
func=caller.f_code.co_name if caller is not None else None,
)
# The print this replaces shows at any log level, so the record skips the level checks of the
# proxy logger (at the root's WARNING in the CLI process until initialize() runs) and of its JSON
# handler (LITELLM_LOG). It also skips the logger's stdout cap: that cap bounds echoed request
# payloads, while a startup message is operator text the print showed whole (the required migration
# SQL, the policy list). It skips secret redaction too, which the print never had and whose sk-
# pattern turns a model named risk-assessment-gpt4o into riREDACTED. The processed record counts as
# scrubbed, so the handler's secret filter and the formatter leave it alone too.
if not _process_record(record, base64_limit=_get_max_base64_length_stdout_log(), text_limit=0, redact=False):
return
# The print wrote to stdout whatever the logging config said, so the record goes through the proxy's
# own JSON handler on stdout rather than the proxy logger's handlers: uvicorn applies an operator
# --log_config after proxy_cli's turn_on_json() and before initialize() re-installs the JSON handler
# through load_config(), and in that window, where the banner and the feedback box are emitted, a
# config that names "LiteLLM Proxy" has left the logger with no handler or with the operator's own.
_startup_json_handler().handle(record)
def _startup_json_handler() -> logging.Handler:
"""The proxy's JSON handler on stdout, filters included, for one startup message."""
handler: Final = LevelRoutingStreamHandler()
handler.setFormatter(JsonFormatter())
handler.addFilter(_secret_filter)
handler.addFilter(_correlation_filter)
return handler
def _suppress_loggers():
"""Suppress noisy loggers at INFO level"""

View file

@ -4,7 +4,7 @@ import os
import subprocess
from typing import Final
from litellm._logging import verbose_logger
from litellm._logging import print_startup_message, verbose_logger
def extract_sql_commands(diff_output: str) -> list[str]:
@ -63,8 +63,8 @@ def check_prisma_schema_diff_helper(db_url: str) -> tuple[bool, list[str]]:
run_prisma,
)
except ImportError as e:
print( # noqa: T201 # boot-time operator output, same channel as this helper's other messages
f"Skipping the migration diff: litellm-proxy-extras has no Prisma runner. Error: {e}"
print_startup_message(
f"Skipping the migration diff: litellm-proxy-extras has no Prisma runner. Error: {e}",
)
return False, []
@ -89,22 +89,21 @@ def check_prisma_schema_diff_helper(db_url: str) -> tuple[bool, list[str]]:
sql_commands: Final = extract_sql_commands(result.stdout)
if sql_commands:
print("Changes to DB Schema detected") # noqa: T201
print("Required SQL commands:") # noqa: T201
for command in sql_commands:
print(command) # noqa: T201
print_startup_message(
"\n".join(("Changes to DB Schema detected", "Required SQL commands:", *sql_commands)),
)
return True, sql_commands
else:
return False, []
except subprocess.TimeoutExpired:
print( # noqa: T201 # boot-time operator output, same channel as this helper's other messages
print_startup_message(
f"Timed out after {timeout}s generating the migration diff. "
f"Raise {PRISMA_COMMAND_TIMEOUT_ENV_VAR} if this database needs longer."
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(error_message) # noqa: T201
print_startup_message(error_message)
return False, []

View file

@ -6,10 +6,11 @@ Configuration structure:
- policy_attachments: Define WHERE policies apply (teams, keys, models)
"""
import sys
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Optional
from litellm._logging import verbose_proxy_logger
from litellm._logging import print_startup_message, 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
@ -32,12 +33,9 @@ def _print_policies_on_startup(
"""
Print loaded policies to console on startup (similar to model list).
"""
import sys
print( # noqa: T201
lines: Final = [
f"{_green_color_code}\nLiteLLM Policy Engine: Loaded {len(policies_config)} policies{_reset_color_code}\n"
)
sys.stdout.flush()
]
for policy_name, policy_data in policies_config.items():
guardrails = policy_data.get("guardrails", {})
@ -49,23 +47,21 @@ def _print_policies_on_startup(
guardrails_remove = guardrails.get("remove", []) if isinstance(guardrails, dict) else []
inherit_str = f" (inherits: {inherit})" if inherit else ""
print( # noqa: T201
f"{_blue_color_code} - {policy_name}{inherit_str}{_reset_color_code}"
)
lines.append(f"{_blue_color_code} - {policy_name}{inherit_str}{_reset_color_code}")
if description:
print(f" description: {description}") # noqa: T201
lines.append(f" description: {description}")
if guardrails_add:
print(f" guardrails.add: {guardrails_add}") # noqa: T201
lines.append(f" guardrails.add: {guardrails_add}")
if guardrails_remove:
print(f" guardrails.remove: {guardrails_remove}") # noqa: T201
lines.append(f" guardrails.remove: {guardrails_remove}")
if condition:
model_condition = condition.get("model") if isinstance(condition, dict) else None
if model_condition:
print(f" condition.model: {model_condition}") # noqa: T201
lines.append(f" condition.model: {model_condition}")
# Print attachments
if policy_attachments_config:
print( # noqa: T201
lines.append(
f"\n{_yellow_color_code}Policy Attachments: {len(policy_attachments_config)} attachment(s){_reset_color_code}"
)
for attachment in policy_attachments_config:
@ -86,13 +82,14 @@ def _print_policies_on_startup(
scope_parts.append(f"models={models}")
scope_str = ", ".join(scope_parts) if scope_parts else "all"
print(f" - {policy} -> {scope_str}") # noqa: T201
lines.append(f" - {policy} -> {scope_str}")
lines.append("")
print_startup_message("\n".join(lines))
else:
print( # noqa: T201
f"\n{_yellow_color_code}Warning: No policy_attachments configured. Policies will not be applied to any requests.{_reset_color_code}"
print_startup_message("\n".join(lines))
print_startup_message(
f"\n{_yellow_color_code}Warning: No policy_attachments configured. Policies will not be applied to any requests.{_reset_color_code}\n",
)
print() # noqa: T201
sys.stdout.flush()

View file

@ -1,4 +1,5 @@
# ruff: noqa: T201
import functools
import importlib
import json
import os
@ -7,7 +8,7 @@ import re
import subprocess
import sys
import urllib.parse as urlparse
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any, Final
@ -18,6 +19,7 @@ from dotenv import load_dotenv
from pydantic import ConfigDict
import litellm
from litellm._logging import print_startup_message
from litellm.constants import DEFAULT_NUM_WORKERS_LITELLM_PROXY
from litellm.proxy.db.pgbouncer import (
PgBouncerError,
@ -202,6 +204,29 @@ def deprecated_v2_flag_passed_on_cli() -> bool:
return ctx.get_parameter_source("use_v2_migration_resolver") is ParameterSource.COMMANDLINE
def _print_enforce_prisma_migration_check_notice() -> None:
print_startup_message(
"\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",
)
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_enforce_prisma_migration_check_notice()
return None
print_once: Final = functools.cache(_print_enforce_prisma_migration_check_notice)
ctx: Final = click.get_current_context(silent=True)
if ctx is not None:
ctx.call_on_close(print_once)
return print_once
class ProxyInitializationHelpers:
@staticmethod
def echo_litellm_version():
@ -318,7 +343,7 @@ class ProxyInitializationHelpers:
"timeout_graceful_shutdown": GracefulShutdownManager.get_timeout(),
}
if log_config is not None:
print(f"Using log_config: {log_config}")
print_startup_message(f"Using log_config: {log_config}")
uvicorn_args["log_config"] = log_config
elif litellm.json_logs:
# Use JSON log config for uvicorn to ensure all logs (including exceptions) are JSON
@ -331,10 +356,10 @@ class ProxyInitializationHelpers:
if "timeout_worker_healthcheck" in inspect.signature(uvicorn.Config.__init__).parameters:
uvicorn_args["timeout_worker_healthcheck"] = timeout_worker_healthcheck
else:
print(
print_startup_message(
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"
f"Ignoring the flag.\033[0m",
)
return uvicorn_args
@ -354,18 +379,18 @@ class ProxyInitializationHelpers:
import uvicorn
if max_requests_before_restart is None:
print(
print_startup_message(
"\033[1;33mLiteLLM Proxy: --max_requests_before_restart_jitter "
"has no effect without --max_requests_before_restart\033[0m\n"
"has no effect without --max_requests_before_restart\033[0m\n",
)
return
if "limit_max_requests_jitter" in inspect.signature(uvicorn.Config.__init__).parameters:
uvicorn_args["limit_max_requests_jitter"] = jitter
else:
print(
print_startup_message(
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"
f"Ignoring the flag.\033[0m",
)
_apply_uvicorn_max_requests_jitter = apply_uvicorn_max_requests_jitter
@ -489,12 +514,12 @@ class ProxyInitializationHelpers:
from hypercorn.asyncio import serve
from hypercorn.config import Config
print(f"\033[1;32mLiteLLM Proxy: Starting server on {host}:{port} using Hypercorn\033[0m\n")
print_startup_message(f"\033[1;32mLiteLLM Proxy: Starting server on {host}:{port} using Hypercorn\033[0m\n")
config: Final = Config()
config.bind = [f"{host}:{port}"]
if ssl_certfile_path is not None and ssl_keyfile_path is not None:
print(
print_startup_message(
f"\033[1;32mLiteLLM Proxy: Using SSL with certfile: {ssl_certfile_path} and keyfile: {ssl_keyfile_path}\033[0m\n"
)
config.certfile = ssl_certfile_path
@ -527,14 +552,14 @@ class ProxyInitializationHelpers:
from granian import Granian
from granian.constants import Interfaces
print(f"\033[1;32mLiteLLM Proxy: Starting server on {host}:{port} using Granian\033[0m\n")
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(
print_startup_message(
"\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"
"(Granian uses workers_lifetime in seconds, not a per-request limit).\033[0m\n",
)
if ciphers is not None:
print("\033[1;33mLiteLLM: --ciphers is not applied when using --run_granian.\033[0m\n")
print_startup_message("\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",
@ -547,7 +572,7 @@ class ProxyInitializationHelpers:
if granian_runtime_threads is not None:
kwargs["runtime_threads"] = granian_runtime_threads
if ssl_certfile_path is not None and ssl_keyfile_path is not None:
print(
print_startup_message(
f"\033[1;32mLiteLLM Proxy: Using SSL with certfile: {ssl_certfile_path} and keyfile: {ssl_keyfile_path}\033[0m\n"
)
kwargs["ssl_cert"] = Path(ssl_certfile_path)
@ -608,13 +633,15 @@ class ProxyInitializationHelpers:
\n
"""
)
print()
print(
print_startup_message("")
print_startup_message(
'\033[1;34mLiteLLM: Test your local proxy with: "litellm --test" This runs an openai.ChatCompletion request to your proxy [In a new terminal tab]\033[0m\n'
)
print(f"\033[1;34mLiteLLM: Curl Command Test for your local proxy\n {curl_command} \033[0m\n")
print("\033[1;34mDocs: https://docs.litellm.ai/docs/simple_proxy\033[0m\n")
print(f"\033[1;34mSee all Router/Swagger docs on http://0.0.0.0:{port} \033[0m\n")
print_startup_message(
f"\033[1;34mLiteLLM: Curl Command Test for your local proxy\n {curl_command} \033[0m\n"
)
print_startup_message("\033[1;34mDocs: https://docs.litellm.ai/docs/simple_proxy\033[0m\n")
print_startup_message(f"\033[1;34mSee all Router/Swagger docs on http://0.0.0.0:{port} \033[0m\n")
def load_config(self):
# note: This Loads the gunicorn config - has nothing to do with LiteLLM Proxy config
@ -634,7 +661,9 @@ class ProxyInitializationHelpers:
# gunicorn app function
return self.application
print(f"\033[1;32mLiteLLM Proxy: Starting server on {host}:{port} with {num_workers} workers\033[0m\n")
print_startup_message(
f"\033[1;32mLiteLLM Proxy: Starting server on {host}:{port} with {num_workers} workers\033[0m\n"
)
graceful_shutdown_timeout: Final = GracefulShutdownManager.get_timeout()
gunicorn_options: Final = {
"bind": f"{host}:{port}",
@ -652,9 +681,9 @@ 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(
print_startup_message(
"\033[1;33mLiteLLM Proxy: --max_requests_before_restart_jitter "
"has no effect without --max_requests_before_restart\033[0m\n"
"has no effect without --max_requests_before_restart\033[0m\n",
)
else:
gunicorn_options["max_requests_jitter"] = max_requests_before_restart_jitter
@ -669,7 +698,7 @@ class ProxyInitializationHelpers:
gunicorn_options["child_exit"] = child_exit
if ssl_certfile_path is not None and ssl_keyfile_path is not None:
print(
print_startup_message(
f"\033[1;32mLiteLLM Proxy: Using SSL with certfile: {ssl_certfile_path} and keyfile: {ssl_keyfile_path}\033[0m\n"
)
gunicorn_options["certfile"] = ssl_certfile_path
@ -693,7 +722,7 @@ class ProxyInitializationHelpers:
with open(os.devnull, "w") as devnull:
subprocess.Popen(command, stdout=devnull, stderr=devnull)
except Exception as e:
print(f"""
print_startup_message(f"""
LiteLLM Warning: proxy started with `ollama` model\n`ollama serve` failed with Exception{e}. \nEnsure you run `ollama serve`
""")
@ -762,7 +791,7 @@ class ProxyInitializationHelpers:
os.makedirs(multiproc_dir, exist_ok=True)
wipe_directory(multiproc_dir)
action: Final = "Using existing" if configured_dir else "Auto-created"
print(f"LiteLLM: {action} PROMETHEUS_MULTIPROC_DIR={multiproc_dir}")
print_startup_message(f"LiteLLM: {action} PROMETHEUS_MULTIPROC_DIR={multiproc_dir}")
return multiproc_dir
_maybe_setup_prometheus_multiproc_dir = maybe_setup_prometheus_multiproc_dir
@ -1172,12 +1201,13 @@ def run_server(
if validate_config is True:
ProxyInitializationHelpers.run_config_validation(config)
return
if enforce_prisma_migration_check:
print(
"\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"
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
)
if enforce_prisma_migration_check
else None
)
if model and "ollama" in model and api_base is None:
ProxyInitializationHelpers.run_ollama_serve()
if health is True:
@ -1293,8 +1323,14 @@ def run_server(
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 held_prisma_notice is not None:
held_prisma_notice()
### GENERAL SETTINGS ###
general_settings = _config.get("general_settings", {})
if general_settings is None:
@ -1461,7 +1497,7 @@ def run_server(
lifetime_params,
)
)
print(
print_startup_message(
postgres_connection_budget_message(
writer_limit=_url_query_value(os.getenv("DATABASE_URL"), "connection_limit")
or f"{db_connection_pool_limit}",
@ -1493,19 +1529,19 @@ def run_server(
env_value=os.getenv("USE_V2_MIGRATION_RESOLVER"),
)
if deprecated_v2_flag_passed_on_cli() and use_v2_resolver:
print(
print_startup_message(
"\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 "
"opt back into the legacy v1 resolver, pass "
"--use_legacy_migration_resolver.\033[0m"
"--use_legacy_migration_resolver.\033[0m",
)
if not use_v2_resolver:
print(
print_startup_message(
"\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 "
"LiteLLM versions contend for the same DB.\033[0m"
"LiteLLM versions contend for the same DB.\033[0m",
)
try:
migrated: Final = PrismaManager.setup_database(
@ -1573,7 +1609,7 @@ def run_server(
# Skip server startup if requested (after all setup is done)
if skip_server_startup:
print("LiteLLM: Setup complete. Skipping server startup as requested.")
print_startup_message("LiteLLM: Setup complete. Skipping server startup as requested.")
return
# Auto-create PROMETHEUS_MULTIPROC_DIR for multi-worker setups
@ -1592,7 +1628,7 @@ def run_server(
)
except MetricsServerStartupError as error:
raise click.ClickException(str(error)) from error
print(
print_startup_message(
f"\033[1;32mLiteLLM: Serving Prometheus metrics on {host}:{prometheus_metrics_port}/metrics "
f"(pid {metrics_process.pid})\033[0m"
)
@ -1615,7 +1651,7 @@ def run_server(
run_gunicorn=run_gunicorn is True,
)
if single_worker_warning is not None:
print(f"\033[1;33m{single_worker_warning}\033[0m\n")
print_startup_message(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
@ -1626,7 +1662,7 @@ def run_server(
jitter=max_requests_before_restart_jitter,
)
if ssl_certfile_path is not None and ssl_keyfile_path is not None:
print(
print_startup_message(
f"\033[1;32mLiteLLM Proxy: Using SSL with certfile: {ssl_certfile_path} and keyfile: {ssl_keyfile_path}\033[0m\n"
)
uvicorn_args["ssl_keyfile"] = ssl_keyfile_path

View file

@ -273,25 +273,27 @@ def generate_feedback_box():
# Select a random message
message: Final = random.choice(list_of_messages)
print() # noqa: T201
print("\033[1;37m" + "#" + "-" * box_width + "#\033[0m") # noqa: T201
print("\033[1;37m" + "#" + " " * box_width + "#\033[0m") # noqa: T201
print("\033[1;37m" + f"# {message:^59} #\033[0m") # noqa: T201
print( # noqa: T201
"\033[1;37m" + "# {:^59} #\033[0m".format("https://github.com/BerriAI/litellm/issues/new")
print_startup_message(
"\n".join(
(
"",
"\033[1;37m" + "#" + "-" * box_width + "#\033[0m",
"\033[1;37m" + "#" + " " * box_width + "#\033[0m",
"\033[1;37m" + f"# {message:^59} #\033[0m",
"\033[1;37m" + "# {:^59} #\033[0m".format("https://github.com/BerriAI/litellm/issues/new"),
"\033[1;37m" + "#" + " " * box_width + "#\033[0m",
"\033[1;37m" + "#" + "-" * box_width + "#\033[0m",
"",
" Thank you for using LiteLLM! - Krrish & Ishaan",
"",
"",
"",
"\033[1;31mGive Feedback / Get Help: https://github.com/BerriAI/litellm/issues/new\033[0m",
"",
"",
)
)
)
print("\033[1;37m" + "#" + " " * box_width + "#\033[0m") # noqa: T201
print("\033[1;37m" + "#" + "-" * box_width + "#\033[0m") # noqa: T201
print() # noqa: T201
print(" Thank you for using LiteLLM! - Krrish & Ishaan") # noqa: T201
print() # noqa: T201
print() # noqa: T201
print() # noqa: T201
print( # noqa: T201
"\033[1;31mGive Feedback / Get Help: https://github.com/BerriAI/litellm/issues/new\033[0m"
)
print() # noqa: T201
print() # noqa: T201
import contextlib
@ -304,6 +306,7 @@ import litellm._redis
from litellm import Router
from litellm._internal_context import service_target, with_service_target
from litellm._logging import (
print_startup_message,
redact_string,
verbose_proxy_logger,
verbose_router_logger,
@ -6412,17 +6415,13 @@ class ProxyConfig:
search_tools_parsed: Final[list[SearchToolTypedDict]] = []
print( # noqa: T201
"\033[32mLiteLLM: Proxy initialized with Search Tools:\033[0m"
)
print_startup_message("\033[32mLiteLLM: Proxy initialized with Search Tools:\033[0m")
for search_tool in search_tools_raw:
# Display loaded search tool
search_tool_name = search_tool.get("search_tool_name", "")
search_provider = search_tool.get("litellm_params", {}).get("search_provider", "")
print( # noqa: T201
f"\033[32m {search_tool_name} ({search_provider})\033[0m"
)
print_startup_message(f"\033[32m {search_tool_name} ({search_provider})\033[0m")
# Handle os.environ/ variables in litellm_params
litellm_params = search_tool.get("litellm_params", {})
@ -6598,7 +6597,7 @@ class ProxyConfig:
reset_color_code: Final = "\033[0m"
for key, value in litellm_settings.items():
if key == "cache" and value is True:
print(f"{blue_color_code}\nSetting Cache on Proxy") # noqa: T201
print_startup_message(f"{blue_color_code}\nSetting Cache on Proxy")
from litellm.caching.caching import Cache
cache_params = {}
@ -6789,7 +6788,7 @@ class ProxyConfig:
if PrometheusLogger is not None:
verbose_proxy_logger.debug("mounting metrics endpoint")
PrometheusLogger._mount_metrics_endpoint()
print( # noqa: T201
print_startup_message(
f"{blue_color_code} Initialized Success Callbacks - {litellm.success_callback} {reset_color_code}"
)
elif key == "failure_callback":
@ -6808,7 +6807,7 @@ class ProxyConfig:
# these are litellm callbacks - "langfuse", "sentry", "wandb"
else:
litellm.logging_callback_manager.add_litellm_failure_callback(callback)
print( # noqa: T201
print_startup_message(
f"{blue_color_code} Initialized Failure Callbacks - {litellm.failure_callback} {reset_color_code}"
)
elif key == "audit_log_callbacks":
@ -6833,7 +6832,7 @@ class ProxyConfig:
_store_audit_logs = litellm_settings.get("store_audit_logs", litellm.store_audit_logs)
if is_audit_logging_enabled(store_audit_logs=_store_audit_logs):
print( # noqa: T201
print_startup_message(
f"{blue_color_code} Initialized Audit Log Callbacks - {litellm.audit_log_callbacks} {reset_color_code}"
)
else:
@ -7232,9 +7231,7 @@ class ProxyConfig:
if model_list:
router_params["model_list"] = model_list
validate_auto_router_capability_limits(model_list, limit=_license_check.auto_router_capability_limit())
print( # noqa: T201
"\033[32mLiteLLM: Proxy initialized with Config, Set models:\033[0m"
)
print_startup_message("\033[32mLiteLLM: Proxy initialized with Config, Set models:\033[0m")
for model in model_list:
### LOAD FROM os.environ/ ###
for k, v in model["litellm_params"].items():
@ -7251,7 +7248,7 @@ class ProxyConfig:
complexity_router_config=complexity_router_config,
config_file_path=config_file_path,
)
print(f"\033[32m {model.get('model_name', '')}\033[0m") # noqa: T201
print_startup_message(f"\033[32m {model.get('model_name', '')}\033[0m")
litellm_model_name = model["litellm_params"]["model"]
litellm_model_api_base = model["litellm_params"].get("api_base", None)
if "ollama" in litellm_model_name and litellm_model_api_base is None:
@ -9672,9 +9669,14 @@ async def initialize(
master_key, \
user_custom_auth, \
prisma_client
from litellm.proxy.common_utils.banner import show_banner
from litellm.proxy.common_utils.banner import LITELLM_BANNER, show_banner
show_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()
if os.getenv("LITELLM_DONT_SHOW_FEEDBACK_BOX", "").lower() != "true":
generate_feedback_box()
user_model = model
@ -11930,7 +11932,7 @@ class ProxyStartupEvent:
and proxy_logging_obj.slack_alerting_instance.alerting is not None
and prisma_client is not None
):
print("Alerting: Initializing Weekly/Monthly Spend Reports") # noqa: T201
print_startup_message("Alerting: Initializing Weekly/Monthly Spend Reports")
spend_report_frequency: Final[str] = general_settings.get("spend_report_frequency", "7d") or "7d"
days: Final = int(spend_report_frequency[:-1])

View file

@ -987,6 +987,7 @@ def refused_boot_log(
overrides: Mapping[str, str],
*,
config: Path | None = None,
extra_arguments: tuple[str, ...] = (),
) -> str:
"""Start the proxy and return its log once it exits non-zero instead of becoming ready."""
root: Final = _proxy_root()
@ -1004,6 +1005,7 @@ def refused_boot_log(
"--num_workers",
"1",
*DB_PUSH,
*extra_arguments,
)
launch: Final = _launch(command, root, environment, output)
try:

View file

@ -0,0 +1,409 @@
"""JSON logging keeps every line the proxy writes during boot, serving and shutdown machine-parseable.
A deployment that turns on JSON logs ships the proxy's stdout to a log pipeline, and every line that is
not a JSON object breaks that pipeline's parser. This contract boots an owned proxy with JSON logs on,
serves a chat request on every configured model plus one rejected request, stops the proxy and
rejects any line of its output that is not one JSON record, while checking that the
startup information those lines used to carry is still present as JSON records. JSON mode is switched
on through the environment and through the config, at one and two uvicorn workers, at the INFO, the
default and the WARNING log level, under a concurrent burst that lands on both workers, under an operator
--log_config that names the proxy logger or gives it a handler of its own, and with a policy list longer
than the stdout cap. The model list keeps a model name the secret patterns would match. The deprecated
--enforce_prisma_migration_check notice is one JSON record under a config-only json_logs, and a boot whose
config cannot be loaded still prints it before failing.
"""
from __future__ import annotations
import re
import uuid
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Final
import pytest
import yaml
from pydantic import JsonValue, TypeAdapter, ValidationError
from tests.integration._support.client import Gateway
from tests.integration._support.process import owned_proxy_process, refused_boot_log
JSON_RECORD: Final = TypeAdapter(dict[str, JsonValue])
PLAIN_LOG_LINE: Final = re.compile(r"^\d\d:\d\d:\d\d - LiteLLM")
# prisma-client-py starts the Prisma query engine on the proxy's own stdout, and the engine announces
# its build hash there in two writes (the text, then the newline), so with two workers the two
# announcements land glued together or glued to a neighbor's line. The text is not litellm's and is
# removed before the lines are read.
PRISMA_ENGINE_TEXT: Final = re.compile(r"query-engine [0-9a-f]{40}")
STARTED_WORKER: Final = "Started server process"
MODEL_LIST_HEADER: Final = "Proxy initialized with Config, Set models:"
CACHE_LINE: Final = "Setting Cache on Proxy"
# printed by the CLI process before any worker exists, so it proves the launcher itself logs JSON
CONNECTION_BUDGET_LINE: Final = "Postgres connection budget"
REDIS_AUTH_CACHE_LINE: Final = "enable_redis_auth_cache"
BANNER_FRAGMENT: Final = "██╗"
FEEDBACK_BOX_LINE: Final = "Give Feedback / Get Help"
WORKER_COUNTS: Final = (1, 2)
UNKNOWN_MODEL_STATUS: Final = 400
BURST_SIZE: Final = 16
CACHE_SETTINGS: Final[Mapping[str, JsonValue]] = {"cache": True, "cache_params": {"type": "redis"}}
# the policy list and the required migration SQL are the startup listings that outgrow the stdout cap
# (MAX_STRING_LENGTH_STDOUT_LOG, 4096 by default) that bounds echoed request payloads
STDOUT_CAP: Final = 4096
POLICY_COUNT: Final = 60
POLICY_LIST_HEADER: Final = "LiteLLM Policy Engine: Loaded"
TRUNCATION_MARKER: Final = "litellm_truncated"
DEPRECATED_FLAG: Final = "--enforce_prisma_migration_check"
DEPRECATED_FLAG_NOTICE: Final = "--enforce_prisma_migration_check is deprecated and has no effect"
UNPARSEABLE_CONFIG: Final = "model_list: [\n - bad: yaml: here\n"
# an operator logging config of the usual shape: it sets the proxy logger's level and gives it no handler
# of its own, and formats everything else, uvicorn included, as JSON through the root handler
OPERATOR_LOG_CONFIG: Final[Mapping[str, JsonValue]] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"()": "litellm._logging.JsonFormatter"}},
"handlers": {"stdout": {"class": "logging.StreamHandler", "stream": "ext://sys.stdout", "formatter": "json"}},
"loggers": {"LiteLLM Proxy": {"level": "INFO"}},
"root": {"level": "INFO", "handlers": ["stdout"]},
}
# an operator logging config that silences the proxy logger with a handler of its own and keeps the rest JSON
OPERATOR_SILENCING_LOG_CONFIG: Final[Mapping[str, JsonValue]] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"()": "litellm._logging.JsonFormatter"}},
"handlers": {
"stdout": {"class": "logging.StreamHandler", "stream": "ext://sys.stdout", "formatter": "json"},
"silence": {"class": "logging.NullHandler"},
},
"loggers": {"LiteLLM Proxy": {"level": "INFO", "handlers": ["silence"], "propagate": False}},
"root": {"level": "INFO", "handlers": ["stdout"]},
}
def _models() -> tuple[str, str]:
stamp: Final = uuid.uuid4().hex
return (f"json-logs-first-{stamp}", f"json-logs-second-{stamp}")
def _policies() -> dict[str, JsonValue]:
stamp: Final = uuid.uuid4().hex
return {
f"startup-listing-policy-{n:02d}-{stamp}": {
"description": f"policy {n:02d} of a listing that runs past the stdout cap meant for request payloads"
}
for n in range(POLICY_COUNT)
}
def _write_config(
directory: Path,
upstream_url: str,
models: tuple[str, ...],
litellm_settings: Mapping[str, JsonValue],
extra_sections: Mapping[str, JsonValue] | None = None,
) -> Path:
config: Final = directory / f"json_logs_{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",
},
}
for model in models
],
"litellm_settings": dict(litellm_settings),
"general_settings": {
"master_key": "os.environ/LITELLM_MASTER_KEY",
"database_url": "os.environ/DATABASE_URL",
"disable_prisma_schema_update": True,
},
**(extra_sections or {}),
},
sort_keys=False,
)
)
return config
def _record(line: str) -> dict[str, JsonValue] | None:
stripped: Final = line.strip()
if not stripped.startswith("{"):
return None
try:
return JSON_RECORD.validate_json(stripped)
except ValidationError:
return None
def _lines(log: str) -> tuple[str, ...]:
return tuple(line for line in PRISMA_ENGINE_TEXT.sub("", log).splitlines() if line.strip())
def _messages(log: str) -> tuple[str, ...]:
records: Final = tuple(record for record in (_record(line) for line in _lines(log)) if record is not None)
return tuple(str(record["message"]) for record in records if "message" in record)
def _non_json_lines(log: str) -> tuple[str, ...]:
return tuple(line for line in _lines(log) if _record(line) is None)
def _boot_serve_and_stop(
gateway: Gateway,
directory: Path,
config: Path,
overrides: Mapping[str, str],
workers: int,
models: tuple[str, ...],
extra_arguments: tuple[str, ...] = (),
) -> str:
with owned_proxy_process(
gateway,
directory,
overrides,
config=config,
workers=workers,
database_setup=(),
extra_arguments=extra_arguments,
) as owned:
_serve(owned.gateway, models)
log: Final = owned.log
return log.read_text()
def _serve(gateway: Gateway, models: tuple[str, ...]) -> None:
"""One chat per model and one rejected request, so the log holds the request-time lines of served
and failed requests next to the boot lines."""
for model in models:
reply: Final = gateway.chat(model, text=f"json logs {uuid.uuid4().hex}")
assert reply["model"] == model, reply
rejected: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": f"missing-{uuid.uuid4().hex}", "messages": [{"role": "user", "content": "json logs"}]},
)
assert rejected.status_code == UNKNOWN_MODEL_STATUS, rejected.text
def _assert_every_line_is_json(
log: str, workers: int, models: tuple[str, ...], *, redis_auth_cache_line: bool = True
) -> None:
plaintext: Final = _non_json_lines(log)
assert plaintext == (), "plaintext lines with JSON logs on:\n" + "\n".join(plaintext)
messages: Final = _messages(log)
assert sum(STARTED_WORKER in message for message in messages) == workers, messages
assert any(MODEL_LIST_HEADER in message for message in messages), messages
for model in models:
assert any(model in message for message in messages), (model, messages)
assert any(CACHE_LINE in message for message in messages), messages
if redis_auth_cache_line:
assert any(REDIS_AUTH_CACHE_LINE in message for message in messages), messages
assert any(CONNECTION_BUDGET_LINE in message for message in messages), messages
assert sum(BANNER_FRAGMENT in message for message in messages) == workers, messages
assert sum(FEEDBACK_BOX_LINE in message for message in messages) == workers, messages
@pytest.mark.parametrize("workers", WORKER_COUNTS)
def test_json_logs_env_keeps_every_line_json(gateway: Gateway, tmp_path: Path, workers: int) -> None:
models: Final = _models()
config: Final = _write_config(tmp_path, gateway.upstream_url, models, CACHE_SETTINGS)
log: Final = _boot_serve_and_stop(
gateway, tmp_path, config, {"JSON_LOGS": "true", "LITELLM_LOG": "INFO"}, workers, models
)
_assert_every_line_is_json(log, workers, models)
@pytest.mark.parametrize("workers", WORKER_COUNTS)
def test_json_logs_config_keeps_every_line_json(gateway: Gateway, tmp_path: Path, workers: int) -> None:
models: Final = _models()
# cache is listed before json_logs on purpose: the cache branch logs the Redis auth-cache line
# while the config is still being read, so JSON mode must not depend on the key order.
config: Final = _write_config(tmp_path, gateway.upstream_url, models, {**CACHE_SETTINGS, "json_logs": True})
log: Final = _boot_serve_and_stop(gateway, tmp_path, config, {"LITELLM_LOG": "INFO"}, workers, models)
_assert_every_line_is_json(log, workers, models)
@pytest.mark.parametrize("workers", WORKER_COUNTS)
def test_json_logs_with_operator_log_config_keeps_every_line_json(
gateway: Gateway, tmp_path: Path, workers: int
) -> None:
"""uvicorn applies an operator `--log_config` after the CLI process switched JSON on and before
`initialize()` switches it on again through the config; a config that names the proxy logger leaves it
without handlers in that window, where the banner and the feedback box are emitted. They still arrive,
as JSON, one per worker, and the operator's own formatter keeps uvicorn's lines JSON. The Redis
auth-cache line is a regular logger line written in that same window; this log_config removed the
proxy logger's handlers and left propagation off, so it is lost at one worker on main and here alike."""
models: Final = _models()
config: Final = _write_config(tmp_path, gateway.upstream_url, models, {**CACHE_SETTINGS, "json_logs": True})
log_config: Final = tmp_path / "operator_log_config.yaml"
log_config.write_text(yaml.safe_dump(dict(OPERATOR_LOG_CONFIG)))
log: Final = _boot_serve_and_stop(
gateway, tmp_path, config, {"LITELLM_LOG": "INFO"}, workers, models, ("--log_config", str(log_config))
)
_assert_every_line_is_json(log, workers, models, redis_auth_cache_line=False)
@pytest.mark.parametrize("workers", WORKER_COUNTS)
def test_json_logs_with_operator_handler_on_the_proxy_logger_keeps_the_startup_notices(
gateway: Gateway, tmp_path: Path, workers: int
) -> None:
"""An operator `--log_config` that gives the proxy logger a handler of its own, here a NullHandler that
silences it, is in force in the window where the banner and the feedback box are emitted. The prints
they replace wrote to stdout whatever the logging config said, so both still arrive there as JSON, one
per worker. The Redis auth-cache line is a regular logger line written in that same window and goes
to the operator's handler, so it is silenced on main and here alike."""
models: Final = _models()
config: Final = _write_config(tmp_path, gateway.upstream_url, models, {**CACHE_SETTINGS, "json_logs": True})
log_config: Final = tmp_path / "operator_silencing_log_config.yaml"
log_config.write_text(yaml.safe_dump(dict(OPERATOR_SILENCING_LOG_CONFIG)))
log: Final = _boot_serve_and_stop(
gateway, tmp_path, config, {"LITELLM_LOG": "INFO"}, workers, models, ("--log_config", str(log_config))
)
_assert_every_line_is_json(log, workers, models, redis_auth_cache_line=False)
def test_json_logs_keeps_a_startup_listing_longer_than_the_stdout_cap_whole(gateway: Gateway, tmp_path: Path) -> None:
"""A startup listing longer than MAX_STRING_LENGTH_STDOUT_LOG reaches the pipeline whole. The policy list
stands in for every such listing (the required migration SQL on a drifted database is the other one):
the print it replaces showed it whole, and the stdout cap exists for echoed request payloads, so the JSON
record must not be middle-truncated."""
models: Final = _models()
policies: Final = _policies()
config: Final = _write_config(
tmp_path, gateway.upstream_url, models, {**CACHE_SETTINGS, "json_logs": True}, {"policies": policies}
)
log: Final = _boot_serve_and_stop(gateway, tmp_path, config, {"LITELLM_LOG": "INFO"}, 1, models)
_assert_every_line_is_json(log, 1, models)
listings: Final = tuple(message for message in _messages(log) if POLICY_LIST_HEADER in message)
assert len(listings) == 1, listings
assert len(listings[0]) > STDOUT_CAP, len(listings[0])
assert TRUNCATION_MARKER not in listings[0], listings[0]
for policy in policies:
assert policy in listings[0], (policy, listings[0])
def test_json_logs_model_list_keeps_a_model_name_the_secret_patterns_would_match(
gateway: Gateway, tmp_path: Path
) -> None:
"""The model list printed every model name as configured. The sk- key pattern matches inside a name such as
`risk-assessment-...`, so the JSON record must carry that name as the print did, not `riREDACTED`."""
models: Final = (f"risk-assessment-{uuid.uuid4().hex}",)
config: Final = _write_config(tmp_path, gateway.upstream_url, models, {**CACHE_SETTINGS, "json_logs": True})
log: Final = _boot_serve_and_stop(gateway, tmp_path, config, {"LITELLM_LOG": "INFO"}, 1, models)
_assert_every_line_is_json(log, 1, models)
assert f" {models[0]}" in _messages(log), _messages(log)
def test_json_logs_default_level_keeps_every_line_json(gateway: Gateway, tmp_path: Path) -> None:
"""Without LITELLM_LOG the proxy loggers sit at WARNING; the startup notices a plain-mode operator
always sees still arrive, as JSON, and nothing arrives as plaintext."""
models: Final = _models()
config: Final = _write_config(tmp_path, gateway.upstream_url, models, {**CACHE_SETTINGS, "json_logs": True})
log: Final = _boot_serve_and_stop(gateway, tmp_path, config, {}, 1, models)
plaintext: Final = _non_json_lines(log)
assert plaintext == (), "plaintext lines with JSON logs on:\n" + "\n".join(plaintext)
messages: Final = _messages(log)
assert sum(STARTED_WORKER in message for message in messages) == 1, messages
for fragment in (
BANNER_FRAGMENT,
FEEDBACK_BOX_LINE,
MODEL_LIST_HEADER,
CACHE_LINE,
CONNECTION_BUDGET_LINE,
*models,
):
assert any(fragment in message for message in messages), (fragment, messages)
def test_json_logs_warning_level_keeps_startup_notices(gateway: Gateway, tmp_path: Path) -> None:
"""`LITELLM_LOG=WARNING` drops the INFO logger lines; the startup notices a plain-mode operator sees at
every level still arrive, as JSON, and nothing arrives as plaintext."""
models: Final = _models()
config: Final = _write_config(tmp_path, gateway.upstream_url, models, {**CACHE_SETTINGS, "json_logs": True})
log: Final = _boot_serve_and_stop(gateway, tmp_path, config, {"LITELLM_LOG": "WARNING"}, 1, models)
plaintext: Final = _non_json_lines(log)
assert plaintext == (), "plaintext lines with JSON logs on:\n" + "\n".join(plaintext)
messages: Final = _messages(log)
assert not any(REDIS_AUTH_CACHE_LINE in message for message in messages), messages
for fragment in (
BANNER_FRAGMENT,
FEEDBACK_BOX_LINE,
MODEL_LIST_HEADER,
CACHE_LINE,
CONNECTION_BUDGET_LINE,
*models,
):
assert any(fragment in message for message in messages), (fragment, messages)
def test_json_logs_burst_on_two_workers_keeps_every_line_json(gateway: Gateway, tmp_path: Path) -> None:
"""Concurrent requests spread over both workers interleave their log writes; every line must still be
one complete JSON record."""
models: Final = _models()
config: Final = _write_config(tmp_path, gateway.upstream_url, models, {**CACHE_SETTINGS, "json_logs": True})
with owned_proxy_process(
gateway, tmp_path, {"LITELLM_LOG": "INFO"}, config=config, workers=2, database_setup=()
) as owned:
with ThreadPoolExecutor(max_workers=BURST_SIZE) as pool:
replies: Final = tuple(
pool.map(lambda index: owned.gateway.chat(models[index % 2], text=f"burst {index}"), range(BURST_SIZE))
)
assert tuple(reply["model"] for reply in replies) == tuple(models[index % 2] for index in range(BURST_SIZE))
log: Final = owned.log
text: Final = log.read_text()
plaintext: Final = _non_json_lines(text)
assert plaintext == (), "plaintext lines with JSON logs on:\n" + "\n".join(plaintext)
assert sum(STARTED_WORKER in message for message in _messages(text)) == 2, _messages(text)
def test_json_logs_config_makes_the_deprecated_flag_notice_one_json_record(gateway: Gateway, tmp_path: Path) -> None:
"""The notice waits until the config is read, so a json_logs set only there makes it a JSON record."""
models: Final = _models()
config: Final = _write_config(tmp_path, gateway.upstream_url, models, {"json_logs": True})
log: Final = _boot_serve_and_stop(gateway, tmp_path, config, {"LITELLM_LOG": "INFO"}, 1, models, (DEPRECATED_FLAG,))
plaintext: Final = _non_json_lines(log)
assert plaintext == (), "plaintext lines with JSON logs on:\n" + "\n".join(plaintext)
assert sum(DEPRECATED_FLAG_NOTICE in message for message in _messages(log)) == 1, _messages(log)
@pytest.mark.parametrize("config_text", (None, UNPARSEABLE_CONFIG), ids=("missing", "unparseable"))
def test_a_config_that_fails_to_load_still_prints_the_deprecated_flag_notice(
gateway: Gateway, tmp_path: Path, config_text: str | None
) -> None:
"""A boot with the deprecated flag and a config file that does not exist or does not parse exits
non-zero, and it still prints the notice once before the error, as it did before the notice waited
for the config."""
config: Final = tmp_path / f"unloadable_{uuid.uuid4().hex}.yaml"
if config_text is not None:
config.write_text(config_text)
log: Final = refused_boot_log(gateway, tmp_path, {}, config=config, extra_arguments=(DEPRECATED_FLAG,))
assert log.count(DEPRECATED_FLAG_NOTICE) == 1, log
def test_plain_logs_control_prints_startup_text(gateway: Gateway, tmp_path: Path) -> None:
models: Final = _models()
config: Final = _write_config(tmp_path, gateway.upstream_url, models, CACHE_SETTINGS)
log: Final = _boot_serve_and_stop(gateway, tmp_path, config, {"LITELLM_LOG": "INFO"}, 1, models)
plaintext: Final = _non_json_lines(log)
assert any(BANNER_FRAGMENT in line for line in plaintext), log
assert any(FEEDBACK_BOX_LINE in line for line in plaintext), log
assert any(CACHE_LINE in line for line in plaintext), log
assert any(MODEL_LIST_HEADER in line for line in plaintext), log
for model in models:
assert any(line.strip().endswith(model) or line.strip().endswith(f"{model}\x1b[0m") for line in plaintext), (
model,
log,
)
assert any(PLAIN_LOG_LINE.match(line) and REDIS_AUTH_CACHE_LINE in line for line in plaintext), log
assert any(line.startswith(f"LiteLLM Proxy: {CONNECTION_BUDGET_LINE}") for line in plaintext), log

View file

@ -100,6 +100,8 @@ if not earlier_calls and os.environ.get("FAKE_PRISMA_HANG_FIRST"):
grandchild = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(600)"])
pathlib.Path(os.environ["FAKE_PRISMA_GRANDCHILD_PIDFILE"]).write_text(str(grandchild.pid))
time.sleep(600)
if os.environ.get("FAKE_PRISMA_STDOUT"):
sys.stdout.write(os.environ["FAKE_PRISMA_STDOUT"])
sys.exit(0)
"""
@ -111,6 +113,8 @@ class FakePrismaCli:
With FAKE_PRISMA_HANG_FIRST set it hangs on its first call from a process tree
of its own, the way the real CLI wraps Node around a Rust schema engine, so a
timeout that kills only the direct child leaves the rest of that tree running.
With FAKE_PRISMA_STDOUT set it writes that text to stdout, the way `migrate diff
--script` prints the SQL that would close a schema gap.
"""
calls_file: Path
@ -142,6 +146,7 @@ def fake_prisma_cli(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generato
monkeypatch.setenv("FAKE_PRISMA_GRANDCHILD_PIDFILE", str(cli.grandchild_pidfile))
monkeypatch.setenv("LITELLM_PRISMA_COMMAND_TIMEOUT", "1")
monkeypatch.delenv("FAKE_PRISMA_HANG_FIRST", raising=False)
monkeypatch.delenv("FAKE_PRISMA_STDOUT", raising=False)
yield cli
if cli.grandchild_pidfile.exists():
try:

View file

@ -1,6 +1,12 @@
import json
import logging
import sys
from typing import Final
import pytest
import litellm
from litellm._logging import turn_on_json, verbose_logger, verbose_proxy_logger
def test_check_migration_out_of_sync(mocker):
@ -69,3 +75,37 @@ def test_migrate_diff_without_the_prisma_runner_skips_instead_of_crashing_boot(m
monkeypatch.setitem(sys.modules, "litellm_proxy_extras.prisma_toolchain", None)
assert check_prisma_schema_diff_helper("postgresql://u:p@localhost:9/x") == (False, [])
def test_migrate_diff_announces_the_schema_changes_as_one_json_record(fake_prisma_cli, monkeypatch, capfd):
"""
The "Changes to DB Schema detected" notice and its SQL went to stdout as plaintext;
with json_logs on they are one INFO record, so a log pipeline keeps parsing. The
loggers sit at WARNING, as they do at boot without LITELLM_LOG, and the record
still goes out: the print it replaces had no level.
"""
from litellm.proxy.db.check_migration import check_prisma_schema_diff_helper
monkeypatch.setenv(
"FAKE_PRISMA_STDOUT", '-- AlterTable\nALTER TABLE "LiteLLM_UserTable" ADD COLUMN "nickname" TEXT;\n'
)
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook) # turn_on_json swaps it; put it back at teardown
turn_on_json()
verbose_logger.setLevel(logging.WARNING)
verbose_proxy_logger.setLevel(logging.WARNING)
assert check_prisma_schema_diff_helper("postgresql://u:p@localhost:9/x") == (
True,
['ALTER TABLE "LiteLLM_UserTable" ADD COLUMN "nickname" TEXT;'],
)
out, err = capfd.readouterr()
assert err == ""
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert len(lines) == 1, lines
record: Final = json.loads(lines[0])
assert record["level"] == "INFO"
assert record["message"] == (
'Changes to DB Schema detected\nRequired SQL commands:\nALTER TABLE "LiteLLM_UserTable" ADD COLUMN "nickname" TEXT;'
)

View file

@ -0,0 +1,107 @@
"""The policy table printed at boot keeps its terminal form in plain mode and is JSON when json_logs is on."""
import json
import sys
from collections.abc import Mapping, Sequence
from typing import Final
import pytest
import litellm
from litellm._logging import turn_on_json
from litellm.proxy.policy_engine.init_policies import _print_policies_on_startup
POLICIES: Final[Mapping[str, Mapping[str, object]]] = {
"pii-policy": {
"inherit": "base",
"description": "Block PII",
"guardrails": {"add": ["pii-guard"], "remove": ["noop"]},
"condition": {"model": "gpt-4o"},
}
}
ATTACHMENTS: Final[Sequence[Mapping[str, object]]] = ({"policy": "pii-policy", "scope": "*", "teams": ["team-a"]},)
POLICY_ROWS: Final = (
" - pii-policy (inherits: base)",
" description: Block PII",
" guardrails.add: ['pii-guard']",
" guardrails.remove: ['noop']",
" condition.model: gpt-4o",
)
PLAIN_POLICY_TABLE: Final = (
"\x1b[92m\nLiteLLM Policy Engine: Loaded 1 policies\x1b[0m\n\n"
"\x1b[94m - pii-policy (inherits: base)\x1b[0m\n"
" description: Block PII\n"
" guardrails.add: ['pii-guard']\n"
" guardrails.remove: ['noop']\n"
" condition.model: gpt-4o\n"
)
NO_ATTACHMENTS_WARNING: Final = (
"Warning: No policy_attachments configured. Policies will not be applied to any requests."
)
@pytest.mark.parametrize(
("attachments", "expected"),
[
(
ATTACHMENTS,
PLAIN_POLICY_TABLE
+ "\n\x1b[93mPolicy Attachments: 1 attachment(s)\x1b[0m\n"
+ " - pii-policy -> scope=* (global), teams=['team-a']\n\n",
),
(None, PLAIN_POLICY_TABLE + f"\n\x1b[93m{NO_ATTACHMENTS_WARNING}\x1b[0m\n\n"),
],
ids=["with-attachments", "without-attachments"],
)
def test_policy_table_prints_unchanged_in_plain_mode(monkeypatch, capfd, attachments, expected):
"""Operators without JSON logs keep the colored policy table byte for byte."""
monkeypatch.setattr(litellm, "json_logs", False)
_print_policies_on_startup(POLICIES, attachments)
assert capfd.readouterr() == (expected, "")
def test_policy_table_is_one_json_record_in_json_mode(monkeypatch, capfd):
"""With JSON logs on the whole table, attachments included, is one INFO record without color codes."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook) # turn_on_json swaps it; put it back at teardown
turn_on_json()
_print_policies_on_startup(POLICIES, ATTACHMENTS)
out, err = capfd.readouterr()
assert err == ""
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert len(lines) == 1, lines
record: Final = json.loads(lines[0])
assert record["level"] == "INFO"
assert record["message"] == "\n".join(
(
"LiteLLM Policy Engine: Loaded 1 policies",
"",
*POLICY_ROWS,
"",
"Policy Attachments: 1 attachment(s)",
" - pii-policy -> scope=* (global), teams=['team-a']",
)
)
def test_missing_attachments_warning_is_a_second_json_record(monkeypatch, capfd):
"""Without attachments the table and the yellow warning are two INFO records on stdout, where the two
prints put them."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
turn_on_json()
_print_policies_on_startup(POLICIES, None)
out, err = capfd.readouterr()
assert err == ""
records: Final = [json.loads(raw) for raw in out.splitlines() if raw.strip()]
assert [record["message"] for record in records] == [
"\n".join(("LiteLLM Policy Engine: Loaded 1 policies", "", *POLICY_ROWS)),
NO_ATTACHMENTS_WARNING,
]
assert {record["level"] for record in records} == {"INFO"}

View file

@ -1,4 +1,5 @@
import inspect
import json
import logging
import os
from contextlib import nullcontext
@ -23,6 +24,8 @@ import yaml
from uvicorn.config import LOOP_FACTORIES
from uvicorn.importer import import_from_string
import litellm
from litellm._logging import turn_on_json
from litellm.constants import GRACEFUL_SHUTDOWN_TEARDOWN_HEADROOM_SECONDS
from litellm.proxy import proxy_cli
from litellm.proxy.proxy_cli import ProxyInitializationHelpers, run_server
@ -2875,6 +2878,22 @@ class TestRunServerDbSetup:
mock_setup_database.assert_called_once_with(use_migrate=True, use_v2_resolver=True)
assert ("--enforce_prisma_migration_check is deprecated and has no effect" in capsys.readouterr().out) is warned
@pytest.mark.parametrize("argument", ("--health", "--test"), ids=("health", "test"))
def test_the_retired_enforce_prisma_migration_check_opt_in_warns_on_health_and_test(self, argument, capsys):
"""`--health` and `--test` return before the config is read, where a server start emits the
deprecation line; they still print it, as they did when the notice came before those returns."""
from litellm.proxy.proxy_cli import ProxyInitializationHelpers, run_server
with (
patch.object(ProxyInitializationHelpers, "run_health_check") as mock_health_check,
patch.object(ProxyInitializationHelpers, "run_test_chat_completion") as mock_test_chat,
):
run_server.main([argument, "--enforce_prisma_migration_check"], standalone_mode=False)
expected_calls = (1, 0) if argument == "--health" else (0, 1)
assert (mock_health_check.call_count, mock_test_chat.call_count) == expected_calls
assert "--enforce_prisma_migration_check is deprecated and has no effect" in capsys.readouterr().out
@patch("subprocess.run")
@patch("atexit.register")
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database", return_value=True)
@ -3935,3 +3954,140 @@ class TestValidateConfigFlag:
assert result.exit_code == 0, f"exit_code={result.exit_code}, output={result.output}"
assert "config OK" in result.output
mock_popen.assert_not_called()
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."""
@staticmethod
def _json_logs_env_after_boot(config_path: Path, litellm_settings: dict[str, object]) -> 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()))
mock_proxy_module: Final = MagicMock(
app=MagicMock(),
ProxyConfig=mock_proxy_config,
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")}
with (
patch.dict(os.environ, clean_env, clear=True),
patch.dict(
"sys.modules",
{"proxy_server": mock_proxy_module, "litellm.proxy.proxy_server": mock_proxy_module},
),
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)
return os.environ.get("JSON_LOGS")
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"
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
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."""
NOTICE: Final = "--enforce_prisma_migration_check is deprecated and has no effect"
@staticmethod
def _boot(config_path: Path, get_config: AsyncMock) -> None:
mock_proxy_config: Final = MagicMock()
mock_proxy_config.return_value.get_config = get_config
mock_proxy_module: Final = MagicMock(
app=MagicMock(),
ProxyConfig=mock_proxy_config,
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")}
with (
patch.dict(os.environ, clean_env, clear=True),
patch.dict(
"sys.modules",
{"proxy_server": mock_proxy_module, "litellm.proxy.proxy_server": mock_proxy_module},
),
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", "--enforce_prisma_migration_check"],
standalone_mode=False,
)
def test_a_config_read_that_fails_still_prints_the_notice(self, tmp_path: Path, monkeypatch, capsys) -> None:
monkeypatch.setattr(litellm, "json_logs", False)
missing: Final = tmp_path / "missing.yaml"
with pytest.raises(FileNotFoundError):
self._boot(missing, AsyncMock(side_effect=FileNotFoundError(f"Config file not found: {missing}")))
assert capsys.readouterr().out.count(self.NOTICE) == 1
def test_a_plain_config_prints_the_notice_once(self, tmp_path: Path, monkeypatch, capsys) -> None:
monkeypatch.setattr(litellm, "json_logs", False)
self._boot(tmp_path / "config.yaml", AsyncMock(return_value={"model_list": [], "litellm_settings": {}}))
assert capsys.readouterr().out.count(self.NOTICE) == 1
def test_a_config_only_json_logs_makes_the_notice_one_json_record(
self, tmp_path: Path, monkeypatch, capsys
) -> None:
monkeypatch.setattr(litellm, "json_logs", False) # run_server flips it on; undone at teardown
config: Final = {"model_list": [], "litellm_settings": {"json_logs": True}}
self._boot(tmp_path / "config.yaml", AsyncMock(return_value=config))
notice_lines: Final = [line for line in capsys.readouterr().out.splitlines() if self.NOTICE in line]
assert len(notice_lines) == 1, notice_lines
assert self.NOTICE in json.loads(notice_lines[0])["message"]
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
printed while the launcher starts; with json_logs on they are INFO records on stdout like every other
line, not plaintext between the records."""
pytest.importorskip("granian")
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook) # turn_on_json swaps it; put it back at teardown
turn_on_json()
with (
patch("granian.Granian") as mock_granian_cls,
patch("granian.constants.Interfaces", SimpleNamespace(ASGI="asgi")),
):
ProxyInitializationHelpers._init_granian_server(
host="0.0.0.0",
port=4000,
num_workers=2,
ssl_certfile_path=None,
ssl_keyfile_path=None,
max_requests_before_restart=100,
ciphers="ECDHE",
granian_runtime_threads=None,
)
mock_granian_cls.return_value.serve.assert_called_once()
out, err = capfd.readouterr()
assert err == ""
records: Final = [json.loads(raw) for raw in out.splitlines() if raw.strip()]
assert [record["message"] for record in records] == [
"LiteLLM Proxy: Starting server on 0.0.0.0:4000 using Granian",
"LiteLLM: --max_requests_before_restart is not supported by Granian "
"(Granian uses workers_lifetime in seconds, not a per-request limit).",
"LiteLLM: --ciphers is not applied when using --run_granian.",
]
assert {record["level"] for record in records} == {"INFO"}

View file

@ -7,6 +7,7 @@ import os
import re
import socket
import subprocess
import sys
import time
import types
from datetime import datetime, timedelta, timezone
@ -28,6 +29,7 @@ from fastapi.testclient import TestClient
import litellm
import litellm.proxy.proxy_server as proxy_server_module
from litellm._internal_context import current_service_target
from litellm._logging import turn_on_json, verbose_proxy_logger
from litellm.caching.caching import RedisCache
from litellm.caching.redis_cluster_cache import RedisClusterCache
from litellm.litellm_core_utils.get_model_cost_map import ModelCostMapReloaded
@ -46,6 +48,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.hooks.parallel_request_limiter_v3 import RequestRateLimiterStash
from litellm.proxy.proxy_server import app, aws_error_type_exception_handler, initialize, openai_exception_handler
from litellm.utils import _invalidate_model_cost_lowercase_map
from litellm.proxy.common_utils.banner import LITELLM_BANNER
from tests._master_key import MASTER_KEY
PUBLICLY_KNOWN_KEY: Final = "sk-" + "1234"
@ -14266,7 +14269,6 @@ async def test_moderations_response_carries_litellm_call_id_header():
async def test_moderations_failure_log_carries_the_callers_litellm_call_id(caplog):
"""LIT-7836: the /v1/moderations error line must carry the litellm_call_id the
client sent, rendered in the message and as a structured log record field."""
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import ProxyException
call_id = "moderations-call-7836"
@ -15588,3 +15590,54 @@ async def test_spend_capture_rate_check_job_clears_the_gauge_once_the_setting_is
call(api_provider="openai", capture_rate=None),
]
def test_feedback_box_is_one_json_record_in_json_mode(monkeypatch, capfd):
"""The feedback box is a dozen terminal lines; with JSON logs on it has to land as one parseable
record, not a plaintext line per row."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook) # turn_on_json swaps it; put it back at teardown
turn_on_json()
proxy_server_module.generate_feedback_box()
out, err = capfd.readouterr()
assert err == ""
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert len(lines) == 1, lines
record: Final = json.loads(lines[0])
assert "Give Feedback / Get Help: https://github.com/BerriAI/litellm/issues/new" in record["message"]
assert "Thank you for using LiteLLM! - Krrish & Ishaan" in record["message"]
assert "\x1b[" not in record["message"]
@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."""
monkeypatch.setattr(litellm, "json_logs", False)
monkeypatch.setenv("LITELLM_DONT_SHOW_FEEDBACK_BOX", "true")
await proxy_server_module.initialize()
out, err = capfd.readouterr()
assert err == ""
assert out.startswith(f"\n{LITELLM_BANNER}\n\n"), out
@pytest.mark.asyncio
async def test_initialize_prints_the_banner_as_one_json_record_in_json_mode(monkeypatch, capfd):
"""With JSON logs on, the six banner rows are one INFO record with their indentation kept, so the
first line a worker writes parses like every other."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook) # turn_on_json swaps it; put it back at teardown
monkeypatch.setenv("LITELLM_DONT_SHOW_FEEDBACK_BOX", "true")
turn_on_json()
await proxy_server_module.initialize()
out, err = capfd.readouterr()
assert err == ""
records: Final = [json.loads(raw) for raw in out.splitlines() if raw.strip()]
banner_records: Final = [record for record in records if record["message"] == LITELLM_BANNER]
assert len(banner_records) == 1, records
assert banner_records[0]["level"] == "INFO"

View file

@ -34,6 +34,7 @@ from litellm._logging import (
_parse_json_logs_env,
_plain_log_format,
_stdout_truncation_marker,
print_startup_message,
turn_on_json,
format_base64_size,
session_id_var,
@ -1824,3 +1825,209 @@ def test_print_verbose_formats_args_only_when_set_verbose_is_on(monkeypatch, cap
logging_module.print_verbose("key %s value %s", "k1", _StrCountingValue())
assert _StrCountingValue.str_calls == 1
assert capsys.readouterr().out == "key k1 value cached-vector\n"
def test_print_startup_message_prints_plain_mode_text_unchanged(monkeypatch, capfd):
"""Operators without JSON logs keep the colored, spaced terminal output exactly as before."""
monkeypatch.setattr(litellm, "json_logs", False)
print_startup_message("\n\033[1;32mLiteLLM: Proxy initialized with Config, Set models:\033[0m\n")
out, err = capfd.readouterr()
assert out == "\n\033[1;32mLiteLLM: Proxy initialized with Config, Set models:\033[0m\n\n"
assert err == ""
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
at WARNING, as it does in the CLI process, and the record still goes out: a print has no level."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook) # turn_on_json swaps it; put it back at teardown
turn_on_json()
verbose_proxy_logger.setLevel(logging.WARNING)
print_startup_message(
"\n\033[1;32mLiteLLM: Proxy initialized with Config, Set models:\033[0m\n\033[32m gpt-4o\033[0m\n\n"
)
out, err = capfd.readouterr()
assert err == ""
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert len(lines) == 1, lines
record: Final = json.loads(lines[0])
assert record["message"] == "LiteLLM: Proxy initialized with Config, Set models:\n gpt-4o"
assert record["level"] == "INFO"
assert record["component"] == "LiteLLM Proxy"
# the record names the call site, not the helper, so operators can find the message
assert str(record["logger"]).startswith("test_logging.py:"), record["logger"]
def test_print_startup_message_yellow_notice_is_an_info_record_on_stdout(monkeypatch, capfd):
"""An operator notice printed in yellow (an ignored flag, a deprecation) stays on stdout in JSON mode,
where the print put it, as one INFO record with the color codes removed."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
turn_on_json()
verbose_proxy_logger.setLevel(logging.WARNING)
print_startup_message("\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
record: Final = json.loads(lines[0])
assert record["message"] == "LiteLLM: --ciphers is not applied when using --run_granian."
assert record["level"] == "INFO"
def test_print_startup_message_shows_above_the_json_handler_level(monkeypatch, capfd):
"""`LITELLM_LOG=WARNING` sets the JSON handler to WARNING, which drops the INFO logger lines; the
startup notices still go out, as the prints they replace did at every level."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
turn_on_json()
verbose_proxy_logger.setLevel(logging.INFO)
for json_handler in verbose_proxy_logger.handlers:
monkeypatch.setattr(json_handler, "level", logging.WARNING)
print_startup_message("\033[94m\nSetting Cache on Proxy\033[0m")
verbose_proxy_logger.info("enable_redis_auth_cache is not set")
out, _ = capfd.readouterr()
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert [json.loads(raw)["message"] for raw in lines] == ["Setting Cache on Proxy"]
def test_print_startup_message_emits_json_after_an_operator_log_config_removed_the_handlers(
request, monkeypatch, capfd
):
"""uvicorn applies an operator `--log_config` after proxy_cli's turn_on_json() and before initialize()
re-installs the JSON handler through load_config(); a config that names "LiteLLM Proxy" leaves the
logger without handlers in that window, where the banner and the feedback box are emitted, and they
still go out as one JSON record each, on stdout."""
import logging.config
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
turn_on_json()
request.addfinalizer(turn_on_json) # put the JSON handler back for the tests that follow
logging.config.dictConfig(
{"version": 1, "disable_existing_loggers": False, "loggers": {"LiteLLM Proxy": {"level": "INFO"}}}
)
assert verbose_proxy_logger.handlers == []
print_startup_message("\n\033[1;37m# Give Feedback / Get Help #\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
record: Final = json.loads(lines[0])
assert record["message"] == "# Give Feedback / Get Help #"
assert record["level"] == "INFO"
assert record["component"] == "LiteLLM Proxy"
OPERATOR_HANDLERS_ON_THE_PROXY_LOGGER: Final = {
"silenced": {"class": "logging.NullHandler"},
"stderr_warning": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
"level": "WARNING",
"formatter": "plain",
},
}
@pytest.mark.parametrize("handler", sorted(OPERATOR_HANDLERS_ON_THE_PROXY_LOGGER))
def test_print_startup_message_stays_on_stdout_under_an_operator_handler_on_the_proxy_logger(
request, monkeypatch, capfd, handler: str
):
"""An operator `--log_config` that gives "LiteLLM Proxy" a handler of its own, a NullHandler that silences
it or a plain WARNING-level handler on stderr, is in force when the banner and the feedback box are
emitted; the print they replace wrote to stdout whatever the logging config said, so the record still
goes out as one JSON record on stdout and the operator's handler gets nothing."""
import logging.config
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
turn_on_json()
request.addfinalizer(turn_on_json) # put the JSON handler back for the tests that follow
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {"plain": {"format": "%(levelname)s %(message)s"}},
"handlers": {"proxy": OPERATOR_HANDLERS_ON_THE_PROXY_LOGGER[handler]},
"loggers": {"LiteLLM Proxy": {"level": "INFO", "handlers": ["proxy"], "propagate": False}},
}
)
assert len(verbose_proxy_logger.handlers) == 1 and not isinstance(
verbose_proxy_logger.handlers[0], LevelRoutingStreamHandler
)
print_startup_message("\n\033[1;37m# Give Feedback / Get Help #\033[0m\n")
out, err = capfd.readouterr()
assert err == "", err
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert len(lines) == 1, lines
record: Final = json.loads(lines[0])
assert record["message"] == "# Give Feedback / Get Help #"
assert record["level"] == "INFO"
assert record["component"] == "LiteLLM Proxy"
def test_print_startup_message_keeps_a_listing_longer_than_the_stdout_cap_whole(monkeypatch, capfd):
"""The required migration SQL and the policy list run past MAX_STRING_LENGTH_STDOUT_LOG on a drifted
deployment. The prints they replace showed them whole, and that cap exists for echoed request payloads,
so the JSON record carries the whole listing too."""
monkeypatch.setenv("MAX_STRING_LENGTH_STDOUT_LOG", "500")
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook) # turn_on_json swaps it; put it back at teardown
turn_on_json()
commands: Final = tuple(f'ALTER TABLE "LiteLLM_Table{n}" ADD COLUMN "column_{n}" TEXT;' for n in range(40))
listing: Final = "\n".join(("Changes to DB Schema detected", "Required SQL commands:", *commands))
assert len(listing) > 500
print_startup_message(listing)
out, err = capfd.readouterr()
assert err == ""
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert len(lines) == 1, lines
record: Final = json.loads(lines[0])
assert LITELLM_TRUNCATED_PAYLOAD_FIELD not in record["message"]
assert record["message"] == listing
def test_print_startup_message_keeps_a_model_name_the_secret_patterns_would_match(monkeypatch, capfd):
"""The model list printed `risk-assessment-gpt4o` as configured; the sk- key pattern matches inside
that name, so the JSON record has to carry the name as the print did, not `riREDACTED`."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook) # turn_on_json swaps it; put it back at teardown
turn_on_json()
assert secret_redaction.redact_string("risk-assessment-gpt4o") != "risk-assessment-gpt4o"
print_startup_message("\033[32m risk-assessment-gpt4o\033[0m")
out, err = capfd.readouterr()
assert err == ""
lines: Final = [raw for raw in out.splitlines() if raw.strip()]
assert len(lines) == 1, lines
assert json.loads(lines[0])["message"] == " risk-assessment-gpt4o"
def test_print_startup_message_blank_spacer_emits_no_json_record(monkeypatch, capfd):
"""A spacer that only pads the terminal has nothing to say to a log pipeline."""
monkeypatch.setattr(litellm, "json_logs", True)
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
turn_on_json()
verbose_proxy_logger.setLevel(logging.INFO)
print_startup_message("")
print_startup_message("\n\033[0m\n")
assert capfd.readouterr() == ("", "")