mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
fix(config): treat blank env values as unset so the proxy neither crash-loops nor silently drops a guardrail (#46341)
* fix(config): treat blank numeric env values as unset instead of crashing at import
A numeric knob exported as an empty string, which is what a Helm value: "" or an
emptied .env line produces, raised ValueError inside litellm/constants.py and the
proxy crash-looped before serving traffic. Every int(os.getenv(...)) and
float(os.getenv(...)) in constants.py now goes through get_env_int / get_env_float,
which treat blank as unset and fall back to the default with a warning on garbage.
* fix(guardrails): treat blank litellm_params values as unset so the guardrail still loads
A guardrail whose litellm_params read os.environ/X resolves X to "" when the
variable is exported but empty. Pydantic rejected "" for bool and typed fields
such as default_on and fail_on_error, the boot log reported the guardrail as
skipped and the proxy came up without it. A before-validator on BaseLitellmParams
now drops blank string values so each field takes its default, and the
default_on shortcut in LitellmParams treats a blank value as False.
* fix(config): warn once about an unparsable numeric env value so logging cannot recurse
The LiteLLM loggers' filter reads MAX_STRING_LENGTH_STDOUT_LOG and MAX_BASE64_LENGTH_STDOUT_LOG through get_env_int on every record. Warning about a garbage value from inside that read re-entered the filter, so every log call raised RecursionError. The value is now marked as reported before the warning is logged, which also stops per-record readers from repeating it. get_env_int_or_none and the new get_env_float_or_none share the same parser
* fix(config): treat blank values as unset for the remaining import-time numeric knobs
LITELLM_MAX_STREAMING_DURATION_SECONDS, REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES, a whitespace CLI_JWT_EXPIRATION_HOURS, LITELLM_CONFIG_PARAM_CACHE_TTL_SECONDS, the agent health check timeouts and the Prisma reconnect knobs still crashed the proxy at boot when blank. A blank or unparsable REQUEST_TIMEOUT no longer counts as explicitly set, so chat completion() keeps its 600s fallback
* fix(guardrails): map blank litellm_params to the field default so PATCH can still clear a value
Dropping blank keys before validation made PATCH /guardrails/{id} with api_base "" a silent no-op. Blank values of optional fields now resolve to the field default and stay in the set fields, so the PATCH clears the stored value. Required fields and unknown keys keep the value as given
* test: cover proxy numeric knobs in-process and expect no warning for a blank Straiker api_version
The three proxy import-time numeric knobs move into litellm/constants.py so the existing in-process blank-env test covers them, replacing the subprocess test. A blank api_version now means unset, so the Straiker test asserts the key-prefix version with no warning.
* fix(guardrails): keep LitellmParams kwargs typed without adding argument-type errors
---------
Co-authored-by: yuneng-jiang <yuneng@berri.ai>
This commit is contained in:
parent
2d4472efc1
commit
493ed616bb
11 changed files with 679 additions and 393 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -4,23 +4,65 @@ Utility helpers for reading and parsing environment variables.
|
|||
|
||||
import logging
|
||||
import os
|
||||
from typing import Final
|
||||
from typing import Final, TypeVar
|
||||
|
||||
_NumberT: Final = TypeVar("_NumberT", int, float)
|
||||
|
||||
# Marked before logging: the LiteLLM loggers' own filters read env knobs through these helpers, so
|
||||
# warning first would re-enter the helper from inside the log call and recurse until RecursionError.
|
||||
_reported_unparsable_values: Final[set[tuple[str, str]]] = set() # mutable-ok: process-wide warn-once registry
|
||||
|
||||
|
||||
def _warn_unparsable_once(env_var: str, raw: str, number_type: type[int] | type[float]) -> None:
|
||||
key: Final = (env_var, raw)
|
||||
if key in _reported_unparsable_values:
|
||||
return
|
||||
_reported_unparsable_values.add(key)
|
||||
logging.getLogger("LiteLLM").warning(
|
||||
"%s=%r is not a valid %s. Treating it as unset.", env_var, raw, number_type.__name__
|
||||
)
|
||||
|
||||
|
||||
def _get_env_number_or_none(env_var: str, number_type: type[_NumberT]) -> _NumberT | None:
|
||||
raw: Final = os.getenv(env_var)
|
||||
if raw is None or not raw.strip():
|
||||
return None
|
||||
try:
|
||||
return number_type(raw.strip())
|
||||
except ValueError:
|
||||
_warn_unparsable_once(env_var, raw, number_type)
|
||||
return None
|
||||
|
||||
|
||||
def get_env_int_or_none(env_var: str) -> int | None:
|
||||
"""Parse an environment variable as an integer, returning None when it is unset, blank or unparsable.
|
||||
|
||||
Use this instead of `get_env_int` when callers must distinguish "explicitly configured"
|
||||
from "left at the default", for example when an override should take precedence over a
|
||||
value resolved from somewhere else.
|
||||
"""
|
||||
return _get_env_number_or_none(env_var, int)
|
||||
|
||||
|
||||
def get_env_float_or_none(env_var: str) -> float | None:
|
||||
"""Float counterpart of `get_env_int_or_none`."""
|
||||
return _get_env_number_or_none(env_var, float)
|
||||
|
||||
|
||||
def get_env_int(env_var: str, default: int) -> int:
|
||||
"""Parse an environment variable as an integer, falling back to default on invalid values.
|
||||
"""Parse an environment variable as an integer, falling back to default when it is unset, blank or unparsable.
|
||||
|
||||
Handles empty strings, whitespace, and non-numeric values gracefully
|
||||
so that misconfiguration doesn't crash the process at import time.
|
||||
An unparsable value is warned about once, so misconfiguration neither crashes the process at
|
||||
import time nor goes unnoticed.
|
||||
"""
|
||||
raw = os.getenv(env_var)
|
||||
if raw is None:
|
||||
return default
|
||||
raw = raw.strip()
|
||||
try:
|
||||
return int(raw)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
value: Final = get_env_int_or_none(env_var)
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def get_env_float(env_var: str, default: float) -> float:
|
||||
"""Float counterpart of `get_env_int`."""
|
||||
value: Final = get_env_float_or_none(env_var)
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def get_env_int_in_range(env_var: str, default: int, minimum: int, maximum: int) -> int:
|
||||
|
|
@ -41,19 +83,3 @@ def get_env_int_in_range(env_var: str, default: int, minimum: int, maximum: int)
|
|||
default,
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
def get_env_int_or_none(env_var: str) -> int | None:
|
||||
"""Parse an environment variable as an integer, returning None when it is unset or unusable.
|
||||
|
||||
Use this instead of `get_env_int` when callers must distinguish "explicitly configured"
|
||||
from "left at the default", for example when an override should take precedence over a
|
||||
value resolved from somewhere else.
|
||||
"""
|
||||
raw: Final = os.getenv(env_var)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw.strip())
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from typing_extensions import ReadOnly, Required, assert_never
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import AGENT_HEALTH_CHECK_GATHER_TIMEOUT_SECONDS, AGENT_HEALTH_CHECK_TIMEOUT_SECONDS
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.proxy._types import (
|
||||
CommonProxyErrors,
|
||||
|
|
@ -228,10 +229,6 @@ def _check_agent_management_permission(user_api_key_dict: UserAPIKeyAuth) -> Non
|
|||
)
|
||||
|
||||
|
||||
AGENT_HEALTH_CHECK_TIMEOUT_SECONDS: Final = float(os.environ.get("LITELLM_AGENT_HEALTH_CHECK_TIMEOUT", "5.0"))
|
||||
AGENT_HEALTH_CHECK_GATHER_TIMEOUT_SECONDS = float(os.environ.get("LITELLM_AGENT_HEALTH_CHECK_GATHER_TIMEOUT", "30.0"))
|
||||
|
||||
|
||||
class _AgentHealthResult(TypedDict, total=False):
|
||||
agent_id: Required[str]
|
||||
healthy: Required[bool]
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ from litellm import _custom_logger_compatible_callbacks_literal
|
|||
from litellm.constants import (
|
||||
DEFAULT_MODEL_CREATED_AT_TIME,
|
||||
FILE_USAGE_MAX_TRACKED_COUNTERS,
|
||||
LITELLM_CONFIG_CACHE_TTL_SECONDS,
|
||||
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL,
|
||||
MAX_TEAM_LIST_LIMIT,
|
||||
PROXY_REJECTED_BEFORE_ROUTING_KEY,
|
||||
|
|
@ -150,6 +151,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
is_expected_client_error,
|
||||
)
|
||||
from litellm.litellm_core_utils.duration_parser import get_budget_window_start
|
||||
from litellm.litellm_core_utils.env_utils import get_env_float, get_env_int
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
|
|
@ -4505,7 +4507,6 @@ async def _lookup_deprecated_key(
|
|||
|
||||
# DualCache for LiteLLM_Config param_name reads.
|
||||
# Redis layer is attached in proxy_server._init_cache.
|
||||
LITELLM_CONFIG_CACHE_TTL_SECONDS: Final[int] = int(os.environ.get("LITELLM_CONFIG_PARAM_CACHE_TTL_SECONDS", "60"))
|
||||
_CONFIG_CACHE_MISS: Final[str] = "__litellm_config_param_miss__"
|
||||
|
||||
litellm_config_cache: Final[DualCache] = DualCache(
|
||||
|
|
@ -4789,28 +4790,28 @@ class PrismaClient:
|
|||
self._db_health_watchdog_task: asyncio.Task | None = None
|
||||
self._view_setup_task: asyncio.Task[_ViewSetupOutcome] | None = None
|
||||
self._db_last_reconnect_attempt_ts: float = 0.0
|
||||
self._db_reconnect_cooldown_seconds: int = max(1, int(os.getenv("PRISMA_RECONNECT_COOLDOWN_SECONDS", "15")))
|
||||
self._db_reconnect_cooldown_seconds: int = max(1, get_env_int("PRISMA_RECONNECT_COOLDOWN_SECONDS", 15))
|
||||
self._db_read_only_recreate_ts: float = 0.0
|
||||
self._db_read_only_recreate_streak: int = 0
|
||||
self._db_health_watchdog_interval_seconds: int = max(
|
||||
5, int(os.getenv("PRISMA_HEALTH_WATCHDOG_INTERVAL_SECONDS", "30"))
|
||||
5, get_env_int("PRISMA_HEALTH_WATCHDOG_INTERVAL_SECONDS", 30)
|
||||
)
|
||||
self._db_health_watchdog_enabled: bool = (
|
||||
str_to_bool(os.getenv("PRISMA_HEALTH_WATCHDOG_ENABLED", "true")) is True
|
||||
)
|
||||
self._db_health_watchdog_probe_timeout_seconds: float = max(
|
||||
0.5,
|
||||
float(os.getenv("PRISMA_HEALTH_WATCHDOG_PROBE_TIMEOUT_SECONDS", "5.0")),
|
||||
get_env_float("PRISMA_HEALTH_WATCHDOG_PROBE_TIMEOUT_SECONDS", 5.0),
|
||||
)
|
||||
self._db_watchdog_reconnect_timeout_seconds: float = max(
|
||||
1.0, float(os.getenv("PRISMA_WATCHDOG_RECONNECT_TIMEOUT_SECONDS", "30.0"))
|
||||
1.0, get_env_float("PRISMA_WATCHDOG_RECONNECT_TIMEOUT_SECONDS", 30.0)
|
||||
)
|
||||
self._db_auth_reconnect_timeout_seconds: float = max(
|
||||
0.5, float(os.getenv("PRISMA_AUTH_RECONNECT_TIMEOUT_SECONDS", "2.0"))
|
||||
0.5, get_env_float("PRISMA_AUTH_RECONNECT_TIMEOUT_SECONDS", 2.0)
|
||||
)
|
||||
self._db_auth_reconnect_lock_timeout_seconds: float = max(
|
||||
0.0,
|
||||
float(os.getenv("PRISMA_AUTH_RECONNECT_LOCK_TIMEOUT_SECONDS", "0.1")),
|
||||
get_env_float("PRISMA_AUTH_RECONNECT_LOCK_TIMEOUT_SECONDS", 0.1),
|
||||
)
|
||||
self._consecutive_reconnect_failures: int = 0
|
||||
# Last generation of each read engine whose repair was attempted and
|
||||
|
|
@ -4821,7 +4822,7 @@ class PrismaClient:
|
|||
# back to a caller whose engine is still unrepaired. Bounded at two
|
||||
# entries: a client has one writer and at most one reader.
|
||||
self._failed_recreate_generations: Mapping[PrismaWrapper, int] = MappingProxyType({})
|
||||
self._reconnect_escalation_threshold: int = max(1, int(os.getenv("PRISMA_RECONNECT_ESCALATION_THRESHOLD", "3")))
|
||||
self._reconnect_escalation_threshold: int = max(1, get_env_int("PRISMA_RECONNECT_ESCALATION_THRESHOLD", 3))
|
||||
self._engine_pidfd: int = -1
|
||||
self._engine_pid: int = 0
|
||||
self._watching_engine: bool = False
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from types import MappingProxyType
|
|||
from typing import Final, Literal, cast
|
||||
|
||||
from pydantic import ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from typing_extensions import ReadOnly, Required, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -1000,6 +1001,16 @@ def runtime_stream_scope(
|
|||
LoggingOnlyScope = Literal["input", "output"]
|
||||
|
||||
|
||||
def _is_blank(value: object) -> bool:
|
||||
return isinstance(value, str) and not value.strip()
|
||||
|
||||
|
||||
def _default_if_blank(field: FieldInfo | None, value: object) -> object:
|
||||
if field is None or field.is_required() or not _is_blank(value):
|
||||
return value
|
||||
return field.get_default(call_default_factory=True)
|
||||
|
||||
|
||||
class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch update guardrails
|
||||
api_key: str | None = Field(default=None, description="API key for the guardrail service")
|
||||
api_base: str | None = Field(default=None, description="Base URL for the guardrail service API")
|
||||
|
|
@ -1288,6 +1299,14 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up
|
|||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="before") # pyright: ignore[reportUntypedFunctionDecorator] # pydantic decorators are untyped
|
||||
@classmethod
|
||||
def blank_values_mean_field_default(cls, data: object) -> object:
|
||||
if not isinstance(data, Mapping):
|
||||
return data
|
||||
fields: Final = cast(Mapping[str, object], data) # cast-ok: pydantic hands a before-validator the raw mapping
|
||||
return {key: _default_if_blank(cls.model_fields.get(key), value) for key, value in fields.items()}
|
||||
|
||||
@field_validator(
|
||||
"mode",
|
||||
"default_action",
|
||||
|
|
@ -1398,14 +1417,14 @@ class LitellmParams( # pyright: ignore[reportIncompatibleVariableOverride] # o
|
|||
)
|
||||
return self
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
default_on: Final = kwargs.pop("default_on", None)
|
||||
if default_on is not None:
|
||||
if default_on is not None and not _is_blank(default_on):
|
||||
kwargs["default_on"] = default_on
|
||||
else:
|
||||
kwargs["default_on"] = False
|
||||
|
||||
super().__init__(**kwargs)
|
||||
super().__init__(**kwargs) # pyright: ignore[reportArgumentType] # pydantic validates each field's type at runtime
|
||||
|
||||
def __contains__(self, key) -> bool:
|
||||
# Define custom behavior for the 'in' operator
|
||||
|
|
|
|||
96
tests/unit/litellm_core_utils/test_env_utils.py
Normal file
96
tests/unit/litellm_core_utils/test_env_utils.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.env_utils import (
|
||||
get_env_float,
|
||||
get_env_float_or_none,
|
||||
get_env_int,
|
||||
get_env_int_or_none,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def litellm_log(caplog: pytest.LogCaptureFixture) -> Iterator[pytest.LogCaptureFixture]:
|
||||
verbose_logger.addHandler(caplog.handler)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
|
||||
yield caplog
|
||||
finally:
|
||||
verbose_logger.removeHandler(caplog.handler)
|
||||
|
||||
|
||||
def _logged_messages(caplog: pytest.LogCaptureFixture) -> list[str]:
|
||||
return [record.getMessage() for record in dict.fromkeys(caplog.records)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", " ", "\t"])
|
||||
def test_blank_numeric_env_value_means_unset_without_warning(
|
||||
monkeypatch: pytest.MonkeyPatch, litellm_log: pytest.LogCaptureFixture, raw: str
|
||||
) -> None:
|
||||
monkeypatch.setenv("ENV_UTILS_TEST_KNOB", raw)
|
||||
|
||||
assert get_env_float("ENV_UTILS_TEST_KNOB", 1.5) == 1.5
|
||||
assert get_env_int("ENV_UTILS_TEST_KNOB", 7) == 7
|
||||
assert get_env_float_or_none("ENV_UTILS_TEST_KNOB") is None
|
||||
assert get_env_int_or_none("ENV_UTILS_TEST_KNOB") is None
|
||||
assert _logged_messages(litellm_log) == []
|
||||
|
||||
|
||||
def test_set_numeric_env_value_is_parsed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ENV_UTILS_TEST_FLOAT", " 2.5 ")
|
||||
monkeypatch.setenv("ENV_UTILS_TEST_INT", " 12 ")
|
||||
|
||||
assert get_env_float("ENV_UTILS_TEST_FLOAT", 1.5) == 2.5
|
||||
assert get_env_int("ENV_UTILS_TEST_INT", 7) == 12
|
||||
assert get_env_float_or_none("ENV_UTILS_TEST_FLOAT") == 2.5
|
||||
assert get_env_int_or_none("ENV_UTILS_TEST_INT") == 12
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("read", "raw", "expected"),
|
||||
[
|
||||
(lambda name: get_env_int(name, 7), "fast-int", 7),
|
||||
(lambda name: get_env_float(name, 1.5), "fast-float", 1.5),
|
||||
(get_env_int_or_none, "fast-int-or-none", None),
|
||||
(get_env_float_or_none, "fast-float-or-none", None),
|
||||
],
|
||||
)
|
||||
def test_unparsable_numeric_env_value_falls_back_and_warns_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
litellm_log: pytest.LogCaptureFixture,
|
||||
read: Callable[[str], float | None],
|
||||
raw: str,
|
||||
expected: float | None,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ENV_UTILS_TEST_KNOB", raw)
|
||||
|
||||
assert read("ENV_UTILS_TEST_KNOB") == expected
|
||||
assert read("ENV_UTILS_TEST_KNOB") == expected
|
||||
|
||||
warnings = [message for message in _logged_messages(litellm_log) if raw in message]
|
||||
assert len(warnings) == 1
|
||||
assert "ENV_UTILS_TEST_KNOB" in warnings[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env_var", ["MAX_STRING_LENGTH_STDOUT_LOG", "MAX_BASE64_LENGTH_STDOUT_LOG"])
|
||||
def test_unparsable_stdout_log_limit_does_not_break_logging(
|
||||
monkeypatch: pytest.MonkeyPatch, litellm_log: pytest.LogCaptureFixture, env_var: str
|
||||
) -> None:
|
||||
"""
|
||||
Regression: the LiteLLM loggers' own filter reads these limits through get_env_int on every
|
||||
record, so warning about a garbage value re-entered the filter and every log call raised
|
||||
RecursionError.
|
||||
"""
|
||||
raw = f"not-a-number-{env_var}"
|
||||
monkeypatch.setenv(env_var, raw)
|
||||
|
||||
verbose_logger.warning("first line")
|
||||
verbose_logger.warning("second line")
|
||||
|
||||
messages = _logged_messages(litellm_log)
|
||||
assert "first line" in messages
|
||||
assert "second line" in messages
|
||||
assert len([message for message in messages if raw in message]) == 1
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from types import MappingProxyType, SimpleNamespace
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
|
@ -1216,7 +1217,25 @@ def test_v3_initializer_reads_api_version_from_config():
|
|||
assert g._webhook_url().endswith("/api/v3/detect")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_version", ["2024-09-01", "", "v2"])
|
||||
@pytest.mark.parametrize(("api_key", "expected"), [("c4ac433a-uuid", "v1"), (V3_KEY, "v3")])
|
||||
def test_blank_api_version_follows_key_prefix_without_warning(
|
||||
api_key: str, expected: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.types.guardrails import Guardrail, LitellmParams
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", litellm.callbacks.copy())
|
||||
params: Final = LitellmParams(guardrail="straiker", mode="pre_call", api_key=api_key, api_version="")
|
||||
|
||||
with patch.object(verbose_proxy_logger, "warning") as warning:
|
||||
g = initialize_guardrail(params, Guardrail(guardrail_name="straiker", litellm_params=params))
|
||||
|
||||
assert g.api_version == expected
|
||||
warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_version", ["2024-09-01", "v2"])
|
||||
@pytest.mark.parametrize(("api_key", "expected"), [("c4ac433a-uuid", "v1"), (V3_KEY, "v3")])
|
||||
def test_unknown_api_version_follows_key_prefix(api_version, api_key, expected, monkeypatch):
|
||||
import litellm
|
||||
|
|
|
|||
|
|
@ -1670,6 +1670,31 @@ async def test_patch_guardrail_clears_scope_when_logging_only_mode_is_removed(
|
|||
handler.delete_in_memory_guardrail(stored_guardrail["guardrail_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_guardrail_with_blank_value_clears_the_stored_value(mocker, monkeypatch, mock_guardrail_registry):
|
||||
"""
|
||||
Regression: blank litellm_params were dropped before validation, so a PATCH that cleared
|
||||
api_base with "" returned 200 but kept the stored value.
|
||||
"""
|
||||
handler, stored_guardrail = _setup_patch_scope_guardrail(
|
||||
mocker,
|
||||
monkeypatch,
|
||||
mock_guardrail_registry,
|
||||
_PatchScopeSupportedGuardrail,
|
||||
"patch_blank_value_test",
|
||||
{"mode": "pre_call", "api_base": "https://old.example.com"},
|
||||
)
|
||||
request = PatchGuardrailRequest.model_validate({"litellm_params": {"api_base": ""}})
|
||||
|
||||
try:
|
||||
await patch_guardrail(stored_guardrail["guardrail_id"], request, user_api_key_dict=MOCK_ADMIN_USER)
|
||||
|
||||
persisted_guardrail = mock_guardrail_registry.update_guardrail_in_db.call_args.kwargs["guardrail"]
|
||||
assert persisted_guardrail["litellm_params"].api_base is None
|
||||
finally:
|
||||
handler.delete_in_memory_guardrail(stored_guardrail["guardrail_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_guardrail_tolerates_stored_unsupported_scope_on_unrelated_update(
|
||||
mocker, monkeypatch, mock_guardrail_registry, caplog
|
||||
|
|
|
|||
|
|
@ -371,6 +371,44 @@ def test_init_guardrails_v2_skips_invalid_guardrail_instead_of_crashing_boot():
|
|||
assert "healthy_presidio" in guardrail_names
|
||||
|
||||
|
||||
def test_init_guardrails_v2_keeps_guardrail_whose_env_values_are_blank():
|
||||
"""
|
||||
Regression: a config.yaml guardrail with `default_on: os.environ/GUARDRAIL_DEFAULT_ON` and
|
||||
that variable exported blank (a Helm `value: ""`) resolved to "", pydantic rejected it on the
|
||||
bool field, and the proxy logged the guardrail and booted without it. Blank now means unset,
|
||||
so the guardrail boots with its defaults.
|
||||
"""
|
||||
from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER
|
||||
|
||||
IN_MEMORY_GUARDRAIL_HANDLER.IN_MEMORY_GUARDRAILS.clear()
|
||||
IN_MEMORY_GUARDRAIL_HANDLER.guardrail_id_to_custom_guardrail.clear()
|
||||
|
||||
all_guardrails = [
|
||||
{
|
||||
"guardrail_name": "presidio_with_blank_env_values",
|
||||
"litellm_params": {
|
||||
"guardrail": SupportedGuardrailIntegrations.PRESIDIO.value,
|
||||
"mode": "pre_call",
|
||||
"default_on": "",
|
||||
"mask_request_content": "",
|
||||
"presidio_analyzer_api_base": "https://fakelink.com/v1/presidio/analyze",
|
||||
"presidio_anonymizer_api_base": "https://fakelink.com/v1/presidio/anonymize",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
init_guardrails_v2(all_guardrails=all_guardrails)
|
||||
|
||||
initialized = {
|
||||
guardrail["guardrail_name"]: guardrail
|
||||
for guardrail in IN_MEMORY_GUARDRAIL_HANDLER.IN_MEMORY_GUARDRAILS.values()
|
||||
}
|
||||
assert "presidio_with_blank_env_values" in initialized
|
||||
litellm_params = initialized["presidio_with_blank_env_values"]["litellm_params"]
|
||||
assert litellm_params.default_on is False
|
||||
assert litellm_params.mask_request_content is None
|
||||
|
||||
|
||||
def test_init_guardrails_v2_stops_boot_when_a_custom_code_guardrail_does_not_compile():
|
||||
from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,19 @@ from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outco
|
|||
from unittest.mock import patch
|
||||
|
||||
|
||||
_ENV_HELPERS = frozenset(
|
||||
{"get_env_int", "get_env_float", "get_env_int_in_range", "get_env_int_or_none", "get_env_float_or_none"}
|
||||
)
|
||||
|
||||
|
||||
def _single_name_target(node: ast.stmt) -> str | None:
|
||||
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
return node.target.id
|
||||
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
||||
return node.targets[0].id
|
||||
return None
|
||||
|
||||
|
||||
def _build_constant_env_var_map() -> dict[str, str]:
|
||||
"""
|
||||
Build a mapping of CONSTANT_NAME -> ENV_VAR_NAME by parsing constants.py.
|
||||
|
|
@ -29,13 +42,10 @@ def _build_constant_env_var_map() -> dict[str, str]:
|
|||
parsed = ast.parse(constants_source)
|
||||
|
||||
for node in parsed.body:
|
||||
if not isinstance(node, ast.Assign):
|
||||
constant_name = _single_name_target(node)
|
||||
if constant_name is None or not isinstance(node, (ast.Assign, ast.AnnAssign)) or node.value is None:
|
||||
continue
|
||||
|
||||
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
|
||||
continue
|
||||
|
||||
constant_name = node.targets[0].id
|
||||
env_var_name = None
|
||||
|
||||
for child in ast.walk(node.value):
|
||||
|
|
@ -55,10 +65,10 @@ def _build_constant_env_var_map() -> dict[str, str]:
|
|||
env_var_name = child.args[0].value
|
||||
break
|
||||
|
||||
# get_env_int("ENV_NAME", default)
|
||||
# get_env_int("ENV_NAME", default) and its siblings
|
||||
if (
|
||||
isinstance(child.func, ast.Name)
|
||||
and child.func.id == "get_env_int"
|
||||
and child.func.id in _ENV_HELPERS
|
||||
and len(child.args) >= 1
|
||||
and isinstance(child.args[0], ast.Constant)
|
||||
and isinstance(child.args[0].value, str)
|
||||
|
|
@ -79,8 +89,10 @@ def _build_constant_env_var_map() -> dict[str, str]:
|
|||
(None, "48", 48),
|
||||
(None, None, 24),
|
||||
("48", "72", 48),
|
||||
(" ", None, 24),
|
||||
("", "48", 48),
|
||||
],
|
||||
ids=("canonical-only", "alias-only", "default", "canonical-wins"),
|
||||
ids=("canonical-only", "alias-only", "default", "canonical-wins", "blank-canonical", "blank-canonical-alias-set"),
|
||||
)
|
||||
def test_cli_jwt_expiration_hours_from_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -105,12 +117,79 @@ def test_cli_jwt_expiration_hours_from_environment(
|
|||
importlib.reload(litellm.constants)
|
||||
|
||||
|
||||
def _is_blank(value: object) -> bool:
|
||||
return isinstance(value, str) and not value.strip()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " "])
|
||||
def test_constants_import_treats_blank_env_values_as_unset(blank: str) -> None:
|
||||
"""
|
||||
Regression: a numeric knob exported as a blank string (a Helm `value: ""` or an emptied .env
|
||||
line) raised ValueError while importing litellm.constants, so the proxy crash-looped at boot.
|
||||
Blank now means unset, so import succeeds with every env-backed constant blank, and every
|
||||
numeric or optional constant keeps the value it has with the variable absent.
|
||||
"""
|
||||
constant_env_vars = _build_constant_env_var_map()
|
||||
assert {"ROUTER_MAX_FALLBACKS", "LITELLM_MAX_STREAMING_DURATION_SECONDS"} <= constant_env_vars.keys()
|
||||
env_vars = set(constant_env_vars.values())
|
||||
try:
|
||||
with mock.patch.dict(os.environ):
|
||||
for env_var in env_vars:
|
||||
os.environ.pop(env_var, None)
|
||||
importlib.reload(litellm.constants)
|
||||
unset = {name: getattr(litellm.constants, name) for name in constant_env_vars}
|
||||
with mock.patch.dict(os.environ, {env_var: blank for env_var in env_vars}):
|
||||
importlib.reload(litellm.constants)
|
||||
with_blank_env = {name: getattr(litellm.constants, name) for name in constant_env_vars}
|
||||
finally:
|
||||
importlib.reload(litellm.constants)
|
||||
|
||||
numeric_or_optional = {name for name, value in unset.items() if value is None or type(value) in (int, float)}
|
||||
changed = {
|
||||
name: (unset[name], with_blank_env[name])
|
||||
for name in numeric_or_optional
|
||||
if with_blank_env[name] != unset[name] and not _is_blank(with_blank_env[name])
|
||||
}
|
||||
assert changed == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected_timeout", "expected_explicit"),
|
||||
[
|
||||
(None, constants.DEFAULT_REQUEST_TIMEOUT_SECONDS, False),
|
||||
("", constants.DEFAULT_REQUEST_TIMEOUT_SECONDS, False),
|
||||
(" ", constants.DEFAULT_REQUEST_TIMEOUT_SECONDS, False),
|
||||
("fast", constants.DEFAULT_REQUEST_TIMEOUT_SECONDS, False),
|
||||
(" 30 ", 30.0, True),
|
||||
],
|
||||
)
|
||||
def test_request_timeout_counts_as_explicit_only_when_it_parses(
|
||||
monkeypatch: pytest.MonkeyPatch, raw: str | None, expected_timeout: float, expected_explicit: bool
|
||||
) -> None:
|
||||
"""
|
||||
Regression: a blank REQUEST_TIMEOUT fell back to the 6000s sentinel but was still flagged as
|
||||
explicitly set, so chat completion() kept the 6000s sentinel instead of its 600s fallback.
|
||||
"""
|
||||
if raw is None:
|
||||
monkeypatch.delenv("REQUEST_TIMEOUT", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("REQUEST_TIMEOUT", raw)
|
||||
try:
|
||||
importlib.reload(litellm.constants)
|
||||
assert litellm.constants.request_timeout == expected_timeout
|
||||
assert litellm.constants.request_timeout_explicitly_set is expected_explicit
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
importlib.reload(litellm.constants)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _vcr_outcome_gate(request, vcr):
|
||||
install_live_call_probe(request, vcr)
|
||||
yield
|
||||
record_vcr_outcome(request, vcr)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
try:
|
||||
|
|
@ -120,6 +199,7 @@ def event_loop():
|
|||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def setup_and_teardown(event_loop):
|
||||
import litellm
|
||||
|
|
@ -153,6 +233,7 @@ def setup_and_teardown(event_loop):
|
|||
if pending:
|
||||
event_loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
|
||||
|
||||
_SCALAR_DEFAULTS = {
|
||||
"num_retries": getattr(litellm, "num_retries", None),
|
||||
"set_verbose": getattr(litellm, "set_verbose", False),
|
||||
|
|
@ -176,17 +257,12 @@ def _populate_known_models_for_morph_tests(monkeypatch: pytest.MonkeyPatch) -> N
|
|||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"models_by_provider",
|
||||
{
|
||||
provider: models.copy()
|
||||
for provider, models in litellm.models_by_provider.items()
|
||||
},
|
||||
{provider: models.copy() for provider, models in litellm.models_by_provider.items()},
|
||||
)
|
||||
litellm.add_known_models()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests"
|
||||
)
|
||||
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests")
|
||||
def test_morph_config_get_provider_info():
|
||||
"""Test that MorphChatConfig returns correct provider info."""
|
||||
config = MorphChatConfig()
|
||||
|
|
@ -207,9 +283,8 @@ def test_morph_config_get_provider_info():
|
|||
assert api_base == "https://custom.morph.com"
|
||||
assert api_key == "key"
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests"
|
||||
)
|
||||
|
||||
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests")
|
||||
def test_morph_get_llm_provider():
|
||||
"""Test that get_llm_provider correctly identifies morph models."""
|
||||
# Test with morph/model format
|
||||
|
|
@ -219,9 +294,8 @@ def test_morph_get_llm_provider():
|
|||
_, custom_llm_provider, _, _ = get_llm_provider("morph/morph-v3-fast")
|
||||
assert custom_llm_provider == "morph"
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests"
|
||||
)
|
||||
|
||||
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests")
|
||||
def test_morph_in_provider_lists():
|
||||
"""Test that morph is included in all necessary provider lists."""
|
||||
import litellm
|
||||
|
|
@ -242,9 +316,8 @@ def test_morph_in_provider_lists():
|
|||
# Check models are in model_list after initialization
|
||||
assert all(model in litellm.model_list for model in ["morph/morph-v3-large", "morph/morph-v3-fast"])
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests"
|
||||
)
|
||||
|
||||
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests")
|
||||
def test_morph_supported_params():
|
||||
"""Test that MorphChatConfig returns correct supported parameters."""
|
||||
config = MorphChatConfig()
|
||||
|
|
@ -258,9 +331,8 @@ def test_morph_supported_params():
|
|||
|
||||
assert all(param in supported_params for param in expected_params)
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests"
|
||||
)
|
||||
|
||||
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown", "_populate_known_models_for_morph_tests")
|
||||
def test_morph_custom_llm_provider():
|
||||
"""Test that morph models are correctly identified."""
|
||||
config = MorphChatConfig()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import litellm.litellm_core_utils
|
|||
import litellm.litellm_core_utils.litellm_logging
|
||||
from litellm import completion
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.guardrails import BaseLitellmParams, LitellmParams
|
||||
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
|
||||
|
||||
|
||||
|
|
@ -157,3 +158,23 @@ def setup_and_teardown():
|
|||
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " "])
|
||||
def test_litellm_params_treats_blank_values_as_unset(blank: str) -> None:
|
||||
"""
|
||||
Regression: `os.environ/X` with X exported blank resolves to "", which pydantic rejected on
|
||||
bool fields (default_on, mask_request_content, fail_on_error), so the guardrail was dropped at
|
||||
boot. Blank now means unset, so the field keeps the same default as when it is omitted.
|
||||
"""
|
||||
with_blank_values = LitellmParams(
|
||||
guardrail="presidio",
|
||||
mode="pre_call",
|
||||
default_on=blank,
|
||||
mask_request_content=blank,
|
||||
api_key=blank,
|
||||
)
|
||||
omitted = LitellmParams(guardrail="presidio", mode="pre_call")
|
||||
assert with_blank_values.model_dump() == omitted.model_dump()
|
||||
|
||||
assert BaseLitellmParams(fail_on_error=blank).model_dump() == BaseLitellmParams().model_dump()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue