refactor(redis): move config sync pub/sub behind a typed store

This commit is contained in:
Devin AI 2026-10-30 21:51:27 +00:00
parent 47db6b2ead
commit 2d1d5e92fb
5 changed files with 416 additions and 291 deletions

View file

@ -1,15 +1,20 @@
import asyncio
import json
import random
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import asdict, dataclass
from itertools import chain
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias, cast # noqa: TID251 # untyped prisma/redis boundary
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.redis.stores.config_sync import (
ConfigChange,
ConfigSyncStore,
TransportConfigSyncStore,
decode_config_change,
)
from litellm.redis.transport.contract import RedisFailure, RedisSubscription, raise_redis_failure
from litellm.repositories.prisma_protocols import RowT_co, TableActions
if TYPE_CHECKING:
@ -41,7 +46,6 @@ class _ConfigSyncPubSubClient(Protocol):
def pipeline(self, transaction: bool = True) -> _PublishPipeline: ...
CONFIG_SYNC_CHANNEL: Final = "litellm_proxy.config_change"
CONFIG_SYNC_DEBOUNCE_SECONDS: Final = 1.0
CONFIG_SYNC_JITTER_MAX_SECONDS: Final = 5.0
CONFIG_SYNC_MIN_RESYNC_INTERVAL_SECONDS: Final = 10.0
@ -90,12 +94,6 @@ def coordination_redis_cache() -> "RedisCache | None":
return redis_usage_cache
def config_sync_channel(redis_cache: "RedisCache") -> str:
if redis_cache.namespace is None:
return CONFIG_SYNC_CHANNEL
return f"{redis_cache.namespace}:{CONFIG_SYNC_CHANNEL}"
def pubsub_capable_client(redis_cache: "RedisCache") -> _ConfigSyncPubSubClient:
return cast( # cast-ok: protocol view of the pub/sub-capable async redis client
_ConfigSyncPubSubClient,
@ -106,45 +104,18 @@ def pubsub_capable_client(redis_cache: "RedisCache") -> _ConfigSyncPubSubClient:
_pubsub_capable_client: Final = pubsub_capable_client
@dataclass(frozen=True, slots=True)
class _ConfigChangeMessage:
object_type: str
model_ids: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class _PubSubEnvelope:
data: str | bytes
class _WrittenModelRow(BaseModel):
model_config = ConfigDict(from_attributes=True, frozen=True)
model_id: str
_CONFIG_CHANGE_MESSAGE: Final = TypeAdapter(_ConfigChangeMessage)
_PUBSUB_ENVELOPE: Final = TypeAdapter(_PubSubEnvelope)
_MODEL_TABLE_NAME: Final = "litellm_proxymodeltable"
_TARGETED_WRITE_ACTION_NAMES: Final[frozenset[str]] = frozenset({"create", "update", "upsert"})
PublishConfigChange: TypeAlias = Callable[[str, tuple[str, ...]], Awaitable[None]]
def _config_change_message_json(object_type: str, model_ids: tuple[str, ...]) -> str:
return json.dumps(asdict(_ConfigChangeMessage(object_type=object_type, model_ids=model_ids)))
def parse_config_change(message: object) -> _ConfigChangeMessage | None:
try:
payload: Final = (
message if isinstance(message, (str, bytes)) else _PUBSUB_ENVELOPE.validate_python(message).data
)
return _CONFIG_CHANGE_MESSAGE.validate_json(payload)
except ValidationError:
return None
def written_model_ids(object_type: str, action_name: str, result: object) -> tuple[str, ...]:
if object_type != _MODEL_TABLE_NAME or action_name not in _TARGETED_WRITE_ACTION_NAMES:
return ()
@ -160,10 +131,14 @@ async def publish_config_change(
if redis_cache is None:
return
try:
client: Final = pubsub_capable_client(redis_cache)
await client.publish(config_sync_channel(redis_cache), _config_change_message_json(object_type, model_ids))
published: Final = await TransportConfigSyncStore(redis_cache.transport()).publish_change(
ConfigChange(object_type=object_type, model_ids=model_ids)
)
except Exception as e: # noqa: BLE001 # best-effort publish; writes must never fail on redis errors
verbose_proxy_logger.warning("config sync publish for %s failed: %s", object_type, e)
return
if isinstance(published, RedisFailure):
verbose_proxy_logger.warning("config sync publish for %s failed: %s", object_type, published.message)
async def publish_config_change_for_object_type(object_type: str, model_ids: tuple[str, ...] = ()) -> None:
@ -222,8 +197,17 @@ def wrap_table_actions_for_config_sync(
return cast("TableActions[RowT_co]", wrapped) # cast-ok: dynamic write-through proxy keeps the wrapped row type
def _published_model_ids(messages: tuple[object, ...]) -> tuple[str, ...]:
changes: Final = (parse_config_change(message) for message in messages)
async def _next_payload(subscription: RedisSubscription, timeout: float) -> bytes | None:
message: Final = await subscription.get_message(timeout=timeout)
if not isinstance(message, RedisFailure):
return None if message is None else message.data
if message.kind == "decode":
return b""
raise_redis_failure(message)
def _published_model_ids(payloads: tuple[bytes, ...]) -> tuple[str, ...]:
changes: Final = (decode_config_change(payload) for payload in payloads)
return tuple(dict.fromkeys(chain.from_iterable(change.model_ids for change in changes if change is not None)))
@ -237,17 +221,17 @@ class ConfigSyncSubscriber:
"_last_resync_at",
"_min_resync_interval_seconds",
"_monotonic",
"_redis_cache",
"_resync_callbacks",
"_resync_required",
"_rng",
"_sleep",
"_store",
"_task",
)
def __init__(
self,
redis_cache: "RedisCache",
store: ConfigSyncStore,
resync_callbacks: tuple[Callable[[], Awaitable[None]], ...],
apply_model_ids: Callable[[tuple[str, ...]], Awaitable[None]] | None = None,
debounce_seconds: float = CONFIG_SYNC_DEBOUNCE_SECONDS,
@ -259,7 +243,7 @@ class ConfigSyncSubscriber:
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> None:
self._redis_cache = redis_cache
self._store = store
self._resync_callbacks = resync_callbacks
self._apply_model_ids = apply_model_ids
self._debounce_seconds = debounce_seconds
@ -294,17 +278,15 @@ class ConfigSyncSubscriber:
backoff_seconds = self._backoff_initial_seconds
while True:
try:
client = pubsub_capable_client(self._redis_cache)
pubsub = client.pubsub()
subscription = await self._subscribe()
try:
await pubsub.subscribe(config_sync_channel(self._redis_cache))
if self._resync_required:
await self._debounce_and_resync(pubsub)
await self._debounce_and_resync(subscription)
self._resync_required = False
backoff_seconds = self._backoff_initial_seconds
await self._consume(pubsub)
await self._consume(subscription)
finally:
await self._close_pubsub(pubsub)
await self._close_subscription(subscription)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 # any redis failure falls through to backoff and reconnect
@ -317,20 +299,27 @@ class ConfigSyncSubscriber:
await self._sleep(backoff_seconds)
backoff_seconds = min(backoff_seconds * 2, self._backoff_max_seconds)
async def _consume(self, pubsub: ConfigSyncPubSub) -> None:
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=_POLL_TIMEOUT_SECONDS)
if message is None:
continue
await self._apply(pubsub, message)
await self._debounce_and_resync(pubsub)
async def _subscribe(self) -> RedisSubscription:
subscription: Final = await self._store.subscribe()
if isinstance(subscription, RedisFailure):
raise_redis_failure(subscription)
return subscription
async def _debounce_and_resync(self, pubsub: ConfigSyncPubSub) -> None:
async def _consume(self, subscription: RedisSubscription) -> None:
while True:
payload = await _next_payload(subscription, _POLL_TIMEOUT_SECONDS)
if payload is None:
continue
await self._apply(subscription, payload)
await self._debounce_and_resync(subscription)
async def _debounce_and_resync(self, subscription: RedisSubscription) -> None:
await self._apply_until(
pubsub, self._monotonic() + self._debounce_seconds + self._rng.uniform(0.0, self._jitter_max_seconds)
subscription,
self._monotonic() + self._debounce_seconds + self._rng.uniform(0.0, self._jitter_max_seconds),
)
await self._apply_until(pubsub, self._next_resync_at())
await self._drain_pending(pubsub)
await self._apply_until(subscription, self._next_resync_at())
await self._drain_pending(subscription)
await self._run_resync_callbacks()
self._last_resync_at = self._monotonic()
@ -346,32 +335,30 @@ class ConfigSyncSubscriber:
)
return next_resync_at
async def _apply_until(self, pubsub: ConfigSyncPubSub, deadline: float) -> None:
async def _apply_until(self, subscription: RedisSubscription, deadline: float) -> None:
while True:
remaining = deadline - self._monotonic()
if remaining <= 0:
return
message = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=min(remaining, _POLL_TIMEOUT_SECONDS)
)
if message is not None:
await self._apply(pubsub, message)
payload = await _next_payload(subscription, min(remaining, _POLL_TIMEOUT_SECONDS))
if payload is not None:
await self._apply(subscription, payload)
@staticmethod
async def _drain_pending(pubsub: ConfigSyncPubSub) -> None:
while await pubsub.get_message(ignore_subscribe_messages=True, timeout=0) is not None:
async def _drain_pending(subscription: RedisSubscription) -> None:
while await _next_payload(subscription, 0) is not None:
pass
@staticmethod
async def _ready_messages(pubsub: ConfigSyncPubSub) -> AsyncIterator[object]:
while (message := await pubsub.get_message(ignore_subscribe_messages=True, timeout=0)) is not None:
yield message
async def _ready_payloads(subscription: RedisSubscription) -> AsyncIterator[bytes]:
while (payload := await _next_payload(subscription, 0)) is not None:
yield payload
async def _apply(self, pubsub: ConfigSyncPubSub, message: object) -> None:
async def _apply(self, subscription: RedisSubscription, payload: bytes) -> None:
apply: Final = self._apply_model_ids
if apply is None:
return
ready: Final = (message, *[ready_message async for ready_message in self._ready_messages(pubsub)])
ready: Final = (payload, *[ready_payload async for ready_payload in self._ready_payloads(subscription)])
model_ids: Final = _published_model_ids(ready)
if not model_ids:
return
@ -388,8 +375,11 @@ class ConfigSyncSubscriber:
verbose_proxy_logger.warning("config sync resync callback failed: %s", e)
@staticmethod
async def _close_pubsub(pubsub: ConfigSyncPubSub) -> None:
async def _close_subscription(subscription: RedisSubscription) -> None:
try:
await pubsub.aclose()
closed: Final = await subscription.close()
except Exception as e: # noqa: BLE001 # best-effort close of a possibly-broken connection
verbose_proxy_logger.debug("config sync pubsub close failed: %s", e)
return
if isinstance(closed, RedisFailure):
verbose_proxy_logger.debug("config sync pubsub close failed: %s", closed.message)

View file

@ -841,6 +841,7 @@ from litellm.proxy.telemetry.middleware import TelemetryMiddleware
from litellm.proxy.telemetry.runtime import TelemetryRuntime
from litellm.proxy.telemetry.settings import describe_errors as describe_telemetry_errors
from litellm.proxy.telemetry.settings import load_settings as load_telemetry_settings
from litellm.redis.stores.config_sync import TransportConfigSyncStore
try:
from litellm.proxy.enterprise_billing.billing_metrics import (
@ -8779,7 +8780,7 @@ class ProxyConfig:
await self.add_deployment(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj)
subscriber: Final = ConfigSyncSubscriber(
redis_cache=redis_cache,
store=TransportConfigSyncStore(redis_cache.transport()),
resync_callbacks=(_resync_config_from_db,),
apply_model_ids=resync_model_deployments_by_ids,
)

View file

@ -0,0 +1,46 @@
import json
from dataclasses import asdict, dataclass
from typing import Final, Protocol
from pydantic import TypeAdapter, ValidationError
from litellm.redis.transport.contract import RedisFailure, RedisSubscription, RedisTransport
CONFIG_SYNC_CHANNEL: Final = "litellm_proxy.config_change"
@dataclass(frozen=True, slots=True)
class ConfigChange:
object_type: str
model_ids: tuple[str, ...] = ()
_CONFIG_CHANGE: Final = TypeAdapter(ConfigChange)
def encode_config_change(change: ConfigChange) -> str:
return json.dumps(asdict(change))
def decode_config_change(payload: str | bytes) -> ConfigChange | None:
try:
return _CONFIG_CHANGE.validate_json(payload)
except ValidationError:
return None
class ConfigSyncStore(Protocol):
async def publish_change(self, change: ConfigChange) -> int | RedisFailure: ...
async def subscribe(self) -> RedisSubscription | RedisFailure: ...
@dataclass(frozen=True, slots=True)
class TransportConfigSyncStore:
transport: RedisTransport
async def publish_change(self, change: ConfigChange) -> int | RedisFailure:
return await self.transport.publish(CONFIG_SYNC_CHANNEL, encode_config_change(change))
async def subscribe(self) -> RedisSubscription | RedisFailure:
return await self.transport.subscribe(CONFIG_SYNC_CHANNEL)

View file

@ -1,26 +1,30 @@
import asyncio
import json
import random
from typing import Callable, Coroutine, Final, Iterable, List, Optional, Tuple
from collections.abc import Callable, Coroutine, Iterable
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import fakeredis
import pytest
from pydantic import BaseModel, ConfigDict
from redis.asyncio import Redis
import litellm
from litellm.caching.redis_cache import RedisCache
from litellm.proxy.common_utils.config_sync_pubsub import (
CONFIG_SYNC_CHANNEL,
_CONFIG_SYNCED_TABLE_NAMES,
_RESYNC_APPLIED_CONFIG_PARAM_NAMES,
_WRITE_ACTION_NAMES,
CONFIG_SYNC_JITTER_MAX_SECONDS,
CONFIG_SYNC_MIN_RESYNC_INTERVAL_SECONDS,
ConfigSyncSubscriber,
_CONFIG_SYNCED_TABLE_NAMES,
_PublishOnWriteActions,
_RESYNC_APPLIED_CONFIG_PARAM_NAMES,
_WRITE_ACTION_NAMES,
publish_config_change,
wrap_table_actions_for_config_sync,
)
from litellm.redis.stores.config_sync import CONFIG_SYNC_CHANNEL, ConfigChange, TransportConfigSyncStore
from litellm.redis.transport.contract import PubSubMessage, RedisFailure
from tests._support.fake_redis import fake_redis_cache
_EXPECTED_WRITE_ACTION_NAMES = (
"create",
@ -65,76 +69,49 @@ _EXPECTED_RESYNC_APPLIED_CONFIG_PARAM_NAMES = frozenset(
_STARTUP_ONLY_CONFIG_PARAM_NAMES = ("environment_variables",)
class _RecordingRedisClient(Redis):
def __init__(self) -> None:
self.published: List[Tuple[str, str]] = []
async def publish(self, channel: str, message: str) -> int:
self.published.append((channel, message))
return 1
CHANNEL_BYTES: Final = CONFIG_SYNC_CHANNEL.encode()
class _FailingPublishRedisClient(Redis):
def __init__(self) -> None:
pass
async def publish(self, channel: str, message: str) -> int:
raise ConnectionError("redis down")
class _ScriptedPubSubClient:
"""Pub/sub-capable client that is not a redis.asyncio.Redis.
Mirrors what RedisCache.init_pubsub_client returns for a cluster backend:
a node-level client exposing publish/pubsub without being an instance of
the standalone Redis class.
"""
def __init__(self, pubsubs: Iterable["_QueuePubSub"]) -> None:
self._scripted_pubsubs = iter(pubsubs)
self.published: List[Tuple[str, str]] = []
async def publish(self, channel: str, message: str) -> int:
self.published.append((channel, message))
return 1
def pubsub(self) -> "_QueuePubSub":
return next(self._scripted_pubsubs)
def _wire(message: object) -> object:
return PubSubMessage(channel=CHANNEL_BYTES, data=message.encode()) if isinstance(message, str) else message
class _QueuePubSub:
def __init__(self, initial_messages: Iterable[object] = ()) -> None:
self.queue: "asyncio.Queue[object]" = asyncio.Queue()
self.queue: asyncio.Queue[object] = asyncio.Queue()
for message in initial_messages:
self.queue.put_nowait(message)
self.subscribed_channels: List[str] = []
self.subscribed_channels: list[str] = []
self.closed = False
async def subscribe(self, *channels: str) -> None:
self.subscribed_channels.extend(channels)
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[object]:
async def get_message(self, *, timeout: float) -> object:
if timeout == 0:
try:
return self.queue.get_nowait()
return _wire(self.queue.get_nowait())
except asyncio.QueueEmpty:
return None
try:
return await asyncio.wait_for(self.queue.get(), timeout)
return _wire(await asyncio.wait_for(self.queue.get(), timeout))
except asyncio.TimeoutError:
return None
async def aclose(self) -> None:
async def close(self) -> object:
self.closed = True
return None
class _BrokenPubSub(_QueuePubSub):
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[object]:
raise ConnectionError("connection lost")
async def get_message(self, *, timeout: float) -> object:
return RedisFailure(kind="transport", message="connection lost")
class _CloseFailingBrokenPubSub(_BrokenPubSub):
async def aclose(self) -> None:
async def close(self) -> object:
return RedisFailure(kind="transport", message="close failed")
class _CloseRaisingBrokenPubSub(_BrokenPubSub):
async def close(self) -> object:
raise ConnectionError("close failed")
@ -143,11 +120,11 @@ class _EmptyPollsThenMessagePubSub(_QueuePubSub):
super().__init__(initial_messages=initial_messages)
self.remaining_empty_polls = empty_polls
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[object]:
async def get_message(self, *, timeout: float) -> object:
if timeout != 0 and self.remaining_empty_polls > 0:
self.remaining_empty_polls -= 1
return None
return await super().get_message(ignore_subscribe_messages=ignore_subscribe_messages, timeout=timeout)
return await super().get_message(timeout=timeout)
class _FakeClock:
@ -166,18 +143,18 @@ class _VirtualTimePubSub(_QueuePubSub):
and targeted apply ran in subscriber time.
"""
def __init__(self, clock: _FakeClock, deliveries: Iterable[Tuple[float, object]] = ()) -> None:
def __init__(self, clock: _FakeClock, deliveries: Iterable[tuple[float, object]] = ()) -> None:
super().__init__()
self._clock = clock
self.pending: List[Tuple[float, object]] = sorted(deliveries, key=lambda delivery: delivery[0])
self.pending: list[tuple[float, object]] = sorted(deliveries, key=lambda delivery: delivery[0])
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[object]:
async def get_message(self, *, timeout: float) -> object:
await asyncio.sleep(0)
due_by = self._clock.now + timeout
if self.pending and self.pending[0][0] <= due_by:
delivered_at, message = self.pending.pop(0)
self._clock.now = max(self._clock.now, delivered_at)
return message
return _wire(message)
self._clock.now = due_by
return None
@ -186,36 +163,70 @@ def _change(*model_ids: str) -> str:
return json.dumps({"object_type": "litellm_proxymodeltable", "model_ids": list(model_ids)})
def _redis_delivery(message: str) -> dict:
return {"type": "message", "pattern": None, "channel": b"litellm_proxy.config_change", "data": message.encode()}
class _ScriptedStore:
def __init__(self, subscriptions: Iterable[_QueuePubSub]) -> None:
self._subscriptions = iter(subscriptions)
async def publish_change(self, change: ConfigChange) -> int:
return 0
async def subscribe(self) -> _QueuePubSub:
subscription = next(self._subscriptions)
subscription.subscribed_channels.append(CONFIG_SYNC_CHANNEL)
return subscription
class _ScriptedPubSubRedisClient(Redis):
def __init__(self, pubsubs: Iterable[_QueuePubSub]) -> None:
self._scripted_pubsubs = iter(pubsubs)
class _NodeLevelPubSubClient:
"""Mirrors what RedisCache.init_pubsub_client returns for a cluster backend:
a node-level client exposing publish/pubsub without being a redis.asyncio.Redis."""
def pubsub(self) -> _QueuePubSub:
return next(self._scripted_pubsubs)
def __init__(self, server: fakeredis.FakeServer) -> None:
self._client = fakeredis.FakeAsyncRedis(server=server)
async def publish(self, channel: str, message: str) -> int:
return await self._client.publish(channel, message)
def pubsub(self) -> object:
return self._client.pubsub()
class _FakeRedisCache:
def __init__(self, client: object, namespace: Optional[str] = None) -> None:
self._client = client
self.namespace = namespace
class _NodeLevelPubSubRedisCache(RedisCache):
def init_pubsub_client(self) -> object:
return self._client
return self._node_client
@classmethod
def on(cls, server: fakeredis.FakeServer) -> "_NodeLevelPubSubRedisCache":
cache = cls(host="127.0.0.1", port=1, socket_timeout=0.1)
cache._node_client = _NodeLevelPubSubClient(server)
return cache
class _ExplodingRedisCache:
namespace: Optional[str] = None
class _PubSubUnreachableRedisCache(RedisCache):
def init_pubsub_client(self) -> object:
raise ConnectionError("cannot connect")
async def _listen(server: fakeredis.FakeServer, channel: str = CONFIG_SYNC_CHANNEL) -> object:
pubsub = fakeredis.FakeAsyncRedis(server=server).pubsub()
await pubsub.subscribe(channel)
return pubsub
async def _subscribed(server: fakeredis.FakeServer, channel: str) -> fakeredis.FakeAsyncRedis:
raw = fakeredis.FakeAsyncRedis(server=server)
while (await raw.pubsub_numsub(channel))[0][1] == 0:
await asyncio.sleep(0.01)
return raw
async def _heard(pubsub: object) -> list[tuple[str, str]]:
received = [await pubsub.get_message(ignore_subscribe_messages=True, timeout=0.05) for _ in range(4)]
await pubsub.aclose()
return [(message["channel"].decode(), message["data"].decode()) for message in received if message is not None]
def _recording_callback(
events: List[str], name: str, fired: asyncio.Event
events: list[str], name: str, fired: asyncio.Event
) -> Callable[[], Coroutine[None, None, None]]:
async def callback() -> None:
events.append(name)
@ -228,66 +239,77 @@ async def test_publish_noops_when_redis_cache_is_none() -> None:
await publish_config_change(redis_cache=None, object_type="litellm_proxymodeltable")
async def test_publish_sends_object_type_json_on_channel() -> None:
client = _RecordingRedisClient()
cache = _FakeRedisCache(client)
async def test_publish_sends_the_old_wire_bytes_on_the_channel() -> None:
server = fakeredis.FakeServer()
listener = await _listen(server)
await publish_config_change(redis_cache=cache, object_type="litellm_proxymodeltable")
await publish_config_change(redis_cache=fake_redis_cache(server), object_type="litellm_proxymodeltable")
assert len(client.published) == 1
channel, message = client.published[0]
assert channel == "litellm_proxy.config_change"
assert json.loads(message) == {"object_type": "litellm_proxymodeltable", "model_ids": []}
assert await _heard(listener) == [
(CONFIG_SYNC_CHANNEL, '{"object_type": "litellm_proxymodeltable", "model_ids": []}')
]
async def test_publish_carries_the_written_model_ids() -> None:
client = _RecordingRedisClient()
cache = _FakeRedisCache(client)
server = fakeredis.FakeServer()
listener = await _listen(server)
await publish_config_change(redis_cache=cache, object_type="litellm_proxymodeltable", model_ids=("m-1", "m-2"))
await publish_config_change(
redis_cache=fake_redis_cache(server), object_type="litellm_proxymodeltable", model_ids=("m-1", "m-2")
)
assert json.loads(client.published[0][1]) == {"object_type": "litellm_proxymodeltable", "model_ids": ["m-1", "m-2"]}
assert await _heard(listener) == [
(CONFIG_SYNC_CHANNEL, '{"object_type": "litellm_proxymodeltable", "model_ids": ["m-1", "m-2"]}')
]
async def test_publish_uses_namespaced_channel() -> None:
client = _RecordingRedisClient()
cache = _FakeRedisCache(client, namespace="prod-eu")
server = fakeredis.FakeServer()
listener = await _listen(server, "prod-eu:litellm_proxy.config_change")
await publish_config_change(redis_cache=cache, object_type="litellm_credentialstable")
await publish_config_change(
redis_cache=fake_redis_cache(server, namespace="prod-eu"), object_type="litellm_credentialstable"
)
assert client.published[0][0] == "prod-eu:litellm_proxy.config_change"
assert [channel for channel, _ in await _heard(listener)] == ["prod-eu:litellm_proxy.config_change"]
async def test_publish_swallows_redis_publish_errors() -> None:
cache = _FakeRedisCache(_FailingPublishRedisClient())
server = fakeredis.FakeServer()
cache = fake_redis_cache(server)
server.connected = False
await publish_config_change(redis_cache=cache, object_type="litellm_proxymodeltable")
async def test_publish_swallows_client_init_errors() -> None:
await publish_config_change(redis_cache=_ExplodingRedisCache(), object_type="litellm_proxymodeltable")
cache = _PubSubUnreachableRedisCache(host="127.0.0.1", port=1, socket_timeout=0.1)
await publish_config_change(redis_cache=cache, object_type="litellm_proxymodeltable")
async def test_publish_reaches_cluster_derived_pubsub_clients() -> None:
"""LIT-8543: a cluster-backed cache returns a node-level client from
init_pubsub_client; publishes must go out on it instead of being skipped."""
client = _ScriptedPubSubClient(pubsubs=[])
cache = _FakeRedisCache(client)
server = fakeredis.FakeServer()
listener = await _listen(server)
await publish_config_change(redis_cache=cache, object_type="litellm_proxymodeltable")
await publish_config_change(
redis_cache=_NodeLevelPubSubRedisCache.on(server), object_type="litellm_proxymodeltable"
)
assert client.published == [
assert await _heard(listener) == [
(CONFIG_SYNC_CHANNEL, json.dumps({"object_type": "litellm_proxymodeltable", "model_ids": []}))
]
async def test_subscriber_runs_injected_callbacks_in_order_on_message() -> None:
pubsub = _QueuePubSub()
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
events: List[str] = []
store = _ScriptedStore([pubsub])
events: list[str] = []
fired = asyncio.Event()
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
store=store,
resync_callbacks=(
_recording_callback(events, "add_deployment", asyncio.Event()),
_recording_callback(events, "get_credentials", fired),
@ -309,11 +331,11 @@ async def test_subscriber_runs_injected_callbacks_in_order_on_message() -> None:
async def test_burst_within_debounce_window_coalesces_into_one_resync() -> None:
burst = [json.dumps({"object_type": "litellm_proxymodeltable"}) for _ in range(5)]
pubsub = _QueuePubSub(initial_messages=burst)
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
resyncs: List[str] = []
store = _ScriptedStore([pubsub])
resyncs: list[str] = []
fired = asyncio.Event()
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
store=store,
resync_callbacks=(_recording_callback(resyncs, "resync", fired),),
debounce_seconds=0.05,
jitter_max_seconds=0.0,
@ -328,25 +350,38 @@ async def test_burst_within_debounce_window_coalesces_into_one_resync() -> None:
assert pubsub.queue.empty()
async def test_subscriber_subscribes_on_namespaced_channel_and_resyncs() -> None:
pubsub = _QueuePubSub()
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]), namespace="prod-eu")
resyncs: List[str] = []
async def _resyncs_after_an_old_pod_publish(cache: RedisCache, server: fakeredis.FakeServer, channel: str) -> object:
resyncs: list[str] = []
applied: list[tuple[str, ...]] = []
fired = asyncio.Event()
async def apply(model_ids: tuple[str, ...]) -> None:
applied.append(model_ids)
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
store=TransportConfigSyncStore(cache.transport()),
resync_callbacks=(_recording_callback(resyncs, "resync", fired),),
apply_model_ids=apply,
debounce_seconds=0.01,
jitter_max_seconds=0.0,
)
subscriber.start()
pubsub.queue.put_nowait(json.dumps({"object_type": "litellm_proxymodeltable"}))
await asyncio.wait_for(fired.wait(), timeout=5)
await subscriber.stop()
try:
raw = await asyncio.wait_for(_subscribed(server, channel), timeout=5)
await raw.publish(channel, '{"object_type": "litellm_proxymodeltable", "model_ids": ["m-1"]}')
await asyncio.wait_for(fired.wait(), timeout=5)
finally:
await subscriber.stop()
return applied, resyncs
assert pubsub.subscribed_channels == ["prod-eu:litellm_proxy.config_change"]
assert resyncs == ["resync"]
async def test_subscriber_applies_an_old_pod_publish_on_the_namespaced_channel() -> None:
server = fakeredis.FakeServer()
cache = fake_redis_cache(server, namespace="prod-eu")
outcome = await _resyncs_after_an_old_pod_publish(cache, server, "prod-eu:litellm_proxy.config_change")
assert outcome == ([("m-1",)], ["resync"])
class _MaxJitterRandom(random.Random):
@ -357,28 +392,28 @@ class _MaxJitterRandom(random.Random):
def _virtual_time_subscriber(
pubsub: _VirtualTimePubSub,
clock: _FakeClock,
events: List[str],
events: list[str],
resynced: asyncio.Event,
*,
debounce_seconds: float = 0.0,
jitter_max_seconds: float = 0.0,
min_resync_interval_seconds: float = 10.0,
rng: Optional[random.Random] = None,
rng: random.Random | None = None,
expected_resyncs: int = 1,
apply_error: Optional[Exception] = None,
apply_error: Exception | None = None,
) -> ConfigSyncSubscriber:
async def resync() -> None:
events.append(f"resync@{clock.now}")
if sum(event.startswith("resync@") for event in events) >= expected_resyncs:
resynced.set()
async def apply(model_ids: Tuple[str, ...]) -> None:
async def apply(model_ids: tuple[str, ...]) -> None:
events.append(f"apply:{','.join(model_ids)}@{clock.now}")
if apply_error is not None:
raise apply_error
return ConfigSyncSubscriber(
redis_cache=_FakeRedisCache(_ScriptedPubSubRedisClient([pubsub])),
store=_ScriptedStore([pubsub]),
resync_callbacks=(resync,),
apply_model_ids=apply,
debounce_seconds=debounce_seconds,
@ -400,7 +435,7 @@ async def _run_until_resynced(subscriber: ConfigSyncSubscriber, resynced: asynci
async def test_debounce_waits_out_the_jitter_from_injected_rng() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, _change())])
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(
pubsub, clock, events, resynced, debounce_seconds=1.0, jitter_max_seconds=4.0, rng=_MaxJitterRandom()
@ -414,7 +449,7 @@ async def test_debounce_waits_out_the_jitter_from_injected_rng() -> None:
async def test_published_model_ids_apply_on_receipt_while_the_full_resync_still_debounces() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, _change("m-1"))])
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(
pubsub, clock, events, resynced, debounce_seconds=1.0, jitter_max_seconds=4.0, rng=_MaxJitterRandom()
@ -430,7 +465,7 @@ async def test_model_ids_published_inside_the_throttle_wait_apply_without_waitin
pubsub = _VirtualTimePubSub(
clock, deliveries=[(1000.0, _change()), (1004.0, _change("m-2")), (1006.0, _change("m-3", "m-4"))]
)
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
@ -444,7 +479,7 @@ async def test_model_ids_already_waiting_apply_together_in_one_call() -> None:
pubsub = _VirtualTimePubSub(
clock, deliveries=[(1000.0, _change("m-1")), (1000.0, _change("m-2", "m-1")), (1000.0, _change("m-3"))]
)
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced)
@ -455,8 +490,10 @@ async def test_model_ids_already_waiting_apply_together_in_one_call() -> None:
async def test_redis_shaped_message_applies_its_model_ids() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, _redis_delivery(_change("m-1")))])
events: List[str] = []
pubsub = _VirtualTimePubSub(
clock, deliveries=[(1000.0, PubSubMessage(channel=CHANNEL_BYTES, data=_change("m-1").encode()))]
)
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced)
@ -473,11 +510,11 @@ async def test_messages_without_usable_model_ids_only_schedule_the_full_resync()
(1000.0, "change"),
(1004.0, json.dumps({"object_type": "litellm_proxymodeltable"})),
(1005.0, json.dumps({"object_type": "litellm_proxymodeltable", "model_ids": "m-1"})),
(1006.0, {"type": "message", "pattern": None, "channel": b"c", "data": 7}),
(1006.0, RedisFailure(kind="decode", message="pub/sub reply is not bytes")),
(1007.0, None),
],
)
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
@ -489,7 +526,7 @@ async def test_messages_without_usable_model_ids_only_schedule_the_full_resync()
async def test_failing_targeted_apply_still_runs_the_full_resync() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, _change("m-1"))])
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, apply_error=RuntimeError("db down"))
@ -501,10 +538,10 @@ async def test_failing_targeted_apply_still_runs_the_full_resync() -> None:
async def test_subscriber_without_an_apply_callback_only_resyncs() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, _change("m-1"))])
resyncs: List[str] = []
resyncs: list[str] = []
fired = asyncio.Event()
subscriber = ConfigSyncSubscriber(
redis_cache=_FakeRedisCache(_ScriptedPubSubRedisClient([pubsub])),
store=_ScriptedStore([pubsub]),
resync_callbacks=(_recording_callback(resyncs, "resync", fired),),
debounce_seconds=0.0,
jitter_max_seconds=0.0,
@ -517,19 +554,20 @@ async def test_subscriber_without_an_apply_callback_only_resyncs() -> None:
async def test_peer_holds_every_published_row_before_its_next_full_resync() -> None:
publisher = _RecordingRedisClient()
cache = _FakeRedisCache(publisher)
server = fakeredis.FakeServer()
listener = await _listen(server)
cache = fake_redis_cache(server)
await publish_config_change(redis_cache=cache, object_type="litellm_proxymodeltable", model_ids=("m-order-1",))
await publish_config_change(redis_cache=cache, object_type="litellm_proxymodeltable", model_ids=("m-order-2",))
on_the_wire = [_redis_delivery(message) for _, message in publisher.published]
on_the_wire = [message for _, message in await _heard(listener)]
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, on_the_wire[0]), (1007.0, on_the_wire[1])])
db_rows_written_at = {"m-order-1": 1000.0, "m-order-2": 1007.0}
registry: set = set()
snapshots: List[Tuple[str, float, frozenset]] = []
snapshots: list[tuple[str, float, frozenset]] = []
resynced = asyncio.Event()
async def apply(model_ids: Tuple[str, ...]) -> None:
async def apply(model_ids: tuple[str, ...]) -> None:
registry.update(model_ids)
snapshots.append(("apply", clock.now, frozenset(registry)))
@ -540,7 +578,7 @@ async def test_peer_holds_every_published_row_before_its_next_full_resync() -> N
resynced.set()
subscriber = ConfigSyncSubscriber(
redis_cache=_FakeRedisCache(_ScriptedPubSubRedisClient([pubsub])),
store=_ScriptedStore([pubsub]),
resync_callbacks=(resync,),
apply_model_ids=apply,
debounce_seconds=1.0,
@ -571,7 +609,7 @@ def test_default_min_resync_interval_caps_reload_rate() -> None:
async def test_resync_arriving_inside_min_interval_waits_out_the_remainder() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, "change"), (1004.0, "change")])
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
@ -583,7 +621,7 @@ async def test_resync_arriving_inside_min_interval_waits_out_the_remainder() ->
async def test_resync_after_min_interval_elapsed_is_not_throttled() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, "change"), (1030.0, "change")])
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
@ -597,7 +635,7 @@ async def test_writes_during_the_throttle_wait_collapse_into_the_next_resync() -
pubsub = _VirtualTimePubSub(
clock, deliveries=[(1000.0, "change")] + [(1004.0 + offset, "change") for offset in range(5)]
)
events: List[str] = []
events: list[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
@ -609,11 +647,11 @@ async def test_writes_during_the_throttle_wait_collapse_into_the_next_resync() -
async def test_polls_without_messages_do_not_trigger_resyncs() -> None:
pubsub = _EmptyPollsThenMessagePubSub(empty_polls=3, initial_messages=["change"])
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
resyncs: List[str] = []
store = _ScriptedStore([pubsub])
resyncs: list[str] = []
fired = asyncio.Event()
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
store=store,
resync_callbacks=(_recording_callback(resyncs, "resync", fired),),
debounce_seconds=0.01,
jitter_max_seconds=0.0,
@ -631,11 +669,11 @@ async def test_polls_without_messages_do_not_trigger_resyncs() -> None:
async def test_failing_pubsub_close_still_reconnects() -> None:
broken = _CloseFailingBrokenPubSub()
healthy = _QueuePubSub(initial_messages=["change"])
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([broken, healthy]))
resyncs: List[str] = []
store = _ScriptedStore([broken, healthy])
resyncs: list[str] = []
fired = asyncio.Event()
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
store=store,
resync_callbacks=(_recording_callback(resyncs, "resync", fired),),
debounce_seconds=0.01,
jitter_max_seconds=0.0,
@ -653,8 +691,8 @@ async def test_failing_pubsub_close_still_reconnects() -> None:
async def test_second_start_does_not_open_a_second_subscription() -> None:
pubsub = _QueuePubSub()
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
subscriber = ConfigSyncSubscriber(redis_cache=cache, resync_callbacks=(), debounce_seconds=0.01)
store = _ScriptedStore([pubsub])
subscriber = ConfigSyncSubscriber(store=store, resync_callbacks=(), debounce_seconds=0.01)
subscriber.start()
task = subscriber._task
@ -670,11 +708,11 @@ async def test_second_start_does_not_open_a_second_subscription() -> None:
async def test_redis_error_leads_to_backoff_and_resubscribe() -> None:
broken = _BrokenPubSub()
healthy = _QueuePubSub(initial_messages=[json.dumps({"object_type": "litellm_credentialstable"})])
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([broken, healthy]))
resyncs: List[str] = []
store = _ScriptedStore([broken, healthy])
resyncs: list[str] = []
fired = asyncio.Event()
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
store=store,
resync_callbacks=(_recording_callback(resyncs, "resync", fired),),
debounce_seconds=0.01,
jitter_max_seconds=0.0,
@ -699,11 +737,11 @@ async def test_redis_error_leads_to_backoff_and_resubscribe() -> None:
async def test_reconnect_recovers_missed_config_without_another_message(broken_type: type[_BrokenPubSub]) -> None:
broken: Final = broken_type()
healthy: Final = _QueuePubSub()
cache: Final = _FakeRedisCache(_ScriptedPubSubRedisClient((broken, healthy)))
store: Final = _ScriptedStore((broken, healthy))
recovered: Final = asyncio.Event()
reload_config: Final = AsyncMock(side_effect=recovered.set)
subscriber: Final = ConfigSyncSubscriber(
redis_cache=cache,
store=store,
resync_callbacks=(reload_config,),
debounce_seconds=0.0,
jitter_max_seconds=0.0,
@ -723,15 +761,15 @@ async def test_reconnect_recovers_missed_config_without_another_message(broken_t
async def test_failing_resync_callback_does_not_kill_subscriber() -> None:
pubsub = _QueuePubSub()
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
resyncs: List[str] = []
store = _ScriptedStore([pubsub])
resyncs: list[str] = []
fired = asyncio.Event()
async def failing_callback() -> None:
raise RuntimeError("resync exploded")
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
store=store,
resync_callbacks=(failing_callback, _recording_callback(resyncs, "resync", fired)),
debounce_seconds=0.01,
jitter_max_seconds=0.0,
@ -751,8 +789,8 @@ async def test_failing_resync_callback_does_not_kill_subscriber() -> None:
async def test_stop_cancels_subscriber_cleanly() -> None:
pubsub = _QueuePubSub()
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
subscriber = ConfigSyncSubscriber(redis_cache=cache, resync_callbacks=(), debounce_seconds=0.01)
store = _ScriptedStore([pubsub])
subscriber = ConfigSyncSubscriber(store=store, resync_callbacks=(), debounce_seconds=0.01)
subscriber.start()
await asyncio.sleep(0.05)
@ -767,8 +805,8 @@ async def test_stop_cancels_subscriber_cleanly() -> None:
async def test_stop_before_start_is_a_noop() -> None:
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([]))
subscriber = ConfigSyncSubscriber(redis_cache=cache, resync_callbacks=())
store = _ScriptedStore([])
subscriber = ConfigSyncSubscriber(store=store, resync_callbacks=())
await subscriber.stop()
@ -776,27 +814,17 @@ async def test_stop_before_start_is_a_noop() -> None:
async def test_subscriber_subscribes_on_cluster_derived_pubsub_client() -> None:
"""LIT-8543: the subscriber used to disable itself on cluster caches; now it
subscribes on the node-level client init_pubsub_client returns."""
pubsub = _QueuePubSub(initial_messages=[json.dumps({"object_type": "litellm_proxymodeltable"})])
cache = _FakeRedisCache(_ScriptedPubSubClient(pubsubs=[pubsub]))
resyncs: List[str] = []
fired = asyncio.Event()
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
resync_callbacks=(_recording_callback(resyncs, "resync", fired),),
debounce_seconds=0.01,
jitter_max_seconds=0.0,
server = fakeredis.FakeServer()
outcome = await _resyncs_after_an_old_pod_publish(
_NodeLevelPubSubRedisCache.on(server), server, CONFIG_SYNC_CHANNEL
)
subscriber.start()
await asyncio.wait_for(fired.wait(), timeout=5)
await subscriber.stop()
assert pubsub.subscribed_channels == [CONFIG_SYNC_CHANNEL]
assert resyncs == ["resync"]
assert outcome == ([("m-1",)], ["resync"])
class _FakeTableActions:
def __init__(self, calls: List[Tuple[str, str]]) -> None:
def __init__(self, calls: list[tuple[str, str]]) -> None:
self._calls = calls
async def create(self, **kwargs: object) -> object:
@ -809,7 +837,7 @@ class _FakeTableActions:
class _AllWritesTableActions:
def __init__(self, calls: List[str]) -> None:
def __init__(self, calls: list[str]) -> None:
self._calls = calls
def __getattr__(self, name: str) -> Callable[..., Coroutine[None, None, str]]:
@ -820,17 +848,17 @@ class _AllWritesTableActions:
return action
def _recording_publish(calls: List[Tuple[str, str]]) -> Callable[[str, Tuple[str, ...]], Coroutine[None, None, None]]:
async def publish(object_type: str, model_ids: Tuple[str, ...]) -> None:
def _recording_publish(calls: list[tuple[str, str]]) -> Callable[[str, tuple[str, ...]], Coroutine[None, None, None]]:
async def publish(object_type: str, model_ids: tuple[str, ...]) -> None:
calls.append(("publish", object_type))
return publish
def _recording_publish_with_ids(
calls: List[Tuple[str, Tuple[str, ...]]],
) -> Callable[[str, Tuple[str, ...]], Coroutine[None, None, None]]:
async def publish(object_type: str, model_ids: Tuple[str, ...]) -> None:
calls: list[tuple[str, tuple[str, ...]]],
) -> Callable[[str, tuple[str, ...]], Coroutine[None, None, None]]:
async def publish(object_type: str, model_ids: tuple[str, ...]) -> None:
calls.append((object_type, model_ids))
return publish
@ -862,7 +890,7 @@ def test_wrapper_passes_through_unsynced_tables() -> None:
async def test_wrapper_publishes_table_name_after_write() -> None:
calls: List[Tuple[str, str]] = []
calls: list[tuple[str, str]] = []
wrapped = wrap_table_actions_for_config_sync(
actions=_FakeTableActions(calls),
table_name="litellm_proxymodeltable",
@ -876,7 +904,7 @@ async def test_wrapper_publishes_table_name_after_write() -> None:
async def test_wrapper_does_not_publish_on_reads() -> None:
calls: List[Tuple[str, str]] = []
calls: list[tuple[str, str]] = []
wrapped = wrap_table_actions_for_config_sync(
actions=_FakeTableActions(calls),
table_name="litellm_proxymodeltable",
@ -907,8 +935,8 @@ def test_tool_telemetry_table_writes_pass_through_unwrapped() -> None:
@pytest.mark.parametrize("action_name", _EXPECTED_WRITE_ACTION_NAMES)
async def test_wrapper_publishes_for_every_write_action(action_name: str) -> None:
write_calls: List[str] = []
publish_calls: List[Tuple[str, str]] = []
write_calls: list[str] = []
publish_calls: list[tuple[str, str]] = []
wrapped = wrap_table_actions_for_config_sync(
actions=_AllWritesTableActions(write_calls),
table_name="litellm_guardrailstable",
@ -935,9 +963,9 @@ async def test_wrapper_publishes_for_every_write_action(action_name: str) -> Non
],
)
async def test_wrapper_publishes_the_written_model_id_for_row_writes(
action_name: str, expected_ids: Tuple[str, ...]
action_name: str, expected_ids: tuple[str, ...]
) -> None:
publish_calls: List[Tuple[str, Tuple[str, ...]]] = []
publish_calls: list[tuple[str, tuple[str, ...]]] = []
wrapped = wrap_table_actions_for_config_sync(
actions=_ModelRowTableActions(),
table_name="litellm_proxymodeltable",
@ -950,7 +978,7 @@ async def test_wrapper_publishes_the_written_model_id_for_row_writes(
async def test_wrapper_publishes_no_model_ids_for_other_tables() -> None:
publish_calls: List[Tuple[str, Tuple[str, ...]]] = []
publish_calls: list[tuple[str, tuple[str, ...]]] = []
wrapped = wrap_table_actions_for_config_sync(
actions=_ModelRowTableActions(),
table_name="litellm_guardrailstable",
@ -967,7 +995,8 @@ async def test_model_repository_write_publishes_via_live_coordination_cache() ->
from litellm.proxy.proxy_server import _set_redis_usage_cache
from litellm.repositories.model_repository import ModelRepository
client = _RecordingRedisClient()
server = fakeredis.FakeServer()
listener = await _listen(server)
prisma_client = MagicMock()
prisma_client.db.litellm_proxymodeltable.update = AsyncMock(return_value={"model_id": "m-1"})
repository = ModelRepository(prisma_client)
@ -975,7 +1004,7 @@ async def test_model_repository_write_publishes_via_live_coordination_cache() ->
assert isinstance(table, _PublishOnWriteActions)
previous_cache = proxy_server.redis_usage_cache
_set_redis_usage_cache(_FakeRedisCache(client))
_set_redis_usage_cache(fake_redis_cache(server))
try:
await table.update(where={"model_id": "m-1"}, data={"model_name": "gpt-5.2"})
finally:
@ -984,8 +1013,9 @@ async def test_model_repository_write_publishes_via_live_coordination_cache() ->
prisma_client.db.litellm_proxymodeltable.update.assert_awaited_once_with(
where={"model_id": "m-1"}, data={"model_name": "gpt-5.2"}
)
assert len(client.published) == 1
channel, message = client.published[0]
published = await _heard(listener)
assert len(published) == 1
channel, message = published[0]
assert channel == CONFIG_SYNC_CHANNEL
assert json.loads(message) == {"object_type": "litellm_proxymodeltable", "model_ids": ["m-1"]}
@ -995,14 +1025,15 @@ async def test_ui_settings_write_publishes_via_live_coordination_cache() -> None
from litellm.proxy.proxy_server import _set_redis_usage_cache
from litellm.repositories.table_repositories import UISettingsRepository
client = _RecordingRedisClient()
server = fakeredis.FakeServer()
listener = await _listen(server)
prisma_client = MagicMock()
prisma_client.db.litellm_uisettings.upsert = AsyncMock(return_value={"id": "ui_settings"})
table = UISettingsRepository(prisma_client).table
assert isinstance(table, _PublishOnWriteActions)
previous_cache = proxy_server.redis_usage_cache
_set_redis_usage_cache(_FakeRedisCache(client))
_set_redis_usage_cache(fake_redis_cache(server))
try:
await table.upsert(
where={"id": "ui_settings"},
@ -1011,25 +1042,27 @@ async def test_ui_settings_write_publishes_via_live_coordination_cache() -> None
finally:
_set_redis_usage_cache(previous_cache)
assert len(client.published) == 1
channel, message = client.published[0]
published = await _heard(listener)
assert len(published) == 1
channel, message = published[0]
assert channel == CONFIG_SYNC_CHANNEL
assert json.loads(message) == {"object_type": "litellm_uisettings", "model_ids": []}
async def _publish_calls_for_invalidated_param(param_name: str) -> List[Tuple[str, str]]:
async def _publish_calls_for_invalidated_param(param_name: str) -> list[tuple[str, str]]:
from litellm.proxy import proxy_server
from litellm.proxy.proxy_server import _set_redis_usage_cache
from litellm.proxy.utils import invalidate_config_param
client = _RecordingRedisClient()
server = fakeredis.FakeServer()
listener = await _listen(server)
previous_cache = proxy_server.redis_usage_cache
_set_redis_usage_cache(_FakeRedisCache(client))
_set_redis_usage_cache(fake_redis_cache(server))
try:
await invalidate_config_param(param_name)
finally:
_set_redis_usage_cache(previous_cache)
return client.published
return await _heard(listener)
@pytest.mark.parametrize("param_name", sorted(_EXPECTED_RESYNC_APPLIED_CONFIG_PARAM_NAMES))
@ -1058,15 +1091,16 @@ async def test_evict_config_param_does_not_publish() -> None:
from litellm.proxy.proxy_server import _set_redis_usage_cache
from litellm.proxy.utils import evict_config_param
client = _RecordingRedisClient()
server = fakeredis.FakeServer()
listener = await _listen(server)
previous_cache = proxy_server.redis_usage_cache
_set_redis_usage_cache(_FakeRedisCache(client))
_set_redis_usage_cache(fake_redis_cache(server))
try:
await evict_config_param("model_cost_map_reload_config")
finally:
_set_redis_usage_cache(previous_cache)
assert client.published == []
assert await _heard(listener) == []
def _reload_config_prisma_client() -> MagicMock:
@ -1090,10 +1124,11 @@ async def test_model_cost_map_reload_does_not_publish_config_change() -> None:
litellm_config_cache.flush_cache()
prisma_client = _reload_config_prisma_client()
client = _RecordingRedisClient()
server = fakeredis.FakeServer()
listener = await _listen(server)
previous_cache = proxy_server.redis_usage_cache
original_model_cost = litellm.model_cost.copy()
_set_redis_usage_cache(_FakeRedisCache(client))
_set_redis_usage_cache(fake_redis_cache(server))
try:
from litellm.litellm_core_utils.get_model_cost_map import ModelCostMapReloaded
@ -1110,7 +1145,7 @@ async def test_model_cost_map_reload_does_not_publish_config_change() -> None:
_set_redis_usage_cache(previous_cache)
prisma_client.db.litellm_config.update_many.assert_awaited_once()
assert client.published == []
assert await _heard(listener) == []
async def test_anthropic_beta_headers_reload_does_not_publish_config_change() -> None:
@ -1120,9 +1155,10 @@ async def test_anthropic_beta_headers_reload_does_not_publish_config_change() ->
litellm_config_cache.flush_cache()
prisma_client = _reload_config_prisma_client()
client = _RecordingRedisClient()
server = fakeredis.FakeServer()
listener = await _listen(server)
previous_cache = proxy_server.redis_usage_cache
_set_redis_usage_cache(_FakeRedisCache(client))
_set_redis_usage_cache(fake_redis_cache(server))
try:
with patch("litellm.anthropic_beta_headers_manager.reload_beta_headers_config") as mock_reload:
mock_reload.return_value = {}
@ -1131,7 +1167,7 @@ async def test_anthropic_beta_headers_reload_does_not_publish_config_change() ->
_set_redis_usage_cache(previous_cache)
prisma_client.db.litellm_config.upsert.assert_awaited_once()
assert client.published == []
assert await _heard(listener) == []
class _StopFailingSubscriber(ConfigSyncSubscriber):
@ -1142,11 +1178,11 @@ class _StopFailingSubscriber(ConfigSyncSubscriber):
async def test_proxy_config_subscriber_resyncs_deployments_only() -> None:
from litellm.proxy.proxy_server import ProxyConfig
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([_QueuePubSub()]))
cache = fake_redis_cache(fakeredis.FakeServer())
config = ProxyConfig()
prisma_client = MagicMock()
proxy_logging_obj = MagicMock()
calls: List[Tuple[str, object, object]] = []
calls: list[tuple[str, object, object]] = []
async def fake_add_deployment(prisma_client: object, proxy_logging_obj: object) -> None:
calls.append(("add_deployment", prisma_client, proxy_logging_obj))
@ -1183,7 +1219,7 @@ async def test_proxy_config_subscriber_applies_published_model_ids_from_the_db(
prisma_client.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=rows)
router = MagicMock()
router.registry.return_value.deployments.return_value = ["deployment"]
installed: List[object] = []
installed: list[object] = []
def install(db_models: object) -> None:
installed.append(db_models)
@ -1194,7 +1230,7 @@ async def test_proxy_config_subscriber_applies_published_model_ids_from_the_db(
monkeypatch.setattr(proxy_server, "llm_model_list", None)
monkeypatch.setattr(proxy_server.proxy_config, "get_credentials", AsyncMock())
monkeypatch.setattr(proxy_server.proxy_config, "_add_deployment", install)
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([_QueuePubSub()]))
cache = fake_redis_cache(fakeredis.FakeServer())
config = ProxyConfig()
config.start_config_sync_subscriber(prisma_client=prisma_client, proxy_logging_obj=MagicMock(), redis_cache=cache)
@ -1229,7 +1265,7 @@ async def test_proxy_config_does_not_start_subscriber_without_coordination_redis
async def test_proxy_config_keeps_the_first_subscriber_on_repeat_start() -> None:
from litellm.proxy.proxy_server import ProxyConfig
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([_QueuePubSub()]))
cache = fake_redis_cache(fakeredis.FakeServer())
config = ProxyConfig()
config.start_config_sync_subscriber(prisma_client=MagicMock(), proxy_logging_obj=MagicMock(), redis_cache=cache)
@ -1247,7 +1283,7 @@ async def test_proxy_config_shutdown_survives_a_failing_subscriber_stop() -> Non
config = ProxyConfig()
config.config_sync_subscriber = _StopFailingSubscriber(
redis_cache=_FakeRedisCache(_ScriptedPubSubRedisClient([])),
store=_ScriptedStore([]),
resync_callbacks=(),
)

View file

@ -0,0 +1,52 @@
from typing import Final
import fakeredis
import pytest
from litellm.redis.stores.config_sync import ConfigChange, TransportConfigSyncStore, decode_config_change
from litellm.redis.transport.contract import PubSubMessage, RedisFailure, RedisSubscription
from tests._support.fake_redis import fake_redis_cache
OLD_POD_BYTES: Final = b'{"object_type": "litellm_proxymodeltable", "model_ids": ["m-1", "m-2"]}'
async def _received(subscription: RedisSubscription) -> list[PubSubMessage]:
replies: Final = [await subscription.get_message(timeout=0.05) for _ in range(3)]
return [reply for reply in replies if isinstance(reply, PubSubMessage)]
@pytest.mark.asyncio
async def test_new_and_old_pods_exchange_the_same_bytes_on_the_namespaced_channel(server: fakeredis.FakeServer) -> None:
store: Final = TransportConfigSyncStore(fake_redis_cache(server, namespace="prod-eu").transport())
old_pod: Final = fake_redis_cache(server).transport()
old_pod_subscription: Final = await old_pod.subscribe("prod-eu:litellm_proxy.config_change")
subscription: Final = await store.subscribe()
assert not isinstance(old_pod_subscription, RedisFailure)
assert not isinstance(subscription, RedisFailure)
assert await store.publish_change(ConfigChange("litellm_proxymodeltable", ("m-1", "m-2"))) == 2
assert await old_pod.publish("prod-eu:litellm_proxy.config_change", OLD_POD_BYTES) == 2
expected: Final = [PubSubMessage(channel=b"prod-eu:litellm_proxy.config_change", data=OLD_POD_BYTES)] * 2
assert await _received(old_pod_subscription) == expected
assert await _received(subscription) == expected
assert decode_config_change(OLD_POD_BYTES) == ConfigChange("litellm_proxymodeltable", ("m-1", "m-2"))
assert await subscription.close() is None
assert await old_pod_subscription.close() is None
@pytest.mark.parametrize("payload", [b"change", b'{"object_type": "t", "model_ids": "m-1"}', b"{}", b"\xff"])
def test_unusable_payloads_decode_to_no_change(payload: bytes) -> None:
assert decode_config_change(payload) is None
@pytest.mark.asyncio
async def test_outage_is_a_transport_failure_for_publish_and_subscribe(server: fakeredis.FakeServer) -> None:
store: Final = TransportConfigSyncStore(fake_redis_cache(server).transport())
server.connected = False
published: Final = await store.publish_change(ConfigChange("litellm_proxymodeltable"))
subscribed: Final = await store.subscribe()
assert isinstance(published, RedisFailure) and published.kind == "transport"
assert isinstance(subscribed, RedisFailure) and subscribed.kind == "transport"