mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(health): opt-in model-group allowlist for background health checks and health-check routing (#38539)
* feat(health): opt-in model-group allowlist for background health checks and health-check routing * fix(health): merge shared health states per writer scope instead of replacing * refactor(health): drop restating comment and parameterize test scope annotations * chore: remove stray generated prisma migration file * fix(health): merge health states against the Redis snapshot, not the pod-local copy * fix(health): fall back to the pod-local snapshot when the Redis read returns nothing
This commit is contained in:
parent
493bca667b
commit
452254963e
11 changed files with 452 additions and 12 deletions
|
|
@ -2510,6 +2510,18 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
"are skipped for on-demand GET /health as well as the background health loop."
|
||||
),
|
||||
)
|
||||
background_health_check_model_groups: tuple[str, ...] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Opt-in allowlist of model group names for background health checks and "
|
||||
"health-check routing. When set, the background loop probes only deployments "
|
||||
"whose model_name is listed, and enable_health_check_routing filters unhealthy "
|
||||
"deployments only within the listed groups; every other group, including newly "
|
||||
"added deployments, is skipped and keeps its configured routing strategy. "
|
||||
"When unset, all deployments participate (opt out per deployment via "
|
||||
"model_info.disable_background_health_check)."
|
||||
),
|
||||
)
|
||||
model_list_healthy_only: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ import sys
|
|||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING, Final, TypeVar
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
|
||||
|
|
@ -16,6 +19,7 @@ if TYPE_CHECKING:
|
|||
from litellm.router import Router
|
||||
|
||||
logger: Final = logging.getLogger(__name__)
|
||||
_DeploymentT: Final = TypeVar("_DeploymentT", bound=Mapping[str, object])
|
||||
from litellm.constants import (
|
||||
BACKGROUND_HEALTH_CHECK_MAX_TOKENS,
|
||||
BACKGROUND_HEALTH_CHECK_MAX_TOKENS_REASONING,
|
||||
|
|
@ -167,6 +171,38 @@ def health_check_filter_kwargs_from_general_settings(
|
|||
}
|
||||
|
||||
|
||||
def parse_background_health_check_model_groups(
|
||||
general_settings: Mapping[str, object] | None,
|
||||
) -> frozenset[str] | None:
|
||||
"""
|
||||
Read ``general_settings.background_health_check_model_groups``.
|
||||
|
||||
``None`` means the allowlist is unset and every deployment participates
|
||||
(legacy behavior). A list scopes background health checks and health-check
|
||||
routing to deployments whose ``model_name`` is listed. A malformed value
|
||||
raises so the proxy fails at startup instead of silently probing everything.
|
||||
"""
|
||||
raw: Final = (general_settings or {}).get("background_health_check_model_groups")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return frozenset(TypeAdapter(list[str]).validate_python(raw))
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
"general_settings.background_health_check_model_groups must be a list of model group names"
|
||||
) from e
|
||||
|
||||
|
||||
def filter_deployments_to_model_groups(
|
||||
model_list: Sequence[_DeploymentT],
|
||||
model_groups: AbstractSet[str] | None,
|
||||
) -> tuple[_DeploymentT, ...]:
|
||||
"""Deployments whose ``model_name`` is in ``model_groups``; all of them when unset."""
|
||||
if model_groups is None:
|
||||
return tuple(model_list)
|
||||
return tuple(x for x in model_list if x.get("model_name") in model_groups)
|
||||
|
||||
|
||||
def filter_deployments_by_id(
|
||||
model_list: Sequence[Mapping[str, object]],
|
||||
) -> list:
|
||||
|
|
|
|||
|
|
@ -411,7 +411,9 @@ from litellm.proxy.guardrails.init_guardrails import (
|
|||
initialize_guardrails,
|
||||
)
|
||||
from litellm.proxy.health_check import (
|
||||
filter_deployments_to_model_groups,
|
||||
health_check_filter_kwargs_from_general_settings,
|
||||
parse_background_health_check_model_groups,
|
||||
perform_health_check,
|
||||
)
|
||||
from litellm.proxy.health_endpoints._health_endpoints import router as health_router
|
||||
|
|
@ -3660,6 +3662,13 @@ async def _run_background_health_check():
|
|||
_llm_model_list = [
|
||||
m for m in _llm_model_list if not m.get("model_info", {}).get("disable_background_health_check", False)
|
||||
]
|
||||
scoped_model_groups = llm_router.background_health_check_model_groups if llm_router is not None else None
|
||||
_llm_model_list = list(filter_deployments_to_model_groups(_llm_model_list, scoped_model_groups))
|
||||
if scoped_model_groups is not None and not _llm_model_list:
|
||||
verbose_proxy_logger.warning(
|
||||
"background_health_check_model_groups matched no deployments; groups=%s",
|
||||
sorted(scoped_model_groups),
|
||||
)
|
||||
model_count_enabled = len(_llm_model_list)
|
||||
expected_peak_in_flight = model_count_enabled
|
||||
if isinstance(health_check_concurrency, int) and health_check_concurrency > 0 and model_count_enabled > 0:
|
||||
|
|
@ -5239,6 +5248,7 @@ class ProxyConfig:
|
|||
general_settings = config.get("general_settings", {})
|
||||
if general_settings is None:
|
||||
general_settings = {}
|
||||
_bg_hc_model_groups: Final = parse_background_health_check_model_groups(general_settings)
|
||||
_enable_hc_routing = False
|
||||
_hc_staleness = None
|
||||
_hc_ignore_transient = False
|
||||
|
|
@ -5434,13 +5444,14 @@ class ProxyConfig:
|
|||
_hc_staleness = general_settings.get("health_check_staleness_threshold", None)
|
||||
_hc_ignore_transient = general_settings.get("health_check_ignore_transient_errors", False)
|
||||
verbose_proxy_logger.info(
|
||||
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s",
|
||||
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s model_groups=%s",
|
||||
use_background_health_checks,
|
||||
use_shared_health_check,
|
||||
health_check_interval,
|
||||
health_check_concurrency,
|
||||
health_check_details,
|
||||
_enable_hc_routing,
|
||||
sorted(_bg_hc_model_groups) if _bg_hc_model_groups is not None else None,
|
||||
)
|
||||
|
||||
### RBAC ###
|
||||
|
|
@ -5472,6 +5483,8 @@ class ProxyConfig:
|
|||
router_params["health_check_staleness_threshold"] = _hc_staleness
|
||||
if _hc_ignore_transient:
|
||||
router_params["health_check_ignore_transient_errors"] = True
|
||||
if _bg_hc_model_groups is not None:
|
||||
router_params["background_health_check_model_groups"] = sorted(_bg_hc_model_groups)
|
||||
## MODEL LIST
|
||||
model_list: Final = config.get("model_list", None)
|
||||
if model_list:
|
||||
|
|
|
|||
|
|
@ -602,6 +602,7 @@ class Router:
|
|||
enable_health_check_routing: bool = False,
|
||||
health_check_staleness_threshold: int | None = None,
|
||||
health_check_ignore_transient_errors: bool = False,
|
||||
background_health_check_model_groups: Sequence[str] | None = None,
|
||||
enable_weighted_failover: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
|
|
@ -811,6 +812,11 @@ class Router:
|
|||
self.enable_health_check_routing = enable_health_check_routing
|
||||
self.enable_weighted_failover = enable_weighted_failover
|
||||
self.health_check_ignore_transient_errors = health_check_ignore_transient_errors
|
||||
self.background_health_check_model_groups: frozenset[str] | None = (
|
||||
frozenset(background_health_check_model_groups)
|
||||
if background_health_check_model_groups is not None
|
||||
else None
|
||||
)
|
||||
_staleness: Final = health_check_staleness_threshold or (
|
||||
DEFAULT_HEALTH_CHECK_INTERVAL * DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER
|
||||
)
|
||||
|
|
@ -12719,6 +12725,10 @@ class Router:
|
|||
"""
|
||||
Filter out deployments marked unhealthy by background health checks.
|
||||
No-op when enable_health_check_routing is False.
|
||||
When background_health_check_model_groups is set, only deployments in the
|
||||
listed model groups are filtered; every other group keeps its configured
|
||||
routing strategy untouched, and a router-level allowed_fails_policy no
|
||||
longer disables the filter for the listed groups.
|
||||
Returns all deployments if health state is unavailable, stale, or would
|
||||
exclude every candidate (safety net).
|
||||
"""
|
||||
|
|
@ -12727,8 +12737,10 @@ class Router:
|
|||
|
||||
# When allowed_fails_policy is set, cooldown is the sole routing exclusion
|
||||
# mechanism -- skip the binary health check filter so the policy threshold
|
||||
# is respected before any deployment is excluded.
|
||||
if self.allowed_fails_policy is not None:
|
||||
# is respected before any deployment is excluded. With a model-group
|
||||
# allowlist the filter is already scoped, so listed groups keep it.
|
||||
scoped_groups: Final = self.background_health_check_model_groups
|
||||
if self.allowed_fails_policy is not None and scoped_groups is None:
|
||||
return healthy_deployments
|
||||
|
||||
unhealthy_ids: Final = await self.health_state_cache.async_get_unhealthy_deployment_ids(
|
||||
|
|
@ -12737,7 +12749,12 @@ class Router:
|
|||
if not unhealthy_ids:
|
||||
return healthy_deployments
|
||||
|
||||
filtered: Final = [d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids]
|
||||
filtered: Final = [
|
||||
d
|
||||
for d in healthy_deployments
|
||||
if d["model_info"]["id"] not in unhealthy_ids
|
||||
or (scoped_groups is not None and d["model_name"] not in scoped_groups)
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
|
||||
|
|
@ -12754,14 +12771,20 @@ class Router:
|
|||
if not self.enable_health_check_routing:
|
||||
return healthy_deployments
|
||||
|
||||
if self.allowed_fails_policy is not None:
|
||||
scoped_groups: Final = self.background_health_check_model_groups
|
||||
if self.allowed_fails_policy is not None and scoped_groups is None:
|
||||
return healthy_deployments
|
||||
|
||||
unhealthy_ids: Final = self.health_state_cache.get_unhealthy_deployment_ids(parent_otel_span=parent_otel_span)
|
||||
if not unhealthy_ids:
|
||||
return healthy_deployments
|
||||
|
||||
filtered: Final = [d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids]
|
||||
filtered: Final = [
|
||||
d
|
||||
for d in healthy_deployments
|
||||
if d["model_info"]["id"] not in unhealthy_ids
|
||||
or (scoped_groups is not None and d["model_name"] not in scoped_groups)
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
|
||||
|
|
|
|||
|
|
@ -43,12 +43,33 @@ class DeploymentHealthCache:
|
|||
self.staleness_threshold = staleness_threshold
|
||||
|
||||
def set_deployment_health_states(self, states: dict[str, DeploymentHealthStateValue]) -> None:
|
||||
"""Bulk-write all deployment health states as a single cache entry."""
|
||||
"""Merge the given states into the shared cache entry, pruning expired ones.
|
||||
|
||||
Merging instead of replacing lets writers probing different deployment
|
||||
scopes (e.g. pods with different background health check allowlists)
|
||||
coexist on the one shared entry without erasing each other's results.
|
||||
The snapshot is read from Redis when available, since a pod-local read
|
||||
would only ever see this writer's own previous merge. When the Redis
|
||||
read comes back empty (a miss, or a swallowed connection error), the
|
||||
pod-local copy of the last merge is used so peers are not erased.
|
||||
"""
|
||||
try:
|
||||
redis_raw: Final = (
|
||||
self.cache.redis_cache.get_cache(self.CACHE_KEY) if self.cache.redis_cache is not None else None
|
||||
)
|
||||
raw: Final = redis_raw if isinstance(redis_raw, dict) else self.cache.get_cache(key=self.CACHE_KEY)
|
||||
existing: Final = raw if isinstance(raw, dict) else {}
|
||||
expiry_seconds: Final = self.staleness_threshold * 1.5
|
||||
now: Final = time.time()
|
||||
merged: Final = {
|
||||
model_id: state
|
||||
for model_id, state in {**existing, **states}.items()
|
||||
if isinstance(state, dict) and (now - state.get("timestamp", 0)) < expiry_seconds
|
||||
}
|
||||
self.cache.set_cache(
|
||||
key=self.CACHE_KEY,
|
||||
value=states,
|
||||
ttl=int(self.staleness_threshold * 1.5),
|
||||
value=merged,
|
||||
ttl=int(expiry_seconds),
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.error(
|
||||
|
|
|
|||
|
|
@ -581,3 +581,73 @@ async def test_run_background_health_check_runs_one_cycle_then_cancels(monkeypat
|
|||
"unhealthy_count": 1,
|
||||
"sleep_invoked": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_background_health_check_probes_only_listed_model_groups(monkeypatch):
|
||||
monkeypatch.setattr(proxy_server, "health_check_interval", 60)
|
||||
monkeypatch.setattr(proxy_server, "health_check_concurrency", 1)
|
||||
monkeypatch.setattr(proxy_server, "health_check_details", True)
|
||||
monkeypatch.setattr(proxy_server, "use_shared_health_check", False)
|
||||
monkeypatch.setattr(proxy_server, "redis_usage_cache", None)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
||||
monkeypatch.setattr(proxy_server, "background_health_check_loop_active", False)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"llm_router",
|
||||
SimpleNamespace(background_health_check_model_groups=frozenset({"prod-openai"})),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"llm_model_list",
|
||||
[
|
||||
{"model_name": "prod-openai", "model_info": {"id": "listed-1"}},
|
||||
{"model_name": "prod-openai", "model_info": {"id": "listed-2"}},
|
||||
{"model_name": "internal-claude", "model_info": {"id": "unlisted-1"}},
|
||||
{
|
||||
"model_name": "prod-openai",
|
||||
"model_info": {
|
||||
"id": "listed-disabled",
|
||||
"disable_background_health_check": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"health_check_results",
|
||||
{"healthy_endpoints": [], "unhealthy_endpoints": []},
|
||||
)
|
||||
|
||||
probed = {}
|
||||
|
||||
async def _fake_direct(model_list, *_a, **_kw):
|
||||
probed["ids"] = [m["model_info"]["id"] for m in model_list]
|
||||
return ([], [], {})
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"_run_direct_health_check_with_instrumentation",
|
||||
_fake_direct,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "_schedule_background_health_check_db_save", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "_write_health_state_to_router_cache", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"health_check_filter_kwargs_from_general_settings",
|
||||
lambda _gs: {},
|
||||
)
|
||||
|
||||
async def _stop_sleep(_seconds):
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(proxy_server.asyncio, "sleep", _stop_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _run_background_health_check()
|
||||
|
||||
assert probed["ids"] == ["listed-1", "listed-2"]
|
||||
|
|
|
|||
|
|
@ -623,5 +623,53 @@ async def test_perform_health_check_and_save_forwards_skip_disabled_background_f
|
|||
assert call_kwargs["health_check_skip_disabled_background_models"] is True
|
||||
|
||||
|
||||
def test_parse_background_health_check_model_groups_unset_returns_none():
|
||||
from litellm.proxy.health_check import parse_background_health_check_model_groups
|
||||
|
||||
assert parse_background_health_check_model_groups(None) is None
|
||||
assert parse_background_health_check_model_groups({}) is None
|
||||
assert (
|
||||
parse_background_health_check_model_groups(
|
||||
{"background_health_check_model_groups": None}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_parse_background_health_check_model_groups_list_returns_frozenset():
|
||||
from litellm.proxy.health_check import parse_background_health_check_model_groups
|
||||
|
||||
parsed = parse_background_health_check_model_groups(
|
||||
{"background_health_check_model_groups": ["prod-openai", "prod-claude"]}
|
||||
)
|
||||
assert parsed == frozenset({"prod-openai", "prod-claude"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_value", ["prod-openai", 42, {"a": 1}, [1, 2], [None]])
|
||||
def test_parse_background_health_check_model_groups_malformed_raises(bad_value):
|
||||
from litellm.proxy.health_check import parse_background_health_check_model_groups
|
||||
|
||||
with pytest.raises(ValueError, match="must be a list of model group names"):
|
||||
parse_background_health_check_model_groups(
|
||||
{"background_health_check_model_groups": bad_value}
|
||||
)
|
||||
|
||||
|
||||
def test_filter_deployments_to_model_groups():
|
||||
from litellm.proxy.health_check import filter_deployments_to_model_groups
|
||||
|
||||
model_list = [
|
||||
{"model_name": "prod-openai", "model_info": {"id": "a"}},
|
||||
{"model_name": "internal-claude", "model_info": {"id": "b"}},
|
||||
{"model_name": "prod-openai", "model_info": {"id": "c"}},
|
||||
]
|
||||
|
||||
assert filter_deployments_to_model_groups(model_list, None) == tuple(model_list)
|
||||
assert filter_deployments_to_model_groups(
|
||||
model_list, frozenset({"prod-openai"})
|
||||
) == (model_list[0], model_list[2])
|
||||
assert filter_deployments_to_model_groups(model_list, frozenset()) == ()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
|
|
|||
|
|
@ -502,6 +502,68 @@ class TestHealthCheckFilterBypassWithPolicy:
|
|||
)
|
||||
assert len(result) == 2
|
||||
|
||||
def _make_scoped_router_with_unhealthy(self, policy) -> Router:
|
||||
import time
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.router_utils.health_state_cache import DeploymentHealthCache
|
||||
|
||||
router = Router(
|
||||
model_list=[
|
||||
_make_model("bad-listed"),
|
||||
_make_model("ok-listed"),
|
||||
_make_model("bad-unlisted", "gpt-5"),
|
||||
],
|
||||
allowed_fails_policy=policy,
|
||||
enable_health_check_routing=True,
|
||||
background_health_check_model_groups=["gpt-4"],
|
||||
)
|
||||
cache = DualCache()
|
||||
health_cache = DeploymentHealthCache(cache=cache, staleness_threshold=60.0)
|
||||
health_cache.set_deployment_health_states(
|
||||
{
|
||||
model_id: {
|
||||
"is_healthy": False,
|
||||
"timestamp": time.time(),
|
||||
"reason": "test",
|
||||
}
|
||||
for model_id in ("bad-listed", "bad-unlisted")
|
||||
}
|
||||
)
|
||||
router.health_state_cache = health_cache
|
||||
return router
|
||||
|
||||
def test_filter_with_policy_still_applies_to_listed_groups(self):
|
||||
"""A model-group allowlist keeps the filter active for listed groups even with a policy set."""
|
||||
router = self._make_scoped_router_with_unhealthy(
|
||||
AllowedFailsPolicy(AuthenticationErrorAllowedFails=3)
|
||||
)
|
||||
deployments = [
|
||||
_make_model("bad-listed"),
|
||||
_make_model("ok-listed"),
|
||||
_make_model("bad-unlisted", "gpt-5"),
|
||||
]
|
||||
|
||||
result = router._filter_health_check_unhealthy_deployments(deployments)
|
||||
assert [d["model_info"]["id"] for d in result] == ["ok-listed", "bad-unlisted"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_filter_with_policy_still_applies_to_listed_groups(self):
|
||||
"""Async version: listed groups stay filtered with a policy set, unlisted stay untouched."""
|
||||
router = self._make_scoped_router_with_unhealthy(
|
||||
AllowedFailsPolicy(TimeoutErrorAllowedFails=2)
|
||||
)
|
||||
deployments = [
|
||||
_make_model("bad-listed"),
|
||||
_make_model("ok-listed"),
|
||||
_make_model("bad-unlisted", "gpt-5"),
|
||||
]
|
||||
|
||||
result = await router._async_filter_health_check_unhealthy_deployments(
|
||||
deployments
|
||||
)
|
||||
assert [d["model_info"]["id"] for d in result] == ["ok-listed", "bad-unlisted"]
|
||||
|
||||
|
||||
class TestAllDeploymentsInCooldownSafetyNet:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -111,3 +111,84 @@ def test_malformed_state_entries_are_skipped(health_cache):
|
|||
health_cache.set_deployment_health_states(states)
|
||||
result = health_cache.get_unhealthy_deployment_ids()
|
||||
assert result == {"deploy-1"}
|
||||
|
||||
|
||||
def test_set_merges_states_from_scoped_writers(health_cache):
|
||||
"""A writer covering one scope must not erase another scope's fresh states."""
|
||||
now = time.time()
|
||||
health_cache.set_deployment_health_states(
|
||||
{"listed-bad": {"is_healthy": False, "timestamp": now, "reason": "check_failed"}}
|
||||
)
|
||||
health_cache.set_deployment_health_states(
|
||||
{"other-ok": {"is_healthy": True, "timestamp": now, "reason": ""}}
|
||||
)
|
||||
assert health_cache.get_unhealthy_deployment_ids() == {"listed-bad"}
|
||||
|
||||
|
||||
def test_set_prunes_expired_entries(health_cache, cache):
|
||||
"""Entries older than 1.5x the staleness threshold are dropped on write."""
|
||||
expired_time = time.time() - 100 # threshold 60s, prune horizon 90s
|
||||
health_cache.set_deployment_health_states(
|
||||
{"gone": {"is_healthy": False, "timestamp": expired_time, "reason": "check_failed"}}
|
||||
)
|
||||
now = time.time()
|
||||
health_cache.set_deployment_health_states(
|
||||
{"fresh": {"is_healthy": False, "timestamp": now, "reason": "check_failed"}}
|
||||
)
|
||||
stored = cache.get_cache(key=DeploymentHealthCache.CACHE_KEY)
|
||||
assert set(stored.keys()) == {"fresh"}
|
||||
|
||||
|
||||
class _SharedRedisFake:
|
||||
"""Shared get/set key-value store standing in for the Redis layer of a DualCache."""
|
||||
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
self.fail_get = False
|
||||
|
||||
def get_cache(self, key, parent_otel_span=None, **kwargs):
|
||||
if self.fail_get:
|
||||
return None # RedisCache.get_cache swallows connection errors and returns None
|
||||
return self.store.get(key)
|
||||
|
||||
def set_cache(self, key, value, **kwargs):
|
||||
self.store[key] = value
|
||||
|
||||
|
||||
def test_scoped_writers_on_shared_redis_preserve_each_other():
|
||||
"""Pods with different allowlists share one Redis entry; each merge must keep the peer's scope."""
|
||||
redis_fake = _SharedRedisFake()
|
||||
pod_a = DeploymentHealthCache(cache=DualCache(redis_cache=redis_fake), staleness_threshold=60.0)
|
||||
pod_b = DeploymentHealthCache(cache=DualCache(redis_cache=redis_fake), staleness_threshold=60.0)
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
pod_b.set_deployment_health_states(
|
||||
{"internal-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "timeout"}}
|
||||
)
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
assert set(redis_fake.store[DeploymentHealthCache.CACHE_KEY]) == {"prod-bad", "internal-bad"}
|
||||
assert pod_a.get_unhealthy_deployment_ids() == {"prod-bad", "internal-bad"}
|
||||
|
||||
|
||||
def test_failed_redis_read_falls_back_to_local_copy():
|
||||
"""A swallowed Redis GET error must not make a writer erase peer scopes it already saw."""
|
||||
redis_fake = _SharedRedisFake()
|
||||
pod_a = DeploymentHealthCache(cache=DualCache(redis_cache=redis_fake), staleness_threshold=60.0)
|
||||
pod_b = DeploymentHealthCache(cache=DualCache(redis_cache=redis_fake), staleness_threshold=60.0)
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
pod_b.set_deployment_health_states(
|
||||
{"internal-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "timeout"}}
|
||||
)
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
redis_fake.fail_get = True
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
assert set(redis_fake.store[DeploymentHealthCache.CACHE_KEY]) == {"prod-bad", "internal-bad"}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,12 @@ def _make_health_cache(
|
|||
class TestFilterHealthCheckUnhealthyDeployments:
|
||||
"""Test the sync filter method."""
|
||||
|
||||
def _make_router_like(self, enable: bool, health_cache: DeploymentHealthCache):
|
||||
def _make_router_like(
|
||||
self,
|
||||
enable: bool,
|
||||
health_cache: DeploymentHealthCache,
|
||||
model_groups: frozenset[str] | None = None,
|
||||
):
|
||||
"""Create a minimal object that behaves like Router for filter testing."""
|
||||
|
||||
class FakeRouter:
|
||||
|
|
@ -51,6 +56,7 @@ class TestFilterHealthCheckUnhealthyDeployments:
|
|||
self.enable_health_check_routing = enable
|
||||
self.health_state_cache = health_cache
|
||||
self.allowed_fails_policy = None
|
||||
self.background_health_check_model_groups = model_groups
|
||||
|
||||
# Import the actual method and bind it
|
||||
from litellm.router import Router
|
||||
|
|
@ -115,11 +121,50 @@ class TestFilterHealthCheckUnhealthyDeployments:
|
|||
result = router._filter_health_check_unhealthy_deployments(deployments)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_filter_scoped_to_listed_model_groups(self):
|
||||
"""With an allowlist, only deployments in listed groups are filtered on health."""
|
||||
health_cache = _make_health_cache(unhealthy_ids={"bad-listed", "bad-unlisted"})
|
||||
router = self._make_router_like(
|
||||
enable=True, health_cache=health_cache, model_groups=frozenset({"prod"})
|
||||
)
|
||||
|
||||
deployments = [
|
||||
_make_deployment("bad-listed", model_name="prod"),
|
||||
_make_deployment("ok-listed", model_name="prod"),
|
||||
_make_deployment("bad-unlisted", model_name="other"),
|
||||
_make_deployment("ok-unlisted", model_name="other"),
|
||||
]
|
||||
result = router._filter_health_check_unhealthy_deployments(deployments)
|
||||
assert [d["model_info"]["id"] for d in result] == [
|
||||
"ok-listed",
|
||||
"bad-unlisted",
|
||||
"ok-unlisted",
|
||||
]
|
||||
|
||||
def test_filter_unscoped_when_model_groups_unset(self):
|
||||
"""Without an allowlist, unhealthy deployments in every group are filtered."""
|
||||
health_cache = _make_health_cache(unhealthy_ids={"bad-listed", "bad-unlisted"})
|
||||
router = self._make_router_like(enable=True, health_cache=health_cache)
|
||||
|
||||
deployments = [
|
||||
_make_deployment("bad-listed", model_name="prod"),
|
||||
_make_deployment("ok-listed", model_name="prod"),
|
||||
_make_deployment("bad-unlisted", model_name="other"),
|
||||
_make_deployment("ok-unlisted", model_name="other"),
|
||||
]
|
||||
result = router._filter_health_check_unhealthy_deployments(deployments)
|
||||
assert [d["model_info"]["id"] for d in result] == ["ok-listed", "ok-unlisted"]
|
||||
|
||||
|
||||
class TestAsyncFilterHealthCheckUnhealthyDeployments:
|
||||
"""Test the async filter method."""
|
||||
|
||||
def _make_router_like(self, enable: bool, health_cache: DeploymentHealthCache):
|
||||
def _make_router_like(
|
||||
self,
|
||||
enable: bool,
|
||||
health_cache: DeploymentHealthCache,
|
||||
model_groups: frozenset[str] | None = None,
|
||||
):
|
||||
from litellm.router import Router
|
||||
|
||||
class FakeRouter:
|
||||
|
|
@ -127,6 +172,7 @@ class TestAsyncFilterHealthCheckUnhealthyDeployments:
|
|||
self.enable_health_check_routing = enable
|
||||
self.health_state_cache = health_cache
|
||||
self.allowed_fails_policy = None
|
||||
self.background_health_check_model_groups = model_groups
|
||||
|
||||
fake = FakeRouter()
|
||||
fake._async_filter_health_check_unhealthy_deployments = (
|
||||
|
|
@ -168,6 +214,29 @@ class TestAsyncFilterHealthCheckUnhealthyDeployments:
|
|||
)
|
||||
assert len(result) == 2 # safety net
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_filter_scoped_to_listed_model_groups(self):
|
||||
"""Async version: only deployments in listed groups are filtered on health."""
|
||||
health_cache = _make_health_cache(unhealthy_ids={"bad-listed", "bad-unlisted"})
|
||||
router = self._make_router_like(
|
||||
enable=True, health_cache=health_cache, model_groups=frozenset({"prod"})
|
||||
)
|
||||
|
||||
deployments = [
|
||||
_make_deployment("bad-listed", model_name="prod"),
|
||||
_make_deployment("ok-listed", model_name="prod"),
|
||||
_make_deployment("bad-unlisted", model_name="other"),
|
||||
_make_deployment("ok-unlisted", model_name="other"),
|
||||
]
|
||||
result = await router._async_filter_health_check_unhealthy_deployments(
|
||||
healthy_deployments=deployments
|
||||
)
|
||||
assert [d["model_info"]["id"] for d in result] == [
|
||||
"ok-listed",
|
||||
"bad-unlisted",
|
||||
"ok-unlisted",
|
||||
]
|
||||
|
||||
|
||||
class TestBuildDeploymentHealthStates:
|
||||
"""Test the build_deployment_health_states function."""
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -24878,6 +24878,11 @@ export interface components {
|
|||
* @description If True, a user's personal max_budget is enforced on every request they make, including requests made with a team-scoped key. Defaults to False, where a team-scoped key is governed only by the team and team-member budgets and the key owner's personal max_budget does not apply (see GitHub issue #12905).
|
||||
*/
|
||||
apply_user_budget_to_team_keys?: boolean | null;
|
||||
/**
|
||||
* Background Health Check Model Groups
|
||||
* @description Opt-in allowlist of model group names for background health checks and health-check routing. When set, the background loop probes only deployments whose model_name is listed, and enable_health_check_routing filters unhealthy deployments only within the listed groups; every other group, including newly added deployments, is skipped and keeps its configured routing strategy. When unset, all deployments participate (opt out per deployment via model_info.disable_background_health_check).
|
||||
*/
|
||||
background_health_check_model_groups?: string[] | null;
|
||||
/**
|
||||
* Background Health Checks
|
||||
* @description run health checks in background
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue