mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
Code outside the router now reaches it only through the entry points (registry, settings, health, select, apply, observe, register_plugin, retire) or the endpoint methods; the outside-member allowlist shrinks from 139 to 45. litellm/router.py no longer imports litellm.proxy: it asks the installed RouterHost (litellm/router_host.py) for vertex pass-through credentials, member auto-router authorization and auto-router prompt compression, and the proxy installs its host on import. Call sites where a model name can be missing keep the old answer instead of reaching an entry point with None.
392 lines
13 KiB
Python
392 lines
13 KiB
Python
"""Tests for the Slack alerting model deprecation hook."""
|
|
|
|
import asyncio
|
|
from itertools import chain, repeat
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
import litellm
|
|
from litellm.constants import SLACK_MODEL_DEPRECATION_LOCK_ID
|
|
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
|
|
from litellm.proxy._types import AlertType
|
|
from litellm.types.integrations.slack_alerting import SlackAlertingCacheKeys
|
|
from litellm.types.proxy.model_deprecation import (
|
|
DEFAULT_DEPRECATION_CHECK_INTERVAL_SECONDS,
|
|
DEPRECATION_IDLE_POLL_SECONDS,
|
|
)
|
|
|
|
DEAD_MODEL_COST = {
|
|
"dead-model": {"deprecation_date": "2020-01-01", "litellm_provider": "openai"}
|
|
}
|
|
DEAD_ALIAS_DEPLOYMENT = {
|
|
"model_name": "dead-alias",
|
|
"litellm_params": {"model": "dead-model"},
|
|
"model_info": {"id": "1"},
|
|
}
|
|
|
|
|
|
def _make_router(deployments):
|
|
router = MagicMock()
|
|
router.registry.return_value.deployments.return_value = deployments
|
|
return router
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_skip_when_alert_type_disabled():
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"],
|
|
alert_types=[AlertType.llm_exceptions],
|
|
)
|
|
sent = await alerting.send_model_deprecation_alert(llm_router=MagicMock())
|
|
assert sent is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_skip_when_no_alerting_configured():
|
|
alerting = SlackAlerting(
|
|
alerting=None,
|
|
alert_types=[AlertType.model_deprecation_warnings],
|
|
)
|
|
sent = await alerting.send_model_deprecation_alert(llm_router=MagicMock())
|
|
assert sent is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_skip_when_no_deprecations_found(monkeypatch):
|
|
monkeypatch.setattr(litellm, "model_cost", {})
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"],
|
|
alert_types=[AlertType.model_deprecation_warnings],
|
|
)
|
|
router = _make_router(
|
|
[
|
|
{
|
|
"model_name": "fresh",
|
|
"litellm_params": {"model": "openai/gpt-4o"},
|
|
"model_info": {"id": "x"},
|
|
}
|
|
]
|
|
)
|
|
sent = await alerting.send_model_deprecation_alert(llm_router=router)
|
|
assert sent is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_dispatch_high_severity_when_deprecated(monkeypatch):
|
|
monkeypatch.setattr(
|
|
litellm,
|
|
"model_cost",
|
|
{
|
|
"dead-model": {
|
|
"deprecation_date": "2020-01-01",
|
|
"litellm_provider": "openai",
|
|
}
|
|
},
|
|
)
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"],
|
|
alert_types=[AlertType.model_deprecation_warnings],
|
|
)
|
|
router = _make_router(
|
|
[
|
|
{
|
|
"model_name": "dead-alias",
|
|
"litellm_params": {"model": "dead-model"},
|
|
"model_info": {"id": "1"},
|
|
}
|
|
]
|
|
)
|
|
|
|
with patch.object(
|
|
alerting, "send_alert", new_callable=AsyncMock
|
|
) as mock_send_alert:
|
|
sent = await alerting.send_model_deprecation_alert(llm_router=router)
|
|
|
|
assert sent is True
|
|
mock_send_alert.assert_awaited_once()
|
|
call_kwargs = mock_send_alert.await_args.kwargs
|
|
assert call_kwargs["alert_type"] == AlertType.model_deprecation_warnings
|
|
assert call_kwargs["level"] == "High"
|
|
assert call_kwargs["alerting_metadata"]["deprecated_count"] == 1
|
|
assert call_kwargs["alerting_metadata"]["imminent_count"] == 0
|
|
assert "dead-alias" in call_kwargs["message"]
|
|
assert isinstance(
|
|
await alerting.internal_usage_cache.async_get_cache(
|
|
key=SlackAlertingCacheKeys.deprecation_alert_sent_key.value
|
|
),
|
|
float,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_alert_once_the_alert_type_and_router_arrive_after_startup(
|
|
monkeypatch,
|
|
):
|
|
"""The loop starts before config reload, so a disabled pass must not cost a day of alerts"""
|
|
monkeypatch.setattr(
|
|
litellm,
|
|
"model_cost",
|
|
{"dead-model": {"deprecation_date": "2020-01-01", "litellm_provider": "openai"}},
|
|
)
|
|
alerting = SlackAlerting(alerting=["slack"], alert_types=[AlertType.llm_exceptions])
|
|
router = _make_router(
|
|
[
|
|
{
|
|
"model_name": "dead-alias",
|
|
"litellm_params": {"model": "dead-model"},
|
|
"model_info": {"id": "1"},
|
|
}
|
|
]
|
|
)
|
|
|
|
slept: list[float] = []
|
|
|
|
async def stop_after_third_pass(seconds):
|
|
slept.append(seconds)
|
|
if alerting.alert_types == [AlertType.llm_exceptions]:
|
|
alerting.update_values(
|
|
alert_types=[AlertType.model_deprecation_warnings]
|
|
) # simulates a config reload enabling the alert
|
|
if len(slept) == 3:
|
|
raise asyncio.CancelledError
|
|
|
|
with (
|
|
patch.object(alerting, "send_alert", new_callable=AsyncMock) as mock_send_alert,
|
|
patch(
|
|
"litellm.integrations.SlackAlerting.slack_alerting.asyncio.sleep",
|
|
side_effect=stop_after_third_pass,
|
|
),
|
|
pytest.raises(asyncio.CancelledError),
|
|
):
|
|
await alerting.run_scheduled_deprecation_check(get_llm_router=lambda: router)
|
|
|
|
assert slept == [DEPRECATION_IDLE_POLL_SECONDS] * 3
|
|
mock_send_alert.assert_awaited_once()
|
|
assert "dead-alias" in mock_send_alert.await_args.kwargs["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_wait_for_the_router_instead_of_sleeping_a_full_day(monkeypatch):
|
|
"""Config load can start the loop before the router exists, which must not cost a day of alerts"""
|
|
monkeypatch.setattr(
|
|
litellm,
|
|
"model_cost",
|
|
{"dead-model": {"deprecation_date": "2020-01-01", "litellm_provider": "openai"}},
|
|
)
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"], alert_types=[AlertType.model_deprecation_warnings]
|
|
)
|
|
router = _make_router(
|
|
[
|
|
{
|
|
"model_name": "dead-alias",
|
|
"litellm_params": {"model": "dead-model"},
|
|
"model_info": {"id": "1"},
|
|
}
|
|
]
|
|
)
|
|
router_absent_passes = 100
|
|
routers = chain(repeat(None, router_absent_passes), repeat(router))
|
|
slept: list[float] = []
|
|
|
|
async def record_sleep(seconds):
|
|
slept.append(seconds)
|
|
if len(slept) > router_absent_passes:
|
|
raise asyncio.CancelledError
|
|
|
|
with (
|
|
patch.object(alerting, "send_alert", new_callable=AsyncMock) as mock_send_alert,
|
|
patch(
|
|
"litellm.integrations.SlackAlerting.slack_alerting.asyncio.sleep",
|
|
side_effect=record_sleep,
|
|
),
|
|
pytest.raises(asyncio.CancelledError),
|
|
):
|
|
await alerting.run_scheduled_deprecation_check(
|
|
get_llm_router=lambda: next(routers)
|
|
)
|
|
|
|
assert slept == [DEPRECATION_IDLE_POLL_SECONDS] * (router_absent_passes + 1)
|
|
mock_send_alert.assert_awaited_once()
|
|
assert "dead-alias" in mock_send_alert.await_args.kwargs["message"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"lock_acquired, expect_alert",
|
|
[(True, True), (None, True), (False, False)],
|
|
ids=["lock won", "no redis lock", "another pod holds the lock"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_should_alert_only_from_the_pod_holding_the_daily_lock(
|
|
monkeypatch, lock_acquired, expect_alert
|
|
):
|
|
"""Every pod runs the loop, so a fleet must not send one identical alert per replica"""
|
|
monkeypatch.setattr(
|
|
litellm,
|
|
"model_cost",
|
|
{"dead-model": {"deprecation_date": "2020-01-01", "litellm_provider": "openai"}},
|
|
)
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"], alert_types=[AlertType.model_deprecation_warnings]
|
|
)
|
|
router = _make_router(
|
|
[
|
|
{
|
|
"model_name": "dead-alias",
|
|
"litellm_params": {"model": "dead-model"},
|
|
"model_info": {"id": "1"},
|
|
}
|
|
]
|
|
)
|
|
pod_lock_manager = MagicMock()
|
|
pod_lock_manager.acquire_lock = AsyncMock(return_value=lock_acquired)
|
|
|
|
with (
|
|
patch.object(alerting, "send_alert", new_callable=AsyncMock) as mock_send_alert,
|
|
patch(
|
|
"litellm.integrations.SlackAlerting.slack_alerting.asyncio.sleep",
|
|
side_effect=asyncio.CancelledError,
|
|
),
|
|
pytest.raises(asyncio.CancelledError),
|
|
):
|
|
await alerting.run_scheduled_deprecation_check(
|
|
get_llm_router=lambda: router, pod_lock_manager=pod_lock_manager
|
|
)
|
|
|
|
assert mock_send_alert.await_count == int(expect_alert)
|
|
assert pod_lock_manager.acquire_lock.await_args.kwargs == {
|
|
"cronjob_id": SLACK_MODEL_DEPRECATION_LOCK_ID,
|
|
"ttl": DEFAULT_DEPRECATION_CHECK_INTERVAL_SECONDS,
|
|
"allow_reentrant": False,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_retry_on_the_next_poll_when_the_lock_claim_fails(monkeypatch):
|
|
"""A redis blip at claim time returns False like a held lock, and must not cost every pod a day of alerts"""
|
|
monkeypatch.setattr(litellm, "model_cost", DEAD_MODEL_COST)
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"], alert_types=[AlertType.model_deprecation_warnings]
|
|
)
|
|
router = _make_router([DEAD_ALIAS_DEPLOYMENT])
|
|
pod_lock_manager = MagicMock()
|
|
pod_lock_manager.acquire_lock = AsyncMock(side_effect=[False, True])
|
|
slept: list[float] = []
|
|
|
|
async def stop_after_second_pass(seconds):
|
|
slept.append(seconds)
|
|
if len(slept) == 2:
|
|
raise asyncio.CancelledError
|
|
|
|
with (
|
|
patch.object(alerting, "send_alert", new_callable=AsyncMock) as mock_send_alert,
|
|
patch(
|
|
"litellm.integrations.SlackAlerting.slack_alerting.asyncio.sleep",
|
|
side_effect=stop_after_second_pass,
|
|
),
|
|
pytest.raises(asyncio.CancelledError),
|
|
):
|
|
await alerting.run_scheduled_deprecation_check(
|
|
get_llm_router=lambda: router, pod_lock_manager=pod_lock_manager
|
|
)
|
|
|
|
assert slept == [DEPRECATION_IDLE_POLL_SECONDS] * 2
|
|
assert pod_lock_manager.acquire_lock.await_count == 2
|
|
mock_send_alert.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_not_claim_the_lock_when_there_is_nothing_to_report(monkeypatch):
|
|
"""An empty pass must not hold the daily lock, or a sunset added later waits out the whole window"""
|
|
monkeypatch.setattr(litellm, "model_cost", {})
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"], alert_types=[AlertType.model_deprecation_warnings]
|
|
)
|
|
router = _make_router(
|
|
[
|
|
{
|
|
"model_name": "fresh",
|
|
"litellm_params": {"model": "openai/gpt-4o"},
|
|
"model_info": {"id": "x"},
|
|
}
|
|
]
|
|
)
|
|
pod_lock_manager = MagicMock()
|
|
pod_lock_manager.acquire_lock = AsyncMock(return_value=True)
|
|
|
|
with patch.object(alerting, "send_alert", new_callable=AsyncMock) as mock_send_alert:
|
|
sent = await alerting.send_model_deprecation_alert(
|
|
llm_router=router, pod_lock_manager=pod_lock_manager
|
|
)
|
|
|
|
assert sent is False
|
|
pod_lock_manager.acquire_lock.assert_not_awaited()
|
|
mock_send_alert.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_not_alert_or_claim_the_lock_within_a_day_of_a_sent_alert(monkeypatch):
|
|
"""The shared sent stamp keeps sibling pods and restarts from re-alerting or re-asking redis for a day"""
|
|
monkeypatch.setattr(litellm, "model_cost", DEAD_MODEL_COST)
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"], alert_types=[AlertType.model_deprecation_warnings]
|
|
)
|
|
await alerting.internal_usage_cache.async_set_cache(
|
|
key=SlackAlertingCacheKeys.deprecation_alert_sent_key.value,
|
|
value=1.0,
|
|
ttl=DEFAULT_DEPRECATION_CHECK_INTERVAL_SECONDS,
|
|
)
|
|
router = _make_router([DEAD_ALIAS_DEPLOYMENT])
|
|
pod_lock_manager = MagicMock()
|
|
pod_lock_manager.acquire_lock = AsyncMock(return_value=True)
|
|
|
|
with (
|
|
patch.object(alerting, "send_alert", new_callable=AsyncMock) as mock_send_alert,
|
|
patch(
|
|
"litellm.integrations.SlackAlerting.slack_alerting.asyncio.sleep",
|
|
side_effect=asyncio.CancelledError,
|
|
),
|
|
pytest.raises(asyncio.CancelledError),
|
|
):
|
|
await alerting.run_scheduled_deprecation_check(
|
|
get_llm_router=lambda: router, pod_lock_manager=pod_lock_manager
|
|
)
|
|
|
|
pod_lock_manager.acquire_lock.assert_not_awaited()
|
|
mock_send_alert.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_back_off_a_full_day_after_a_pass_raises(monkeypatch):
|
|
"""A misconfigured webhook raises on every send, which must log once a day rather than every poll"""
|
|
monkeypatch.setattr(litellm, "model_cost", DEAD_MODEL_COST)
|
|
alerting = SlackAlerting(
|
|
alerting=["slack"], alert_types=[AlertType.model_deprecation_warnings]
|
|
)
|
|
router = _make_router([DEAD_ALIAS_DEPLOYMENT])
|
|
slept: list[float] = []
|
|
|
|
async def stop_after_second_pass(seconds):
|
|
slept.append(seconds)
|
|
if len(slept) == 2:
|
|
raise asyncio.CancelledError
|
|
|
|
with (
|
|
patch.object(
|
|
alerting,
|
|
"send_alert",
|
|
new_callable=AsyncMock,
|
|
side_effect=ValueError("Missing SLACK_WEBHOOK_URL from environment"),
|
|
) as mock_send_alert,
|
|
patch(
|
|
"litellm.integrations.SlackAlerting.slack_alerting.asyncio.sleep",
|
|
side_effect=stop_after_second_pass,
|
|
),
|
|
pytest.raises(asyncio.CancelledError),
|
|
):
|
|
await alerting.run_scheduled_deprecation_check(get_llm_router=lambda: router)
|
|
|
|
assert slept == [DEFAULT_DEPRECATION_CHECK_INTERVAL_SECONDS] * 2
|
|
assert mock_send_alert.await_count == 2
|