mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
* fix(proxy): keep every startup line JSON when json_logs is on Boot-time messages the proxy printed straight to stdout (banner, feedback box, model list, cache and callback notices, policy table, migration notices, CLI launch notes) and the first records of uvicorn- or granian-spawned workers came out as plain text while json_logs was on, breaking log pipelines that parse every line as JSON. - print_startup_message() prints unchanged in plain mode and, with json_logs on, emits one INFO record on the proxy logger (ANSI stripped) that skips the logger and handler level checks like the print it replaces; show_banner() routes to it in JSON mode only - the CLI exports JSON_LOGS=true when the config sets json_logs so spawned workers log JSON from their first record - the --enforce_prisma_migration_check deprecation notice prints after the config is read so a config-only json_logs covers it Resolves LIT-1624 * fix(proxy): keep show_banner() SDK-free, pick the JSON banner in initialize() Since #46803 the `lite` shell calls show_banner() against an uninitialized litellm package, and reading litellm.json_logs inside it initialized the whole SDK at the banner (0.14 s to 0.83 s for a bare `lite` here, the seconds that PR removed on small VMs). show_banner() is back to its main version; the proxy's initialize() now emits the JSON banner record itself and calls show_banner() only in plain mode. The banner unit tests move to the initialize() call site. * fix(proxy): keep the JSON banner when an operator --log_config names the proxy logger uvicorn applies --log_config after proxy_cli has switched JSON logging on and before initialize() switches it on again through load_config(). A logging config that names "LiteLLM Proxy" removes that logger's handlers in the window between the two, which is where initialize() emits the banner and the feedback box, so at one worker both went nowhere; two-worker children import litellm after uvicorn's logging setup and were not affected. print_startup_message() now falls back to a one-off JSON handler, on the stream the JSON handler uses, when the proxy logger has no handler of its own. Live, json_logs on plus a log_config naming the proxy logger, one worker: main prints the banner and the feedback box as plaintext,e2303a3925prints neither, this head prints both as one JSON record each. New unit test fails on e2303a3925; new integration cell test_json_logs_with_operator_log_config_keeps_every_line_json passes at one and two workers. * fix(proxy): keep a long startup listing whole in its JSON record print_startup_message ran its record through the proxy logger's filters, so in JSON mode the required-migration SQL list and the policy list were middle-truncated at MAX_STRING_LENGTH_STDOUT_LOG (4096 by default) with the litellm_truncated marker, where the prints they replace showed them whole. That cap bounds echoed request payloads; a startup message is operator text, so the record now gets the uncapped processing of a DEBUG line: secret redaction and base64 collapse still apply, the text cap does not. Red on41f92b745a, green here: - tests/unit/test_logging.py: a listing longer than the cap comes out whole; a credential in a startup message is still redacted - tests/integration/configuration/test_json_logs_startup.py: a 60-policy config boots with json_logs on and the policy listing record (9762 chars) carries every policy with no truncation marker (4096 chars, marker, on the unfixed tree) * fix(proxy): keep the --enforce_prisma_migration_check notice on --health and --test This PR moved the deprecation notice after the config is read, so that a json_logs set only in the config applies to it; --health and --test return before that point and stopped printing it, a visible change main did not have. The notice is now also emitted before those two returns, through the same helper, so a deployment running `litellm --health --enforce_prisma_migration_check` still learns the flag is dead, as on main. A normal server start emits it once, after the config, as in the previous commit. Red on2d7a2d6844, green here: tests/unit/proxy/test_proxy_cli.py asserts the notice on --health and on --test. * fix(proxy): keep the JSON startup records on stdout under an operator handler on the proxy logger 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 in the window where the banner, the feedback box and the cache notice are emitted: uvicorn applies it after the CLI process switched JSON on and before initialize() re-installs the JSON handler through load_config().41f92b745asent the startup records through the proxy logger's handlers and fell back to a JSON stdout handler only when there were none, so under such a config the records landed in the operator's handler, as plaintext on stderr, or vanished, where the prints they replace reached stdout whatever the logging config said. print_startup_message() now writes every startup record through the proxy's own JSON handler on stdout, filters included, and never through the proxy logger's handlers; the operator's handler gets nothing from a startup message, as it got nothing from the print. The normal JSON handler is the same class, formatter and filters, so the records a pipeline reads are unchanged when no operator config is in play. Tests: tests/unit/test_logging.py gets the two handler shapes (red onf6f5957d16: the record went to stderr as plaintext or nowhere); tests/integration/configuration/ test_json_logs_startup.py gets a silencing operator --log_config cell at 1 and 2 workers (red onf6f5957d16: banner, feedback box and cache notice missing; green here). * fix(proxy): print the --enforce_prisma_migration_check notice when the config fails to loadf6f5957d16printed the deprecation notice after the config is read, so a json_logs set only in the config makes it a JSON record. A boot whose config file is missing or does not parse then exited before that point and never printed it, where main printed it before the error (10-20 gauntlet finding). The notice is printed where main printed it, unless a config file can still switch json_logs on: then it is held until the config is read and printed right after the json_logs switch, and a click close callback prints it on the way out if the boot fails first. functools.cache keeps it to one print either way. Tests: tests/unit/proxy/test_proxy_cli.py covers a failed config read (red on75043aed83), a plain config printing it once and a config-only json_logs making it one JSON record; tests/integration/configuration/test_json_logs_startup.py adds the JSON cell and a missing and an unparseable config cell (red on75043aed83, green here), through refused_boot_log, which now takes extra CLI arguments. * fix(proxy): keep startup records as printed instead of running secret redaction In JSON mode the startup messages ran secret redaction, which the prints they replace never had. Its sk- key pattern has no left boundary, so a model named risk-assessment-gpt4o was logged as riREDACTED in the model list (10-21 gauntlet finding). The records now skip redaction like the prints did; the processed record still counts as scrubbed, so the handler's secret filter and the formatter leave it alone. The stdout cap stays off and base64 collapse stays on, as before. Tests: tests/unit/test_logging.py checks the model name comes out whole (red on7d17f126e5), replacing the test that asserted startup records were redacted; tests/integration/configuration/test_json_logs_startup.py adds a boot with a risk-assessment-<id> model whose model list record carries the name (red on7d17f126e5). * refactor(proxy): meet the AGENTS.md rules Greptile flagged on the startup-message change The policy listing builds its rows as tuples in two helpers instead of appending to a list; plain output is unchanged (same bytes as before across 3000 random policy and attachment configs). The explanatory comments added with this change are gone, and the docstring of print_startup_message now says the record is whole and unredacted. Tests: the migration-diff JSON test no longer runs the fake prisma CLI; it checks the skipped-diff notice in-process, and the fixture extension it needed is reverted. The json_logs export and deprecation-notice CLI tests run the real proxy module and a real config file instead of a mocked ProxyConfig and proxy module, and the --health/--test notice tests no longer patch our helpers. Each still fails when the export, the hold or call_on_close is removed. New test parameters are typed. --------- Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
397 lines
19 KiB
Python
397 lines
19 KiB
Python
"""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_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"
|
|
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"}}
|
|
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"
|
|
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"]},
|
|
}
|
|
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()
|
|
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
|