fix(proxy): apply new model rows on every worker as soon as they are written (#49034)

* fix(proxy): apply published model rows on every worker before the debounced resync

Config sync messages now carry the ids of model rows a create, update, or upsert wrote. Every worker that receives one loads those rows by id right away, including while its debounced full resync is still waiting, so a group added one deployment at a time is complete on every worker as soon as the write returns. Deletes, bulk writes, and messages from older pods carry no ids and keep the full resync path

* fix(proxy): load published model rows from the primary under the reconcile lock

Read the by-id rows through the writer so a lagging read replica cannot put an old row back, and read them inside MODEL_RECONCILE_LOCK so a concurrent delete or reload stays the last word. Look the ids up in bounded chunks, reload DB credentials only when a row names one this worker lacks, and leave auto-router rows to the debounced full resync. Add an integration test that boots two proxies and needs the peer to fall back to a just-added backup within 4 s.

* test(proxy): cover a published model row that names no credential

* fix(proxy): keep the unknown model name read-through on the replica and outside the lock

Only the by-id load that follows a published write reads the primary inside
the reconcile lock. A request for a model name a worker does not know reads
through the replica routing before taking the lock again, as on main, so
unknown names put no load on the primary and do not wait behind a reload.

* fix(proxy): keep access group edits from dropping staying deployments and batch published config ids

* fix(proxy): read access group rows from the primary so quick edits are not lost to a lagging replica

* fix(proxy): install ordinary rows queued with an auto router and check access group edits before writing

A batch of published model ids that held one auto router skipped every row in it, so ordinary
rows written at the same moment waited for the full reload. The by-id load now leaves only the
auto router rows to that reload.

An access group update now reads every deployment from the primary, answers 404 for an unknown
group and 400 for an unknown model id or name before it writes anything, then removes the group
from the deployments that leave before it adds it to the ones that join, so a failed write leaves
the group narrower than asked, never wider.

* fix(proxy): write an access group update in one transaction after a fresh read

The update now checks model names first, reads every deployment from the
primary, and commits every join and leave in one batch right after that
read, so a failed write changes nothing and a field another admin set
during validation is kept. Other workers hear about the rows only after
the commit.

* fix(proxy): lock the model rows while an access group update reads and rewrites them

* test(proxy): run the model resync tests on the real installer and a real router

The by-id resync tests now install rows through ProxyConfig's real installer and credential loader on a real Router, faking only the database, and the config sync test row model is frozen.

* fix(proxy): lock only the rows an access group update touches and answer 404 before 400

An access group update locked every model row, so a lock on any unrelated
row stalled it for 30 s and then failed it with a 500, and unrelated model
edits on other proxies waited behind it. It now takes a per-group advisory
lock, locks only the rows that carry the group or that it selects, and
reads them again once locked. The group existence check runs before the
model name check again, so a missing group answers 404 as on main.

* fix(proxy): never wait on a locked row in an access group update

The update now takes its row locks with NOWAIT. When another edit holds one
of the group's rows, it rolls back, holding nothing, and tries again for about
10 s before answering 409. Holding rows while waiting deadlocked against team
deletes and let a concurrent model edit undo the update's change. The
per-group lock also moves to the two-key advisory space, apart from team ids.

* fix(proxy): retry access group row locks for 30 s with SKIP LOCKED and lock only the rows model_ids select

* fix(proxy): take the access group lock with pg_try_advisory_xact_lock and retry while another edit holds it

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
tessellark 2026-10-30 03:19:25 -07:00 • committed by GitHub
parent d1758cda91
commit 080e643edc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2049 additions and 222 deletions

View file

@ -1648,7 +1648,7 @@
},
"/access_group/{access_group}/update": {
"put": {
"description": "Update an access group's model names.\n\nThis will:\n1. Remove the access group from all current deployments\n2. Add the access group to all deployments for the new model_names list\n\nExample:\n```bash\ncurl -X PUT 'http://localhost:4000/access_group/production-models/update' \\\n -H \"Authorization: Bearer $LITELLM_MASTER_KEY\" \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"model_names\": [\"gpt-4\", \"claude-3-sonnet\"]\n }'\n```\n\nParameters:\n- access_group: str - The access group name (URL path parameter)\n- model_names: List[str] - New list of model groups to include\n\nReturns:\n- NewModelGroupResponse with the updated access group details\n\nRaises:\n- HTTPException 400: If any model names don't exist\n- HTTPException 404: If access group not found",
"description": "Update an access group's model names.\n\nThis will:\n1. Add the access group to all deployments for the new model_names list\n2. Remove the access group from every other deployment\n\nExample:\n```bash\ncurl -X PUT 'http://localhost:4000/access_group/production-models/update' \\\n -H \"Authorization: Bearer $LITELLM_MASTER_KEY\" \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"model_names\": [\"gpt-4\", \"claude-3-sonnet\"]\n }'\n```\n\nParameters:\n- access_group: str - The access group name (URL path parameter)\n- model_names: List[str] - New list of model groups to include\n\nReturns:\n- NewModelGroupResponse with the updated access group details\n\nRaises:\n- HTTPException 400: If any model names don't exist\n- HTTPException 404: If access group not found\n- HTTPException 409: If another edit holds the group or one of its models for 30 s",
"operationId": "update_access_group_access_group__access_group__update_put",
"parameters": [
{

View file

@ -2,9 +2,12 @@ import asyncio
import json
import random
import time
from collections.abc import Awaitable, Callable
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # untyped prisma/redis boundary needs cast
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 litellm._logging import verbose_proxy_logger
from litellm.repositories.prisma_protocols import RowT_co, TableActions
@ -106,24 +109,69 @@ _pubsub_capable_client: Final = pubsub_capable_client
@dataclass(frozen=True, slots=True)
class _ConfigChangeMessage:
object_type: str
model_ids: tuple[str, ...] = ()
def _config_change_message_json(object_type: str) -> str:
return json.dumps(asdict(_ConfigChangeMessage(object_type=object_type)))
@dataclass(frozen=True, slots=True)
class _PubSubEnvelope:
data: str | bytes
async def publish_config_change(redis_cache: "RedisCache | None", object_type: str) -> None:
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 ()
try:
return (_WrittenModelRow.model_validate(result).model_id,)
except ValidationError:
return ()
async def publish_config_change(
redis_cache: "RedisCache | None", object_type: str, model_ids: tuple[str, ...] = ()
) -> None:
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))
await client.publish(config_sync_channel(redis_cache), _config_change_message_json(object_type, 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)
async def publish_config_change_for_object_type(object_type: str) -> None:
await publish_config_change(redis_cache=coordination_redis_cache(), object_type=object_type)
async def publish_config_change_for_object_type(object_type: str, model_ids: tuple[str, ...] = ()) -> None:
await publish_config_change(redis_cache=coordination_redis_cache(), object_type=object_type, model_ids=model_ids)
async def publish_model_rows_written(model_ids: tuple[str, ...]) -> None:
await publish_config_change_for_object_type(_MODEL_TABLE_NAME, model_ids)
async def publish_config_param_change(param_name: str) -> None:
@ -139,7 +187,7 @@ async def publish_config_param_change(param_name: str) -> None:
class _PublishOnWriteActions:
__slots__ = ("_actions", "_object_type", "_publish")
def __init__(self, actions: object, object_type: str, publish: Callable[[str], Awaitable[None]]) -> None:
def __init__(self, actions: object, object_type: str, publish: PublishConfigChange) -> None:
self._actions = actions
self._object_type = object_type
self._publish = publish
@ -157,7 +205,7 @@ class _PublishOnWriteActions:
**kwargs: object, # kwargs-ok: transparent passthrough to untyped prisma action
) -> object:
result: Final = await write_action(*args, **kwargs)
await publish(object_type)
await publish(object_type, written_model_ids(object_type, name, result))
return result
return _write_then_publish
@ -166,7 +214,7 @@ class _PublishOnWriteActions:
def wrap_table_actions_for_config_sync(
actions: "TableActions[RowT_co]",
table_name: str,
publish: Callable[[str], Awaitable[None]] = publish_config_change_for_object_type,
publish: PublishConfigChange = publish_config_change_for_object_type,
) -> "TableActions[RowT_co]":
if table_name not in _CONFIG_SYNCED_TABLE_NAMES:
return actions
@ -174,8 +222,14 @@ 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)
return tuple(dict.fromkeys(chain.from_iterable(change.model_ids for change in changes if change is not None)))
class ConfigSyncSubscriber:
__slots__ = (
"_apply_model_ids",
"_backoff_initial_seconds",
"_backoff_max_seconds",
"_debounce_seconds",
@ -195,6 +249,7 @@ class ConfigSyncSubscriber:
self,
redis_cache: "RedisCache",
resync_callbacks: tuple[Callable[[], Awaitable[None]], ...],
apply_model_ids: Callable[[tuple[str, ...]], Awaitable[None]] | None = None,
debounce_seconds: float = CONFIG_SYNC_DEBOUNCE_SECONDS,
jitter_max_seconds: float = CONFIG_SYNC_JITTER_MAX_SECONDS,
min_resync_interval_seconds: float = CONFIG_SYNC_MIN_RESYNC_INTERVAL_SECONDS,
@ -206,6 +261,7 @@ class ConfigSyncSubscriber:
) -> None:
self._redis_cache = redis_cache
self._resync_callbacks = resync_callbacks
self._apply_model_ids = apply_model_ids
self._debounce_seconds = debounce_seconds
self._jitter_max_seconds = jitter_max_seconds
self._min_resync_interval_seconds = min_resync_interval_seconds
@ -266,32 +322,64 @@ class ConfigSyncSubscriber:
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 _debounce_and_resync(self, pubsub: _ConfigSyncPubSub) -> None:
await self._sleep(self._debounce_seconds + self._rng.uniform(0.0, self._jitter_max_seconds))
await self._wait_for_min_resync_interval()
async def _debounce_and_resync(self, pubsub: ConfigSyncPubSub) -> None:
await self._apply_until(
pubsub, 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._run_resync_callbacks()
self._last_resync_at = self._monotonic()
async def _wait_for_min_resync_interval(self) -> None:
def _next_resync_at(self) -> float:
if self._last_resync_at is None:
return
seconds_until_next_resync = self._min_resync_interval_seconds - (self._monotonic() - self._last_resync_at)
if seconds_until_next_resync <= 0:
return
verbose_proxy_logger.debug(
"config sync resync throttled for %.1fs to cap fleet-wide reload rate",
seconds_until_next_resync,
)
await self._sleep(seconds_until_next_resync)
return self._monotonic()
next_resync_at: Final = self._last_resync_at + self._min_resync_interval_seconds
throttled_for: Final = next_resync_at - self._monotonic()
if throttled_for > 0:
verbose_proxy_logger.debug(
"config sync resync throttled for %.1fs to cap fleet-wide reload rate",
throttled_for,
)
return next_resync_at
async def _apply_until(self, pubsub: ConfigSyncPubSub, 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)
@staticmethod
async def _drain_pending(pubsub: ConfigSyncPubSub) -> None:
while await pubsub.get_message(ignore_subscribe_messages=True, timeout=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 _apply(self, pubsub: ConfigSyncPubSub, message: object) -> 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)])
model_ids: Final = _published_model_ids(ready)
if not model_ids:
return
try:
await apply(model_ids)
except Exception as e: # noqa: BLE001 # the debounced full resync retries what a targeted apply could not
verbose_proxy_logger.warning("config sync targeted apply of %s failed: %s", model_ids, e)
async def _run_resync_callbacks(self) -> None:
for callback in self._resync_callbacks:
try:

View file

@ -15,12 +15,13 @@ or the first routed request on this replica would pick a tier it has not loaded.
import asyncio
import time
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
if TYPE_CHECKING:
@ -33,8 +34,14 @@ if TYPE_CHECKING:
)
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.repositories.prisma_protocols import TableActions
from litellm.types.agents import AgentResponse
_ModelTable: TypeAlias = TableActions[prisma_models.LiteLLM_ProxyModelTable]
_ModelRows: TypeAlias = Sequence[prisma_models.LiteLLM_ProxyModelTable]
ModelLoadOutcome: TypeAlias = Literal["loaded", "missing", "no_router", "auto_router"]
READ_THROUGH_MISS_TTL_SECONDS: Final = 2.0
READ_THROUGH_RESYNC_WINDOW_SECONDS: Final = 5.0
READ_THROUGH_MAX_RESYNCS_PER_WINDOW: Final = 20
@ -123,47 +130,114 @@ async def _resync_model_deployments(model_name: str) -> bool:
return False
prisma_client: Final = proxy_server.prisma_client
assert prisma_client is not None
table: Final = ModelRepository(prisma_client).table
name_filter: Final[LiteLLM_ProxyModelTableWhereInput] = {"model_name": model_name}
id_filter: Final[LiteLLM_ProxyModelTableWhereInput] = {"model_id": model_name}
rows: Final = await table.find_many(where=name_filter) or await table.find_many(where=id_filter)
rows: Final = await _find_rows_by_name_or_id(model_name, ModelRepository(prisma_client).table)
if not rows:
return False
router: Final = proxy_server.llm_router
if router is None:
await proxy_server.proxy_config.add_deployment(
prisma_client=prisma_client, proxy_logging_obj=proxy_server.proxy_logging_obj
)
return proxy_server.llm_router is not None
if _holds_an_auto_router(rows):
await proxy_server.proxy_config.add_deployment(
prisma_client=prisma_client, proxy_logging_obj=proxy_server.proxy_logging_obj
)
return _model_is_loaded(model_name)
async with proxy_server.MODEL_RECONCILE_LOCK:
outcome: Final = await _install_model_rows(rows, _always)
if outcome in ("no_router", "auto_router"):
await _reload_every_model()
return outcome == "loaded" or _model_is_loaded(model_name)
async def resync_model_deployments_by_ids(model_ids: tuple[str, ...]) -> None:
from litellm.proxy import proxy_server
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
from litellm.repositories.model_repository import ModelRepository
if not _db_backed_registries_enabled("models"):
return
prisma_client: Final = proxy_server.prisma_client
assert prisma_client is not None
writer_table: Final = ModelRepository(WriterPinnedClient(prisma_client.db)).table
async with proxy_server.MODEL_RECONCILE_LOCK:
rows: Final = await _find_rows_by_ids(model_ids, writer_table)
_ = await _install_model_rows(_without_auto_routers(rows), _names_an_unloaded_credential)
async def _find_rows_by_name_or_id(model_name: str, table: "_ModelTable") -> "_ModelRows":
name_filter: Final[LiteLLM_ProxyModelTableWhereInput] = {"model_name": model_name}
id_filter: Final[LiteLLM_ProxyModelTableWhereInput] = {"model_id": model_name}
return await table.find_many(where=name_filter) or await table.find_many(where=id_filter)
async def _find_rows_by_ids(model_ids: tuple[str, ...], table: "_ModelTable") -> "_ModelRows":
from litellm.repositories.chunked_in import find_many_in
return list(await find_many_in(table, "model_id", model_ids))
async def _install_model_rows(
rows: "_ModelRows",
needs_credentials: Callable[["_ModelRows"], bool],
) -> ModelLoadOutcome:
from litellm.proxy import proxy_server
router: Final = proxy_server.llm_router
if not rows:
return "missing"
if router is None:
return "no_router"
if _holds_an_auto_router(rows):
return "auto_router"
if needs_credentials(rows):
prisma_client: Final = proxy_server.prisma_client
assert prisma_client is not None
await proxy_server.proxy_config.get_credentials(prisma_client=prisma_client)
proxy_server.proxy_config._add_deployment(db_models=rows)
proxy_server.llm_model_list = router.registry().deployments()
proxy_server.proxy_config._add_deployment(db_models=rows)
proxy_server.llm_model_list = router.registry().deployments()
return "loaded"
async def _reload_every_model() -> None:
from litellm.proxy import proxy_server
prisma_client: Final = proxy_server.prisma_client
assert prisma_client is not None
await proxy_server.proxy_config.add_deployment(
prisma_client=prisma_client, proxy_logging_obj=proxy_server.proxy_logging_obj
)
def _always(rows: "_ModelRows") -> bool:
return True
def _holds_an_auto_router(rows: Sequence["prisma_models.LiteLLM_ProxyModelTable"]) -> bool:
def _holds_an_auto_router(rows: "_ModelRows") -> bool:
return any(_names_an_auto_router(row.litellm_params) for row in rows)
def _without_auto_routers(rows: "_ModelRows") -> "_ModelRows":
return list(row for row in rows if not _names_an_auto_router(row.litellm_params))
def _names_an_unloaded_credential(rows: "_ModelRows") -> bool:
return any(_names_an_unloaded_credential_param(row.litellm_params) for row in rows)
_STORED_LITELLM_PARAMS: Final = TypeAdapter(Mapping[str, object])
def _names_an_auto_router(stored_litellm_params: object) -> bool:
def _decrypted_param(stored_litellm_params: object, key: str) -> str | None:
try:
params: Final = _STORED_LITELLM_PARAMS.validate_python(stored_litellm_params)
except ValidationError:
return False
stored_model: Final = params.get("model")
if not isinstance(stored_model, str):
return False
model: Final = decrypt_value_helper(value=stored_model, key="model", return_original_value=True)
return isinstance(model, str) and model.startswith("auto_router/")
return None
stored_value: Final = params.get(key)
if not isinstance(stored_value, str):
return None
value: Final = decrypt_value_helper(value=stored_value, key=key, return_original_value=True)
return value if isinstance(value, str) else None
def _names_an_auto_router(stored_litellm_params: object) -> bool:
model: Final = _decrypted_param(stored_litellm_params, "model")
return model is not None and model.startswith("auto_router/")
def _names_an_unloaded_credential_param(stored_litellm_params: object) -> bool:
credential_name: Final = _decrypted_param(stored_litellm_params, "litellm_credential_name")
return credential_name is not None and CredentialAccessor.find_credential(credential_name) is None
async def _resync_guardrails(guardrail_name: str) -> bool:

View file

@ -12,23 +12,30 @@ Endpoints here:
- DELETE /access_group/{access_group}/budget - Clear an access group's shared budget
"""
import asyncio
import contextlib
import itertools
import json
from collections.abc import Mapping, Sequence
from datetime import datetime
import time
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from datetime import datetime, timedelta
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Any, Final, Protocol
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, TypeAdapter
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.config_sync_pubsub import publish_model_rows_written
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
model_access_group_cache_key,
model_access_group_registry_cache_key,
)
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient, writer_wrapper
from litellm.proxy.management_endpoints.common_utils import validate_budget_duration
# Clear cache and reload models to pick up the access group changes
@ -116,6 +123,28 @@ class _ModelTableClient(Protocol):
async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> object: ...
class _ModelTableBatchActions(Protocol):
def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> None: ...
class _ModelTableBatch(Protocol):
@property
def litellm_proxymodeltable(self) -> _ModelTableBatchActions: ...
class _ModelTableTxActions(Protocol):
async def find_many(self) -> Sequence[_DeploymentRow]: ...
class _ModelTableTx(Protocol):
@property
def litellm_proxymodeltable(self) -> _ModelTableTxActions: ...
async def query_raw(self, query: str, *args: object) -> object: ...
def batch_(self) -> contextlib.AbstractAsyncContextManager[_ModelTableBatch]: ...
class _BudgetRow(Protocol):
@property
def budget_id(self) -> str: ...
@ -168,7 +197,42 @@ class _ModelAccessGroupBudgetTableClient(Protocol):
def _model_table(prisma_client: PrismaClient) -> _ModelTableClient:
return ModelRepository(prisma_client).table
return ModelRepository(WriterPinnedClient(prisma_client.db)).table
_TRY_LOCK_ACCESS_GROUP: Final = (
"SELECT pg_try_advisory_xact_lock(hashtext('model_access_group'), hashtext($1)) AS locked"
)
_LOCK_MODEL_ROWS: Final = (
'SELECT model_id FROM "LiteLLM_ProxyModelTable" WHERE model_id = ANY($1::text[]) '
"ORDER BY model_id FOR UPDATE SKIP LOCKED"
)
_MEMBERSHIP_TX_TIMEOUT: Final = timedelta(seconds=30)
_LOCK_RETRY_BUDGET_SECONDS: Final = _MEMBERSHIP_TX_TIMEOUT.total_seconds()
_LOCK_RETRY_BACKOFF_SECONDS: Final = (0.05, 0.1, 0.2, 0.4, 0.8)
_LOCK_RETRY_LONGEST_DELAY_SECONDS: Final = 1.0
class _LockedModelRow(BaseModel):
model_config = ConfigDict(frozen=True)
model_id: str
_LOCKED_MODEL_ROWS: Final = TypeAdapter(tuple[_LockedModelRow, ...])
class _AccessGroupLock(BaseModel):
model_config = ConfigDict(frozen=True)
locked: bool
_ACCESS_GROUP_LOCK: Final = TypeAdapter(tuple[_AccessGroupLock])
def _model_table_tx(prisma_client: PrismaClient) -> contextlib.AbstractAsyncContextManager[_ModelTableTx]:
return writer_wrapper(prisma_client.db).tx(timeout=_MEMBERSHIP_TX_TIMEOUT)
def _model_access_group_budget_table(prisma_client: PrismaClient) -> _ModelAccessGroupBudgetTableClient:
@ -379,11 +443,10 @@ async def _tag_deployment_with_access_group(
"""Write `access_group` into one deployment's model_info; returns the
(model_id, updated model_info) pair when a write happened, None when the
deployment already carried the group."""
updated_model_info, was_modified = add_access_group_to_deployment(
model_info=dict(_readable_model_info_or_raise(model_id=model_id, model_info=model_info)),
access_group=access_group,
updated_model_info: Final = _model_info_with_access_group(
model_id=model_id, model_info=model_info, access_group=access_group
)
if not was_modified:
if updated_model_info is None:
return None
await _model_table(prisma_client).update(
where={"model_id": model_id},
@ -393,6 +456,141 @@ async def _tag_deployment_with_access_group(
return (model_id, updated_model_info)
def _model_info_with_access_group(model_id: str, model_info: object, access_group: str) -> Mapping[str, object] | None:
updated_model_info, was_modified = add_access_group_to_deployment(
model_info=dict(_readable_model_info_or_raise(model_id=model_id, model_info=model_info)),
access_group=access_group,
)
return updated_model_info if was_modified else None
def _model_info_without_access_group(
model_id: str, model_info: object, access_group: str
) -> Mapping[str, object] | None:
updated_model_info, was_modified = remove_access_group_from_deployment(
model_info=dict(_readable_model_info_or_raise(model_id=model_id, model_info=model_info)),
access_group=access_group,
)
return updated_model_info if was_modified else None
def _membership_rewrite(deployment: _DeploymentRow, selected: bool, access_group: str) -> Mapping[str, object] | None:
if selected:
return _model_info_with_access_group(
model_id=deployment.model_id, model_info=deployment.model_info, access_group=access_group
)
return _model_info_without_access_group(
model_id=deployment.model_id, model_info=deployment.model_info, access_group=access_group
)
def _membership_rewrites(
deployments: Sequence[_DeploymentRow], selected_model_ids: frozenset[str], access_group: str
) -> tuple[tuple[str, Mapping[str, object]], ...]:
rewrites: Final = (
(
deployment.model_id,
_membership_rewrite(
deployment=deployment,
selected=deployment.model_id in selected_model_ids,
access_group=access_group,
),
)
for deployment in deployments
)
return tuple((model_id, model_info) for model_id, model_info in rewrites if model_info is not None)
def _carries_access_group(model_info: object, access_group: str) -> bool:
access_groups: Final = (model_info_as_mapping(model_info) or {}).get("access_groups")
return isinstance(access_groups, list) and access_group in access_groups
def _model_ids_to_lock(
deployments: Sequence[_DeploymentRow], access_group: str, data: UpdateModelGroupRequest
) -> frozenset[str]:
selected_model_ids: Final = frozenset(data.model_ids or ())
selected_model_names: Final = frozenset(() if data.model_ids else data.model_names or ())
return frozenset(
deployment.model_id
for deployment in deployments
if deployment.model_id in selected_model_ids
or deployment.model_name in selected_model_names
or _carries_access_group(deployment.model_info, access_group)
)
async def _raise_404_unless_the_primary_carries(access_group: str, prisma_client: PrismaClient) -> None:
if access_group not in _access_groups_of_deployments(await _model_table(prisma_client).find_many()):
raise _http_error(404, f"Access group '{access_group}' not found")
async def _rewrite_membership_unless_locked(
prisma_client: PrismaClient, access_group: str, data: UpdateModelGroupRequest
) -> tuple[tuple[_DeploymentRow, ...], tuple[tuple[str, Mapping[str, object]], ...]] | None:
async with _model_table_tx(prisma_client) as tx:
(group_lock,) = _ACCESS_GROUP_LOCK.validate_python(await tx.query_raw(_TRY_LOCK_ACCESS_GROUP, access_group))
if not group_lock.locked:
return None
model_ids_to_lock: Final = _model_ids_to_lock(
deployments=await tx.litellm_proxymodeltable.find_many(), access_group=access_group, data=data
)
locked_rows: Final = _LOCKED_MODEL_ROWS.validate_python(
await tx.query_raw(_LOCK_MODEL_ROWS, sorted(model_ids_to_lock))
)
if frozenset(row.model_id for row in locked_rows) != model_ids_to_lock:
return None
deployments: Final = tuple(
deployment
for deployment in await tx.litellm_proxymodeltable.find_many()
if deployment.model_id in model_ids_to_lock
)
if access_group not in _access_groups_of_deployments(deployments):
raise HTTPException(
status_code=404,
detail={"error": f"Access group '{access_group}' not found"},
)
selected_deployments: Final = _selected_deployments_or_400(deployments=deployments, data=data)
rewrites: Final = _membership_rewrites(
deployments=deployments,
selected_model_ids=frozenset(deployment.model_id for deployment in selected_deployments),
access_group=access_group,
)
if rewrites:
async with tx.batch_() as batch:
for model_id, model_info in rewrites:
batch.litellm_proxymodeltable.update(
where={"model_id": model_id}, data={"model_info": json.dumps(model_info)}
)
return selected_deployments, rewrites
def _lock_retry_delays() -> Iterator[float]:
return itertools.chain(_LOCK_RETRY_BACKOFF_SECONDS, itertools.repeat(_LOCK_RETRY_LONGEST_DELAY_SECONDS))
async def _rewrite_membership_once_unlocked(
prisma_client: PrismaClient,
access_group: str,
data: UpdateModelGroupRequest,
retry_budget_seconds: float = _LOCK_RETRY_BUDGET_SECONDS,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
clock: Callable[[], float] = time.monotonic,
) -> tuple[tuple[_DeploymentRow, ...], tuple[tuple[str, Mapping[str, object]], ...]]:
deadline: Final = clock() + retry_budget_seconds
for delay in _lock_retry_delays():
if (
rewritten := await _rewrite_membership_unless_locked(
prisma_client=prisma_client, access_group=access_group, data=data
)
) is not None:
return rewritten
if clock() + delay > deadline:
break
await sleep(delay)
raise _http_error(409, f"Another edit holds access group '{access_group}' or one of its models, try again")
def _readable_model_info_or_raise(model_id: str, model_info: object) -> Mapping[str, object]:
"""These helpers rewrite the model_info column wholesale, so a present-but-unreadable
value must refuse loudly rather than be silently replaced with a fresh object; an
@ -412,11 +610,10 @@ async def _strip_access_group_from_deployment(
"""Remove `access_group` from one deployment's model_info; returns the
(model_id, updated model_info) pair when a write happened, None when the
deployment did not carry the group."""
updated_model_info, was_modified = remove_access_group_from_deployment(
model_info=dict(_readable_model_info_or_raise(model_id=model_id, model_info=model_info)),
access_group=access_group,
updated_model_info: Final = _model_info_without_access_group(
model_id=model_id, model_info=model_info, access_group=access_group
)
if not was_modified:
if updated_model_info is None:
return None
await _model_table(prisma_client).update(
where={"model_id": model_id},
@ -493,6 +690,35 @@ async def update_specific_deployments_with_access_group(
return tuple(pair for pair in tagged if pair is not None)
def _selected_deployments_or_400(
deployments: Sequence[_DeploymentRow], data: UpdateModelGroupRequest
) -> tuple[_DeploymentRow, ...]:
if data.model_ids:
known_model_ids: Final = frozenset(deployment.model_id for deployment in deployments)
unknown_model_id: Final = next(
(model_id for model_id in data.model_ids if model_id not in known_model_ids), None
)
if unknown_model_id is not None:
raise HTTPException(
status_code=400,
detail={"error": f"Deployment with model_id '{unknown_model_id}' not found in Database."},
)
selected_model_ids: Final = frozenset(data.model_ids)
return tuple(deployment for deployment in deployments if deployment.model_id in selected_model_ids)
model_names: Final = data.model_names or ()
known_model_names: Final = frozenset(deployment.model_name for deployment in deployments)
unknown_model_name: Final = next((name for name in model_names if name not in known_model_names), None)
if unknown_model_name is not None:
raise HTTPException(
status_code=400,
detail={
"error": f"Can't find model '{unknown_model_name}' in Database. Access group management is only supported for database models."
},
)
selected_model_names: Final = frozenset(model_names)
return tuple(deployment for deployment in deployments if deployment.model_name in selected_model_names)
async def _find_deployment_or_400(model_id: str, prisma_client: PrismaClient) -> object:
deployment: Final = await _model_table(prisma_client).find_unique(where={"model_id": model_id})
if deployment is None:
@ -536,10 +762,10 @@ async def get_all_access_groups_from_db(
Returns:
Dict[str, AccessGroupInfo]: Dictionary mapping access_group name to info
"""
# Get all deployments
deployments: Final = await ModelRepository(prisma_client).table.find_many()
return dict(_access_groups_of_deployments(await ModelRepository(prisma_client).table.find_many()))
# Build access group map
def _access_groups_of_deployments(deployments: Sequence[_DeploymentRow]) -> Mapping[str, AccessGroupInfo]:
model_names_by_group: Final[dict[str, list[str]]] = {}
for deployment in deployments:
@ -835,8 +1061,8 @@ async def update_access_group(
Update an access group's model names.
This will:
1. Remove the access group from all current deployments
2. Add the access group to all deployments for the new model_names list
1. Add the access group to all deployments for the new model_names list
2. Remove the access group from every other deployment
Example:
```bash
@ -858,6 +1084,7 @@ async def update_access_group(
Raises:
- HTTPException 400: If any model names don't exist
- HTTPException 404: If access group not found
- HTTPException 409: If another edit holds the group or one of its models for 30 s
"""
from litellm.proxy.proxy_server import llm_router, prisma_client
@ -879,26 +1106,14 @@ async def update_access_group(
detail={"error": "Either model_names or model_ids must be provided and non-empty"},
)
use_model_ids: Final = has_model_ids
# Validation: Check if access group exists
try:
access_groups_map: Final = await get_all_access_groups_from_db(prisma_client=prisma_client)
if access_group not in access_groups_map:
raise HTTPException(
status_code=404,
detail={"error": f"Access group '{access_group}' not found"},
)
await _raise_404_unless_the_primary_carries(access_group=access_group, prisma_client=prisma_client)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail={"error": f"Failed to check access group existence: {e}"},
)
raise _http_error(500, f"Failed to check access group existence: {e}")
# Validation: Check if all new models exist (only if using model_names path)
if not use_model_ids and has_model_names:
if not has_model_ids and has_model_names:
assert data.model_names is not None
missing_models: Final = await _missing_models_after_read_through(
model_names=data.model_names,
@ -912,43 +1127,19 @@ async def update_access_group(
)
try:
# Step 1: Remove access group from ALL DB deployments (skip config models)
all_deployments: Final = await _model_table(prisma_client).find_many()
stripped: Final = [
await _strip_access_group_from_deployment(
model_id=deployment.model_id,
model_info=deployment.model_info,
access_group=access_group,
prisma_client=prisma_client,
)
for deployment in all_deployments
]
stripped_pairs: Final = tuple(pair for pair in stripped if pair is not None)
# Step 2: Add access group using the appropriate method
if use_model_ids:
assert data.model_ids is not None
updated_pairs = await update_specific_deployments_with_access_group(
model_ids=data.model_ids,
access_group=access_group,
prisma_client=prisma_client,
)
else:
assert data.model_names is not None
updated_pairs = await update_deployments_with_access_group(
model_names=data.model_names,
access_group=access_group,
prisma_client=prisma_client,
)
models_updated: Final = len(updated_pairs)
selected_deployments, rewrites = await _rewrite_membership_once_unlocked(
prisma_client=prisma_client, access_group=access_group, data=data
)
if rewrites:
await publish_model_rows_written(tuple(model_id for model_id, _ in rewrites))
models_updated: Final = len(selected_deployments)
# Clear cache and reload models to pick up the access group changes
live_before_reload: Final = live_model_ids_snapshot()
reload_outcome: Final = await clear_cache()
_raise_http_if_reload_degraded_serving(
before=live_before_reload,
written_models=list({**dict(stripped_pairs), **dict(updated_pairs)}.items()),
written_models=rewrites,
access_group=access_group,
still_desired=reload_outcome.still_desired,
live_after=reload_outcome.live_after,

View file

@ -779,7 +779,11 @@ async def _auto_router_capability_slot(
limit=limit,
)
yield tables.litellm_proxymodeltable
await publish_config_change(redis_cache=coordination_redis_cache(), object_type="litellm_proxymodeltable")
await publish_config_change(
redis_cache=coordination_redis_cache(),
object_type="litellm_proxymodeltable",
model_ids=() if model_id is None else (model_id,),
)
ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING: Final = "enforce_rpm_tpm_on_model_add"

View file

@ -549,6 +549,7 @@ from litellm.proxy.common_utils.periodic_reload_schedule import (
write_reload_interval,
)
from litellm.proxy.common_utils.proxy_state import ProxyState
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
from litellm.proxy.common_utils.reset_budget_job import ResetBudgetJob
from litellm.proxy.common_utils.responses_stream_errors import ResponsesStreamErrorState
from litellm.proxy.common_utils.scheduled_job_stagger import (
@ -8750,6 +8751,7 @@ class ProxyConfig:
subscriber: Final = ConfigSyncSubscriber(
redis_cache=redis_cache,
resync_callbacks=(_resync_config_from_db,),
apply_model_ids=resync_model_deployments_by_ids,
)
self.config_sync_subscriber = subscriber
subscriber.start()

View file

@ -0,0 +1,109 @@
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Final
import httpx
import pytest
from pydantic import TypeAdapter
from redis import Redis
from tests.integration._support.client import Gateway, eventually, list_value, object_value, string_value
from tests.integration._support.database import scratch_database
from tests.integration._support.process import graceful_stop_seconds, owned_proxy
from tests.integration._support.redis_process import owned_redis
pytestmark: Final = [pytest.mark.timeout(int(4 * graceful_stop_seconds() + 120))]
COORDINATION_CONFIG: Final = Path(__file__).resolve().parents[1] / "coordination_redis_proxy_config.yaml"
CONFIG_SYNC_CHANNEL: Final = "litellm_proxy.config_change"
SUBSCRIBER_COUNTS: Final = TypeAdapter(tuple[tuple[str, int], ...])
PERIODIC_RELOAD_SECONDS: Final = 3600
PEER_PICKUP_SECONDS: Final = 4
PROVIDER_KEY: Final = "integration-provider-key"
@contextmanager
def _proxy_pair(gateway: Gateway, directory: Path) -> Iterator[tuple[Gateway, Gateway]]:
with scratch_database("integration_model_add_sync") as database, owned_redis(directory) as coordination:
shared_stores: Final = {
"DATABASE_URL": database,
"REDIS_HOST": coordination.host,
"REDIS_PORT": str(coordination.port),
"PROXY_CONFIG_RELOAD_INTERVAL_SECONDS": str(PERIODIC_RELOAD_SECONDS),
}
with (
owned_proxy(gateway, directory / "writer", shared_stores, config=COORDINATION_CONFIG) as writer,
owned_proxy(gateway, directory / "peer", shared_stores, config=COORDINATION_CONFIG) as peer,
Redis(host=coordination.host, port=coordination.port, socket_timeout=2) as redis,
):
eventually(
lambda: SUBSCRIBER_COUNTS.validate_python(redis.pubsub_numsub(CONFIG_SYNC_CHANNEL))[0][1],
lambda count: count >= 2,
seconds=30,
)
yield writer, peer
@contextmanager
def _failing_upstream_model(gateway: Gateway, upstream_model: str) -> Iterator[None]:
script: Final = f"{gateway.upstream_url}/__scripts/{upstream_model}"
httpx.post(script, json={"statuses": [500]}, timeout=5, trust_env=False).raise_for_status()
try:
yield
finally:
httpx.delete(script, timeout=5, trust_env=False).raise_for_status()
def _add_deployment(writer: Gateway, group: str, upstream_model: str, order: int) -> None:
writer.post(
"/model/new",
{
"model_name": group,
"litellm_params": {
"model": f"openai/{upstream_model}",
"api_key": PROVIDER_KEY,
"api_base": f"{writer.upstream_url}/v1",
"order": order,
"num_retries": 0,
},
},
)
def _listed_models(proxy: Gateway) -> frozenset[str]:
return frozenset(string_value(object_value(entry)["id"]) for entry in list_value(proxy.get("/v1/models")["data"]))
def _chat(proxy: Gateway, group: str) -> httpx.Response:
return proxy.request(
"POST",
"/v1/chat/completions",
{"model": group, "messages": [{"role": "user", "content": f"order fallback {uuid.uuid4().hex}"}]},
)
def test_a_backup_added_on_one_proxy_catches_the_peers_failing_primary_right_away(
gateway: Gateway, tmp_path: Path
) -> None:
group: Final = f"integration-{uuid.uuid4().hex}"
primary: Final = f"integration-primary-{uuid.uuid4().hex}"
backup: Final = f"integration-backup-{uuid.uuid4().hex}"
with _proxy_pair(gateway, tmp_path) as (writer, peer), _failing_upstream_model(gateway, primary):
_add_deployment(writer, group, primary, order=1)
eventually(lambda: _listed_models(peer), lambda listed: group in listed, seconds=30)
_add_deployment(writer, group, backup, order=2)
on_writer: Final = _chat(writer, group)
on_peer: Final = eventually(
lambda: _chat(peer, group),
lambda response: response.status_code == 200,
seconds=PEER_PICKUP_SECONDS,
return_last_on_timeout=True,
)
assert on_writer.status_code == 200, on_writer.text
assert object_value(on_writer.json())["model"] == backup, on_writer.text
assert on_peer.status_code == 200, on_peer.text
assert object_value(on_peer.json())["model"] == backup, on_peer.text

View file

@ -5,6 +5,7 @@ from typing import Callable, Coroutine, Final, Iterable, List, Optional, Tuple
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel, ConfigDict
from redis.asyncio import Redis
import litellm
@ -102,8 +103,8 @@ class _ScriptedPubSubClient:
class _QueuePubSub:
def __init__(self, initial_messages: Iterable[str] = ()) -> None:
self.queue: "asyncio.Queue[str]" = asyncio.Queue()
def __init__(self, initial_messages: Iterable[object] = ()) -> None:
self.queue: "asyncio.Queue[object]" = asyncio.Queue()
for message in initial_messages:
self.queue.put_nowait(message)
self.subscribed_channels: List[str] = []
@ -112,7 +113,7 @@ class _QueuePubSub:
async def subscribe(self, *channels: str) -> None:
self.subscribed_channels.extend(channels)
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[str]:
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[object]:
if timeout == 0:
try:
return self.queue.get_nowait()
@ -128,7 +129,7 @@ class _QueuePubSub:
class _BrokenPubSub(_QueuePubSub):
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[str]:
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[object]:
raise ConnectionError("connection lost")
@ -138,11 +139,11 @@ class _CloseFailingBrokenPubSub(_BrokenPubSub):
class _EmptyPollsThenMessagePubSub(_QueuePubSub):
def __init__(self, empty_polls: int, initial_messages: Iterable[str] = ()) -> None:
def __init__(self, empty_polls: int, initial_messages: Iterable[object] = ()) -> None:
super().__init__(initial_messages=initial_messages)
self.remaining_empty_polls = empty_polls
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[str]:
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[object]:
if timeout != 0 and self.remaining_empty_polls > 0:
self.remaining_empty_polls -= 1
return None
@ -157,6 +158,38 @@ class _FakeClock:
return self.now
class _VirtualTimePubSub(_QueuePubSub):
"""Timed polls move the injected clock forward instead of waiting on the wall clock.
A delivery scheduled at a clock time is returned by the first poll whose window
reaches it, with the clock set to that time, so a test asserts when each resync
and targeted apply ran in subscriber time.
"""
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])
async def get_message(self, *, ignore_subscribe_messages: bool, timeout: float) -> Optional[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
self._clock.now = due_by
return None
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 _ScriptedPubSubRedisClient(Redis):
def __init__(self, pubsubs: Iterable[_QueuePubSub]) -> None:
self._scripted_pubsubs = iter(pubsubs)
@ -204,7 +237,16 @@ async def test_publish_sends_object_type_json_on_channel() -> None:
assert len(client.published) == 1
channel, message = client.published[0]
assert channel == "litellm_proxy.config_change"
assert json.loads(message) == {"object_type": "litellm_proxymodeltable"}
assert json.loads(message) == {"object_type": "litellm_proxymodeltable", "model_ids": []}
async def test_publish_carries_the_written_model_ids() -> None:
client = _RecordingRedisClient()
cache = _FakeRedisCache(client)
await publish_config_change(redis_cache=cache, 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"]}
async def test_publish_uses_namespaced_channel() -> None:
@ -235,7 +277,7 @@ async def test_publish_reaches_cluster_derived_pubsub_clients() -> None:
await publish_config_change(redis_cache=cache, object_type="litellm_proxymodeltable")
assert client.published == [
(CONFIG_SYNC_CHANNEL, json.dumps({"object_type": "litellm_proxymodeltable"}))
(CONFIG_SYNC_CHANNEL, json.dumps({"object_type": "litellm_proxymodeltable", "model_ids": []}))
]
@ -312,29 +354,210 @@ class _MaxJitterRandom(random.Random):
return b
async def test_debounce_sleep_adds_jitter_from_injected_rng() -> None:
pubsub = _QueuePubSub(initial_messages=[json.dumps({"object_type": "litellm_proxymodeltable"})])
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
sleeps: List[float] = []
fired = asyncio.Event()
def _virtual_time_subscriber(
pubsub: _VirtualTimePubSub,
clock: _FakeClock,
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,
expected_resyncs: int = 1,
apply_error: Optional[Exception] = 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 recording_sleep(seconds: float) -> None:
sleeps.append(seconds)
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
subscriber = ConfigSyncSubscriber(
redis_cache=cache,
resync_callbacks=(_recording_callback([], "resync", fired),),
debounce_seconds=1.0,
jitter_max_seconds=4.0,
rng=_MaxJitterRandom(),
sleep=recording_sleep,
return ConfigSyncSubscriber(
redis_cache=_FakeRedisCache(_ScriptedPubSubRedisClient([pubsub])),
resync_callbacks=(resync,),
apply_model_ids=apply,
debounce_seconds=debounce_seconds,
jitter_max_seconds=jitter_max_seconds,
min_resync_interval_seconds=min_resync_interval_seconds,
rng=rng,
monotonic=clock,
)
subscriber.start()
await asyncio.wait_for(fired.wait(), timeout=5)
await subscriber.stop()
assert sleeps == [5.0]
async def _run_until_resynced(subscriber: ConfigSyncSubscriber, resynced: asyncio.Event) -> None:
subscriber.start()
try:
await asyncio.wait_for(resynced.wait(), timeout=5)
finally:
await subscriber.stop()
async def test_debounce_waits_out_the_jitter_from_injected_rng() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, _change())])
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()
)
await _run_until_resynced(subscriber, resynced)
assert events == ["resync@1005.0"]
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] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(
pubsub, clock, events, resynced, debounce_seconds=1.0, jitter_max_seconds=4.0, rng=_MaxJitterRandom()
)
await _run_until_resynced(subscriber, resynced)
assert events == ["apply:m-1@1000.0", "resync@1005.0"]
async def test_model_ids_published_inside_the_throttle_wait_apply_without_waiting() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(
clock, deliveries=[(1000.0, _change()), (1004.0, _change("m-2")), (1006.0, _change("m-3", "m-4"))]
)
events: List[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
await _run_until_resynced(subscriber, resynced)
assert events == ["resync@1000.0", "apply:m-2@1004.0", "apply:m-3,m-4@1006.0", "resync@1010.0"]
async def test_model_ids_already_waiting_apply_together_in_one_call() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(
clock, deliveries=[(1000.0, _change("m-1")), (1000.0, _change("m-2", "m-1")), (1000.0, _change("m-3"))]
)
events: List[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced)
await _run_until_resynced(subscriber, resynced)
assert events == ["apply:m-1,m-2,m-3@1000.0", "resync@1000.0"]
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] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced)
await _run_until_resynced(subscriber, resynced)
assert events == ["apply:m-1@1000.0", "resync@1000.0"]
async def test_messages_without_usable_model_ids_only_schedule_the_full_resync() -> None:
clock = _FakeClock()
pubsub = _VirtualTimePubSub(
clock,
deliveries=[
(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}),
(1007.0, None),
],
)
events: List[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
await _run_until_resynced(subscriber, resynced)
assert events == ["resync@1000.0", "resync@1010.0"]
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] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, apply_error=RuntimeError("db down"))
await _run_until_resynced(subscriber, resynced)
assert events == ["apply:m-1@1000.0", "resync@1000.0"]
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] = []
fired = asyncio.Event()
subscriber = ConfigSyncSubscriber(
redis_cache=_FakeRedisCache(_ScriptedPubSubRedisClient([pubsub])),
resync_callbacks=(_recording_callback(resyncs, "resync", fired),),
debounce_seconds=0.0,
jitter_max_seconds=0.0,
monotonic=clock,
)
await _run_until_resynced(subscriber, fired)
assert resyncs == ["resync"]
async def test_peer_holds_every_published_row_before_its_next_full_resync() -> None:
publisher = _RecordingRedisClient()
cache = _FakeRedisCache(publisher)
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]
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]] = []
resynced = asyncio.Event()
async def apply(model_ids: Tuple[str, ...]) -> None:
registry.update(model_ids)
snapshots.append(("apply", clock.now, frozenset(registry)))
async def resync() -> None:
registry.update(row for row, written_at in db_rows_written_at.items() if written_at <= clock.now)
snapshots.append(("resync", clock.now, frozenset(registry)))
if sum(kind == "resync" for kind, _, _ in snapshots) == 2:
resynced.set()
subscriber = ConfigSyncSubscriber(
redis_cache=_FakeRedisCache(_ScriptedPubSubRedisClient([pubsub])),
resync_callbacks=(resync,),
apply_model_ids=apply,
debounce_seconds=1.0,
jitter_max_seconds=5.0,
min_resync_interval_seconds=10.0,
rng=_MaxJitterRandom(),
monotonic=clock,
)
await _run_until_resynced(subscriber, resynced)
assert snapshots == [
("apply", 1000.0, frozenset({"m-order-1"})),
("resync", 1006.0, frozenset({"m-order-1"})),
("apply", 1007.0, frozenset({"m-order-1", "m-order-2"})),
("resync", 1016.0, frozenset({"m-order-1", "m-order-2"})),
]
def test_default_jitter_window_is_nonzero() -> None:
@ -345,92 +568,43 @@ def test_default_min_resync_interval_caps_reload_rate() -> None:
assert CONFIG_SYNC_MIN_RESYNC_INTERVAL_SECONDS > CONFIG_SYNC_JITTER_MAX_SECONDS
def _throttled_subscriber(
cache: object,
events: List[str],
fired: asyncio.Event,
clock: _FakeClock,
min_resync_interval_seconds: float = 10.0,
) -> ConfigSyncSubscriber:
async def recording_sleep(seconds: float) -> None:
events.append(f"sleep:{seconds}")
await asyncio.sleep(0)
async def resync() -> None:
events.append("resync")
fired.set()
return ConfigSyncSubscriber(
redis_cache=cache,
resync_callbacks=(resync,),
debounce_seconds=0.0,
jitter_max_seconds=0.0,
min_resync_interval_seconds=min_resync_interval_seconds,
sleep=recording_sleep,
monotonic=clock,
)
async def test_resync_arriving_inside_min_interval_waits_out_the_remainder() -> None:
pubsub = _QueuePubSub()
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
events: List[str] = []
fired = asyncio.Event()
clock = _FakeClock()
subscriber = _throttled_subscriber(cache=cache, events=events, fired=fired, clock=clock)
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, "change"), (1004.0, "change")])
events: List[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
subscriber.start()
pubsub.queue.put_nowait("change")
await asyncio.wait_for(fired.wait(), timeout=5)
fired.clear()
clock.now += 4.0
pubsub.queue.put_nowait("change")
await asyncio.wait_for(fired.wait(), timeout=5)
await subscriber.stop()
await _run_until_resynced(subscriber, resynced)
assert events == ["sleep:0.0", "resync", "sleep:0.0", "sleep:6.0", "resync"]
assert events == ["resync@1000.0", "resync@1010.0"]
async def test_resync_after_min_interval_elapsed_is_not_throttled() -> None:
pubsub = _QueuePubSub()
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
events: List[str] = []
fired = asyncio.Event()
clock = _FakeClock()
subscriber = _throttled_subscriber(cache=cache, events=events, fired=fired, clock=clock)
pubsub = _VirtualTimePubSub(clock, deliveries=[(1000.0, "change"), (1030.0, "change")])
events: List[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
subscriber.start()
pubsub.queue.put_nowait("change")
await asyncio.wait_for(fired.wait(), timeout=5)
fired.clear()
clock.now += 30.0
pubsub.queue.put_nowait("change")
await asyncio.wait_for(fired.wait(), timeout=5)
await subscriber.stop()
await _run_until_resynced(subscriber, resynced)
assert events == ["sleep:0.0", "resync", "sleep:0.0", "resync"]
assert events == ["resync@1000.0", "resync@1030.0"]
async def test_writes_during_the_throttle_wait_collapse_into_the_next_resync() -> None:
pubsub = _QueuePubSub()
cache = _FakeRedisCache(_ScriptedPubSubRedisClient([pubsub]))
events: List[str] = []
fired = asyncio.Event()
clock = _FakeClock()
subscriber = _throttled_subscriber(cache=cache, events=events, fired=fired, clock=clock)
pubsub = _VirtualTimePubSub(
clock, deliveries=[(1000.0, "change")] + [(1004.0 + offset, "change") for offset in range(5)]
)
events: List[str] = []
resynced = asyncio.Event()
subscriber = _virtual_time_subscriber(pubsub, clock, events, resynced, expected_resyncs=2)
subscriber.start()
pubsub.queue.put_nowait("change")
await asyncio.wait_for(fired.wait(), timeout=5)
fired.clear()
for _ in range(5):
pubsub.queue.put_nowait("change")
await asyncio.wait_for(fired.wait(), timeout=5)
await asyncio.sleep(0.1)
await subscriber.stop()
await _run_until_resynced(subscriber, resynced)
assert events.count("resync") == 2
assert pubsub.queue.empty()
assert events == ["resync@1000.0", "resync@1010.0"]
assert pubsub.pending == []
async def test_polls_without_messages_do_not_trigger_resyncs() -> None:
@ -646,13 +820,39 @@ class _AllWritesTableActions:
return action
def _recording_publish(calls: List[Tuple[str, str]]) -> Callable[[str], Coroutine[None, None, None]]:
async def publish(object_type: 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.append((object_type, model_ids))
return publish
class _ModelRow(BaseModel):
model_config = ConfigDict(frozen=True)
model_id: str
model_name: str
class _ModelRowTableActions:
def __getattr__(self, name: str) -> Callable[..., Coroutine[None, None, object]]:
async def action(*args: object, **kwargs: object) -> object:
if name.endswith("_many"):
return 2
return _ModelRow(model_id="m-1", model_name="gpt-5.2")
return action
def test_wrapper_passes_through_unsynced_tables() -> None:
actions = object()
@ -722,6 +922,46 @@ async def test_wrapper_publishes_for_every_write_action(action_name: str) -> Non
assert publish_calls == [("publish", "litellm_guardrailstable")]
@pytest.mark.parametrize(
"action_name, expected_ids",
[
("create", ("m-1",)),
("update", ("m-1",)),
("upsert", ("m-1",)),
("delete", ()),
("create_many", ()),
("update_many", ()),
("delete_many", ()),
],
)
async def test_wrapper_publishes_the_written_model_id_for_row_writes(
action_name: str, expected_ids: Tuple[str, ...]
) -> None:
publish_calls: List[Tuple[str, Tuple[str, ...]]] = []
wrapped = wrap_table_actions_for_config_sync(
actions=_ModelRowTableActions(),
table_name="litellm_proxymodeltable",
publish=_recording_publish_with_ids(publish_calls),
)
await getattr(wrapped, action_name)(data={})
assert publish_calls == [("litellm_proxymodeltable", expected_ids)]
async def test_wrapper_publishes_no_model_ids_for_other_tables() -> None:
publish_calls: List[Tuple[str, Tuple[str, ...]]] = []
wrapped = wrap_table_actions_for_config_sync(
actions=_ModelRowTableActions(),
table_name="litellm_guardrailstable",
publish=_recording_publish_with_ids(publish_calls),
)
await wrapped.create(data={})
assert publish_calls == [("litellm_guardrailstable", ())]
async def test_model_repository_write_publishes_via_live_coordination_cache() -> None:
from litellm.proxy import proxy_server
from litellm.proxy.proxy_server import _set_redis_usage_cache
@ -747,7 +987,7 @@ async def test_model_repository_write_publishes_via_live_coordination_cache() ->
assert len(client.published) == 1
channel, message = client.published[0]
assert channel == CONFIG_SYNC_CHANNEL
assert json.loads(message) == {"object_type": "litellm_proxymodeltable"}
assert json.loads(message) == {"object_type": "litellm_proxymodeltable", "model_ids": ["m-1"]}
async def test_ui_settings_write_publishes_via_live_coordination_cache() -> None:
@ -774,7 +1014,7 @@ async def test_ui_settings_write_publishes_via_live_coordination_cache() -> None
assert len(client.published) == 1
channel, message = client.published[0]
assert channel == CONFIG_SYNC_CHANNEL
assert json.loads(message) == {"object_type": "litellm_uisettings"}
assert json.loads(message) == {"object_type": "litellm_uisettings", "model_ids": []}
async def _publish_calls_for_invalidated_param(param_name: str) -> List[Tuple[str, str]]:
@ -799,7 +1039,7 @@ async def test_invalidate_config_param_publishes_params_a_resync_applies(param_n
assert len(published) == 1
channel, message = published[0]
assert channel == CONFIG_SYNC_CHANNEL
assert json.loads(message) == {"object_type": param_name}
assert json.loads(message) == {"object_type": param_name, "model_ids": []}
@pytest.mark.parametrize("param_name", _STARTUP_ONLY_CONFIG_PARAM_NAMES)
@ -932,6 +1172,46 @@ async def test_proxy_config_subscriber_resyncs_deployments_only() -> None:
assert subscriber._task is None
async def test_proxy_config_subscriber_applies_published_model_ids_from_the_db(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy import proxy_server
from litellm.proxy.proxy_server import ProxyConfig
rows = [MagicMock()]
prisma_client = MagicMock()
prisma_client.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=rows)
router = MagicMock()
router.registry.return_value.deployments.return_value = ["deployment"]
installed: List[object] = []
def install(db_models: object) -> None:
installed.append(db_models)
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
monkeypatch.setattr(proxy_server, "store_model_in_db", True)
monkeypatch.setattr(proxy_server, "llm_router", router)
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()]))
config = ProxyConfig()
config.start_config_sync_subscriber(prisma_client=prisma_client, proxy_logging_obj=MagicMock(), redis_cache=cache)
subscriber = config.config_sync_subscriber
assert subscriber is not None
apply = subscriber._apply_model_ids
assert apply is not None
await apply(("m-1", "m-2"))
await config.stop_config_sync_subscriber()
prisma_client.db.litellm_proxymodeltable.find_many.assert_awaited_once_with(
where={"model_id": {"in": ["m-1", "m-2"]}}
)
assert installed == [rows]
assert proxy_server.llm_model_list == ["deployment"]
async def test_proxy_config_does_not_start_subscriber_without_coordination_redis() -> None:
from litellm.proxy.proxy_server import ProxyConfig

View file

@ -1,4 +1,5 @@
import asyncio
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Final
import pytest
@ -6,7 +7,10 @@ import pytest
from litellm.proxy.common_utils.registry_read_through import RegistryReadThrough
if TYPE_CHECKING:
from litellm import Router
from litellm.proxy._types import LiteLLM_ProxyModelTable
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
from litellm.types.utils import CredentialItem
def nothing_loaded(_key: str) -> bool:
@ -487,6 +491,178 @@ async def test_resync_model_deployments_loads_db_credentials_before_reconciling_
installed.assert_called_once_with(db_models=rows, credential={"api_key": "sk-from-db"})
@pytest.fixture
def stored_params_salt(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-stored-model-rows")
def _stored_model_row(model_id: str, stored_params: Mapping[str, str]) -> "LiteLLM_ProxyModelTable":
from litellm.proxy._types import LiteLLM_ProxyModelTable
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
return LiteLLM_ProxyModelTable(
model_id=model_id,
model_name="gpt-failover-group",
litellm_params={key: encrypt_value_helper(value) for key, value in stored_params.items()},
model_info={"id": model_id},
)
def _backup_row(model_id: str, model: str = "openai/gpt-backup") -> "LiteLLM_ProxyModelTable":
return _stored_model_row(model_id, {"model": model, "api_key": "sk-backup"})
def _router_serving(*model_ids: str) -> "Router":
from litellm import Router
return Router(
model_list=[
{
"model_name": "gpt-failover-group",
"litellm_params": {"model": "openai/gpt-primary", "api_key": "sk-primary"},
"model_info": {"id": model_id},
}
for model_id in model_ids
]
)
def _served_ids(deployments: "Sequence[Mapping[str, Mapping[str, object]]] | None") -> frozenset[object]:
return frozenset(deployment["model_info"]["id"] for deployment in deployments or ())
class _ProxyModelTable:
def __init__(self, rows: "tuple[LiteLLM_ProxyModelTable, ...]") -> None:
self.rows = rows
self.reads = 0
async def find_many(
self, where: Mapping[str, Mapping[str, Sequence[str]]]
) -> "tuple[LiteLLM_ProxyModelTable, ...]":
self.reads += 1
return tuple(row for row in self.rows if row.model_id in where["model_id"]["in"])
async def create(self, data: Mapping[str, object]) -> None:
raise AssertionError(f"a model load wrote a row: {data}")
class _CredentialsTable:
def __init__(self, rows: "tuple[CredentialItem, ...]") -> None:
self.rows = rows
async def find_many(self) -> "tuple[CredentialItem, ...]":
return self.rows
class _Db:
def __init__(
self, litellm_proxymodeltable: _ProxyModelTable, litellm_credentialstable: _CredentialsTable | None = None
) -> None:
self.litellm_proxymodeltable = litellm_proxymodeltable
self.litellm_credentialstable = litellm_credentialstable or _CredentialsTable(())
self.other_tables_read: list[str] = []
def __getattr__(self, table_name: str) -> object:
self.other_tables_read.append(table_name)
raise AttributeError(table_name)
class _PrismaClientWithoutConfigRows:
def __init__(self, db: object) -> None:
self.db = db
async def get_generic_data(self, key: str, value: str, table_name: str) -> None:
return None
def _serve_db_models(monkeypatch: pytest.MonkeyPatch, db: object, router: "Router | None") -> None:
import litellm.proxy.proxy_server as proxy_server
monkeypatch.setattr(proxy_server, "prisma_client", _PrismaClientWithoutConfigRows(db))
monkeypatch.setattr(proxy_server, "store_model_in_db", True)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "llm_model_list", None)
@pytest.mark.asyncio
@pytest.mark.usefixtures("stored_params_salt")
async def test_resync_model_deployments_by_ids_installs_only_the_published_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
router: Final = _router_serving("primary-id")
_serve_db_models(
monkeypatch, _Db(_ProxyModelTable((_backup_row("m-1"), _backup_row("m-2"), _backup_row("m-3")))), router
)
await resync_model_deployments_by_ids(("m-1", "m-2"))
assert _served_ids(router.registry().deployments()) == {"primary-id", "m-1", "m-2"}
assert _served_ids(proxy_server.llm_model_list) == {"primary-id", "m-1", "m-2"}
assert not proxy_server.MODEL_RECONCILE_LOCK.locked()
@pytest.mark.asyncio
@pytest.mark.usefixtures("stored_params_salt")
async def test_resync_model_deployments_by_ids_leaves_the_router_alone_for_deleted_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
router: Final = _router_serving("primary-id")
_serve_db_models(monkeypatch, _Db(_ProxyModelTable(())), router)
await resync_model_deployments_by_ids(("deleted-id",))
assert _served_ids(router.registry().deployments()) == {"primary-id"}
assert proxy_server.llm_model_list is None
assert not proxy_server.MODEL_RECONCILE_LOCK.locked()
@pytest.mark.asyncio
@pytest.mark.usefixtures("stored_params_salt")
async def test_resync_model_deployments_by_ids_without_a_router_leaves_the_reload_to_the_debounced_resync(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
table: Final = _ProxyModelTable((_backup_row("m-1"),))
db: Final = _Db(table)
_serve_db_models(monkeypatch, db, None)
await resync_model_deployments_by_ids(("m-1",))
assert proxy_server.llm_router is None
assert table.reads == 1
assert db.other_tables_read == []
@pytest.mark.asyncio
async def test_resync_model_deployments_by_ids_respects_supported_db_objects(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from unittest.mock import AsyncMock, MagicMock
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
prisma_client: Final = MagicMock()
prisma_client.db.litellm_proxymodeltable.find_many = AsyncMock(
side_effect=AssertionError("db hit for an object type this replica does not load")
)
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
monkeypatch.setattr(proxy_server, "store_model_in_db", True)
monkeypatch.setattr(proxy_server, "general_settings", {"supported_db_objects": ["guardrails"]})
await resync_model_deployments_by_ids(("gated-out-id",))
prisma_client.db.litellm_proxymodeltable.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_resync_model_deployments_respects_supported_db_objects(monkeypatch):
from unittest.mock import AsyncMock, MagicMock
@ -786,3 +962,174 @@ async def test_resync_model_deployments_reconciles_every_db_row_when_the_missed_
assert await _resync_model_deployments(router_name) is router_holds_the_name_after_reconcile
reconciled.assert_called_once_with(prisma_client=prisma_client, proxy_logging_obj=proxy_server.proxy_logging_obj)
@pytest.mark.asyncio
@pytest.mark.usefixtures("stored_params_salt")
async def test_resync_model_deployments_by_ids_reads_the_writer_not_a_lagging_replica(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from unittest.mock import MagicMock
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
from litellm.proxy.db.prisma_client import PrismaWrapper
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper
writer: Final = MagicMock(name="writer_prisma")
writer.litellm_proxymodeltable = _ProxyModelTable((_backup_row("backup-id"),))
replica_table: Final = _ProxyModelTable(())
replica: Final = MagicMock(name="lagging_replica_prisma")
replica.litellm_proxymodeltable = replica_table
router: Final = _router_serving("primary-id")
_serve_db_models(
monkeypatch,
RoutingPrismaWrapper(
writer=PrismaWrapper(original_prisma=writer, iam_token_db_auth=False),
reader=PrismaWrapper(original_prisma=replica, iam_token_db_auth=False),
),
router,
)
await resync_model_deployments_by_ids(("backup-id",))
assert router.registry().has_deployment("backup-id")
assert replica_table.reads == 0
@pytest.mark.asyncio
async def test_unknown_model_name_read_through_reads_the_replica_without_waiting_for_a_reload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.common_utils.registry_read_through import _resync_model_deployments
from litellm.proxy.db.prisma_client import PrismaWrapper
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper
writer: Final = MagicMock(name="writer_prisma")
writer.litellm_proxymodeltable = SimpleNamespace(find_many=AsyncMock(return_value=[]), create=AsyncMock())
replica: Final = MagicMock(name="replica_prisma")
replica.litellm_proxymodeltable = SimpleNamespace(find_many=AsyncMock(return_value=[]), create=AsyncMock())
prisma_client: Final = MagicMock()
prisma_client.db = RoutingPrismaWrapper(
writer=PrismaWrapper(original_prisma=writer, iam_token_db_auth=False),
reader=PrismaWrapper(original_prisma=replica, iam_token_db_auth=False),
)
reload_in_progress: Final = asyncio.Lock()
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
monkeypatch.setattr(proxy_server, "store_model_in_db", True)
monkeypatch.setattr(proxy_server, "llm_router", MagicMock())
monkeypatch.setattr(proxy_server, "MODEL_RECONCILE_LOCK", reload_in_progress)
async with reload_in_progress:
found: Final = await asyncio.wait_for(_resync_model_deployments("no-such-model"), timeout=2)
assert found is False
writer.litellm_proxymodeltable.find_many.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.usefixtures("stored_params_salt")
async def test_resync_model_deployments_by_ids_installs_the_row_a_concurrent_reload_left_current(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
table: Final = _ProxyModelTable((_backup_row("m-1", model="openai/gpt-before-update"),))
router: Final = _router_serving("primary-id")
_serve_db_models(monkeypatch, _Db(table), router)
reload_in_progress: Final = asyncio.Lock()
monkeypatch.setattr(proxy_server, "MODEL_RECONCILE_LOCK", reload_in_progress)
async with reload_in_progress:
targeted_load: Final = asyncio.create_task(resync_model_deployments_by_ids(("m-1",)))
for _ in range(5):
await asyncio.sleep(0)
assert not router.registry().has_deployment("m-1")
table.rows = (_backup_row("m-1", model="openai/gpt-after-update"),)
await targeted_load
installed: Final = router.registry().deployment("m-1")
assert installed is not None
assert installed.litellm_params.model == "openai/gpt-after-update"
@pytest.mark.asyncio
@pytest.mark.usefixtures("stored_params_salt")
@pytest.mark.parametrize(
("stored_credential_name", "credentials_on_this_worker", "api_key_this_worker_uses"),
[
("shared-openai", (), "sk-from-db"),
("shared-openai", ("another-openai",), "sk-from-db"),
("shared-openai", ("shared-openai",), "sk-on-this-worker"),
(None, (), None),
],
)
async def test_resync_model_deployments_by_ids_reloads_credentials_only_for_one_this_worker_lacks(
monkeypatch: pytest.MonkeyPatch,
stored_credential_name: str | None,
credentials_on_this_worker: tuple[str, ...],
api_key_this_worker_uses: str | None,
) -> None:
import litellm
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
from litellm.types.utils import CredentialItem
model_params: Final = {"model": "openai/gpt-backup"}
row: Final = _stored_model_row(
"backup-id",
model_params if stored_credential_name is None else model_params | {"litellm_credential_name": stored_credential_name},
)
stored_credential: Final = CredentialItem(
credential_name="shared-openai",
credential_values={"api_key": encrypt_value_helper("sk-from-db")},
credential_info={},
)
router: Final = _router_serving("primary-id")
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(credential_name=name, credential_values={"api_key": "sk-on-this-worker"}, credential_info={})
for name in credentials_on_this_worker
],
)
_serve_db_models(monkeypatch, _Db(_ProxyModelTable((row,)), _CredentialsTable((stored_credential,))), router)
await resync_model_deployments_by_ids(("backup-id",))
credential: Final = CredentialAccessor.find_credential("shared-openai")
assert (credential.credential_values.get("api_key") if credential else None) == api_key_this_worker_uses
assert router.registry().has_deployment("backup-id")
@pytest.mark.asyncio
@pytest.mark.usefixtures("stored_params_salt")
async def test_resync_model_deployments_by_ids_installs_the_ordinary_rows_queued_with_an_auto_router(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.common_utils.registry_read_through import resync_model_deployments_by_ids
table: Final = _ProxyModelTable(
(
_stored_model_row("auto-router-id", {"model": "auto_router/complexity-router"}),
_backup_row("backup-id"),
)
)
db: Final = _Db(table)
router: Final = _router_serving("primary-id")
_serve_db_models(monkeypatch, db, router)
await resync_model_deployments_by_ids(("auto-router-id", "backup-id"))
assert _served_ids(router.registry().deployments()) == {"primary-id", "backup-id"}
assert _served_ids(proxy_server.llm_model_list) == {"primary-id", "backup-id"}
assert table.reads == 1
assert db.other_tables_read == []
assert not proxy_server.MODEL_RECONCILE_LOCK.locked()

View file

@ -687,33 +687,152 @@ class _FakeAccessGroupBudgetTable:
return self.rows.pop(access_group_name, None)
class _FakeClock:
def __init__(self) -> None:
self.now = 0.0
self.slept: list[float] = []
def __call__(self) -> float:
return self.now
async def sleep(self, seconds: float) -> None:
self.slept.append(seconds)
self.now += seconds
class _FakeModelTable:
def __init__(self, journal: list[str], deployments) -> None:
def __init__(self, journal: list[str], deployments, failing_model_ids=frozenset()) -> None:
self.journal = journal
self.deployments = list(deployments)
self.failing_model_ids = failing_model_ids
self.updates: list[tuple[dict, dict]] = []
self.rows_locked_by_another_session: frozenset[str] = frozenset()
self.group_locked_by_another_session = False
self.edits_the_other_session_commits: dict[str, dict] = {}
self.other_session_commits_after_refusals: int | None = None
self.lock_refusals = 0
async def find_many(self, where=None, **kwargs):
return list(self.deployments)
model_names = (where or {}).get("model_name", {}).get("in")
return [d for d in self.deployments if model_names is None or d.model_name in model_names]
def lock_group(self, wait):
if not self.group_locked_by_another_session:
return True
if wait:
raise TimeoutError("lock_timeout expired waiting for the access group lock")
self._refuse_lock()
return False
def lock_rows(self, model_ids, skip_locked):
held = self.rows_locked_by_another_session.intersection(model_ids)
if not held:
return list(model_ids)
if not skip_locked:
raise TimeoutError(f"timed out waiting for the row lock on {sorted(held)}")
self._refuse_lock()
return [model_id for model_id in model_ids if model_id not in held]
def _refuse_lock(self):
self.lock_refusals += 1
if self.lock_refusals == self.other_session_commits_after_refusals:
self._other_session_commits()
def _other_session_commits(self):
self.deployments = [
_FakeDeployment(
model_id=d.model_id,
model_name=d.model_name,
model_info=self.edits_the_other_session_commits.get(d.model_id, d.model_info),
)
for d in self.deployments
]
self.rows_locked_by_another_session = frozenset()
self.group_locked_by_another_session = False
async def find_unique(self, where, include=None):
return next((d for d in self.deployments if d.model_id == where["model_id"]), None)
async def update(self, where, data, include=None):
if where["model_id"] in self.failing_model_ids:
raise RuntimeError(f"database write to {where['model_id']} failed")
self.journal.append(f"model_table.update:{where['model_id']}")
self.updates.append((dict(where), dict(data)))
return None
async def create(self, data, **kwargs):
raise AssertionError("access group endpoints never create deployments")
class _FakeModelTableBatch:
"""Queues model-table updates and applies them all or none when the batch closes, like a Prisma batch."""
def __init__(self, model_table: _FakeModelTable) -> None:
self.model_table = model_table
self.queued: list[tuple[dict, dict]] = []
self.litellm_proxymodeltable = SimpleNamespace(update=self._queue_update)
def _queue_update(self, where, data):
self.queued.append((dict(where), dict(data)))
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
if exc_type is not None:
return False
failing = [where["model_id"] for where, _ in self.queued if where["model_id"] in self.model_table.failing_model_ids]
if failing:
raise RuntimeError(f"batch write to {failing} failed, nothing applied")
for where, data in self.queued:
await self.model_table.update(where=where, data=data)
return False
class _FakeModelTableTx:
"""An interactive transaction on the primary that journals the row lock and the read it guards."""
def __init__(self, journal: list[str], model_table: _FakeModelTable) -> None:
self.journal = journal
self.model_table = model_table
self.litellm_proxymodeltable = SimpleNamespace(find_many=self._find_many)
async def query_raw(self, query, *args):
if "advisory_xact_lock" in query:
self.journal.append(f"access_group.lock:{args[0]}")
return [{"locked": self.model_table.lock_group(wait="pg_try_" not in query)}]
if "FOR UPDATE" in query:
model_ids = sorted(args[0] if args else (d.model_id for d in self.model_table.deployments))
self.journal.append(f"model_table.lock:{','.join(model_ids)}")
locked = self.model_table.lock_rows(model_ids, skip_locked="SKIP LOCKED" in query)
return [{"model_id": model_id} for model_id in locked]
self.journal.append(f"model_table.query:{query}")
return []
async def _find_many(self):
self.journal.append("model_table.read")
return await self.model_table.find_many()
def batch_(self):
return _FakeModelTableBatch(self.model_table)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class _FakePrismaClient:
def __init__(self, journal: list[str], deployments=()) -> None:
def __init__(self, journal: list[str], deployments=(), failing_model_ids=frozenset()) -> None:
self.budget_table = _FakeBudgetTable(journal)
self.access_group_budget_table = _FakeAccessGroupBudgetTable(journal, self.budget_table)
self.model_table = _FakeModelTable(journal, deployments)
self.model_table = _FakeModelTable(journal, deployments, failing_model_ids)
self.db = SimpleNamespace(
litellm_budgettable=self.budget_table,
litellm_modelaccessgroupbudgettable=self.access_group_budget_table,
litellm_proxymodeltable=self.model_table,
tx=lambda timeout=None: _FakeModelTableTx(journal, self.model_table),
)
def jsonify_object(self, data):
@ -784,6 +903,12 @@ def _proxy_with_stubbed_reload(prisma):
yield
def _serve_model_groups(router, model_groups):
router.registry.return_value.names.side_effect = lambda model_name=None, *, kind="groups", **_: (
list(model_groups) if kind == "groups" else []
)
def _eviction_journal(access_group):
"""Both auth cache keys, in the order a write path has to evict them."""
from litellm.proxy.common_utils.user_api_key_cache import (
@ -1290,3 +1415,605 @@ async def test_deleting_an_access_group_that_never_had_a_budget_still_evicts():
assert response.models_updated == 1
assert prisma.access_group_budget_table.rows == {}
_assert_evicted_after_write(journal, "prod-models", "access_group_budget.delete:prod-models")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"selection",
[{"model_ids": ["stays", "joins"]}, {"model_names": ["gpt-stays", "gpt-joins"]}],
)
async def test_updating_an_access_group_writes_only_the_deployments_that_join_or_leave_it(selection):
import json
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="leaves", model_name="gpt-leaves"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
],
)
with _proxy_with_stubbed_reload(prisma):
_serve_model_groups(proxy_server.llm_router, ["gpt-stays", "gpt-leaves", "gpt-joins"])
response = await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(**selection),
user_api_key_dict=_admin(),
)
written_access_groups = {
where["model_id"]: json.loads(data["model_info"])["access_groups"]
for where, data in prisma.model_table.updates
}
assert written_access_groups == {"joins": ["prod-models"], "leaves": []}
assert response.models_updated == 2
@pytest.mark.asyncio
@pytest.mark.parametrize(
"selection",
[{"model_ids": ["stays", "joins"]}, {"model_names": ["gpt-stays", "gpt-joins"]}],
)
async def test_updating_an_access_group_reads_the_primary_not_a_lagging_replica(selection):
import json
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.db.prisma_client import PrismaWrapper
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
primary = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="leaves", model_name="gpt-leaves"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
],
)
replica_before_the_last_edit = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="leaves", model_name="gpt-leaves", access_groups=()),
_deployment(model_id="joins", model_name="gpt-joins"),
],
)
primary.db = RoutingPrismaWrapper(
writer=PrismaWrapper(original_prisma=primary.db, iam_token_db_auth=False),
reader=PrismaWrapper(original_prisma=replica_before_the_last_edit.db, iam_token_db_auth=False),
)
with _proxy_with_stubbed_reload(primary):
_serve_model_groups(proxy_server.llm_router, ["gpt-stays", "gpt-leaves", "gpt-joins"])
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(**selection),
user_api_key_dict=_admin(),
)
written_access_groups = {
where["model_id"]: json.loads(data["model_info"])["access_groups"]
for where, data in primary.model_table.updates
}
assert written_access_groups == {"joins": ["prod-models"], "leaves": []}
@pytest.mark.asyncio
@pytest.mark.parametrize("failing_model_id", ["leaves", "joins"])
async def test_a_failed_access_group_update_leaves_every_deployment_unchanged(failing_model_id):
from fastapi import HTTPException
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
deployments = [
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="leaves", model_name="gpt-leaves"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
]
prisma = _FakePrismaClient([], deployments=deployments, failing_model_ids=frozenset({failing_model_id}))
with _proxy_with_stubbed_reload(prisma):
_serve_model_groups(proxy_server.llm_router, ["gpt-stays", "gpt-leaves", "gpt-joins"])
with pytest.raises(HTTPException) as raised:
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["stays", "joins"]),
user_api_key_dict=_admin(),
)
assert raised.value.status_code == 500
assert prisma.model_table.updates == []
@pytest.mark.asyncio
async def test_an_access_group_update_keeps_a_model_info_field_another_admin_set_while_names_were_validated():
import json
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
],
)
def another_admin_edits_joins_while_names_are_checked(model_name=None, *, kind="groups", **_):
if kind != "groups":
return []
prisma.model_table.deployments = [
deployment
if deployment.model_id != "joins"
else _FakeDeployment(
model_id="joins",
model_name="gpt-joins",
model_info={"access_groups": [], "description": "set by another admin"},
)
for deployment in prisma.model_table.deployments
]
return ["gpt-stays", "gpt-joins"]
with _proxy_with_stubbed_reload(prisma):
proxy_server.llm_router.registry.return_value.names.side_effect = (
another_admin_edits_joins_while_names_are_checked
)
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_names=["gpt-stays", "gpt-joins"]),
user_api_key_dict=_admin(),
)
written = {where["model_id"]: json.loads(data["model_info"]) for where, data in prisma.model_table.updates}
assert written["joins"]["description"] == "set by another admin"
assert written["joins"]["access_groups"] == ["prod-models"]
@pytest.mark.asyncio
async def test_an_access_group_update_locks_the_rows_it_reads_and_tells_other_workers_after_the_commit():
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
journal: list[str] = []
prisma = _FakePrismaClient(
journal,
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="leaves", model_name="gpt-leaves"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
],
)
async def record_publish(model_ids):
journal.append(f"publish:{','.join(sorted(model_ids))}")
with (
_proxy_with_stubbed_reload(prisma),
patch( # test-quality-ok: the endpoint publishes through its module-level import; Redis is the real seam
"litellm.proxy.management_endpoints.model_access_group_management_endpoints.publish_model_rows_written",
new=record_publish,
),
):
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["stays", "joins"]),
user_api_key_dict=_admin(),
)
assert journal == [
"access_group.lock:prod-models",
"model_table.read",
"model_table.lock:joins,leaves,stays",
"model_table.read",
"model_table.update:leaves",
"model_table.update:joins",
"publish:joins,leaves",
]
@pytest.mark.asyncio
async def test_an_access_group_update_that_changes_no_membership_writes_and_announces_nothing():
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
journal: list[str] = []
prisma = _FakePrismaClient(
journal,
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="also-stays", model_name="gpt-also-stays"),
_deployment(model_id="outside", model_name="gpt-outside", access_groups=()),
],
)
async def record_publish(model_ids):
journal.append(f"publish:{','.join(sorted(model_ids))}")
with (
_proxy_with_stubbed_reload(prisma),
patch( # test-quality-ok: the endpoint publishes through its module-level import; Redis is the real seam
"litellm.proxy.management_endpoints.model_access_group_management_endpoints.publish_model_rows_written",
new=record_publish,
),
):
response = await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["stays", "also-stays"]),
user_api_key_dict=_admin(),
)
assert response.models_updated == 2
assert journal == [
"access_group.lock:prod-models",
"model_table.read",
"model_table.lock:also-stays,stays",
"model_table.read",
]
@pytest.mark.asyncio
@pytest.mark.parametrize("ignored_model_names", [None, ["gpt-unrelated"]])
async def test_an_access_group_update_goes_through_while_another_session_holds_a_model_row_outside_the_group(
ignored_model_names,
):
import json
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="leaves", model_name="gpt-leaves"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
_deployment(model_id="unrelated", model_name="gpt-unrelated", access_groups=("other-group",)),
],
)
prisma.model_table.rows_locked_by_another_session = frozenset({"unrelated"})
with _proxy_with_stubbed_reload(prisma):
response = await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["stays", "joins"], model_names=ignored_model_names),
user_api_key_dict=_admin(),
)
written_access_groups = {
where["model_id"]: json.loads(data["model_info"])["access_groups"]
for where, data in prisma.model_table.updates
}
assert written_access_groups == {"joins": ["prod-models"], "leaves": []}
assert response.models_updated == 2
@pytest.mark.asyncio
async def test_an_access_group_update_retries_a_locked_row_and_keeps_the_edit_committed_there():
import json
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
],
)
prisma.model_table.rows_locked_by_another_session = frozenset({"joins"})
prisma.model_table.edits_the_other_session_commits = {
"joins": {"access_groups": [], "description": "saved while the update waited"}
}
prisma.model_table.other_session_commits_after_refusals = 1
with _proxy_with_stubbed_reload(prisma):
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["stays", "joins"]),
user_api_key_dict=_admin(),
)
written = {where["model_id"]: json.loads(data["model_info"]) for where, data in prisma.model_table.updates}
assert written == {"joins": {"access_groups": ["prod-models"], "description": "saved while the update waited"}}
@pytest.mark.asyncio
async def test_an_access_group_emptied_while_the_update_retried_a_locked_row_answers_404_and_writes_nothing():
from fastapi import HTTPException
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="leaves", model_name="gpt-leaves"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
],
)
prisma.model_table.rows_locked_by_another_session = frozenset({"leaves"})
prisma.model_table.edits_the_other_session_commits = {"leaves": {"access_groups": []}}
prisma.model_table.other_session_commits_after_refusals = 1
with _proxy_with_stubbed_reload(prisma), pytest.raises(HTTPException) as raised:
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["joins"]),
user_api_key_dict=_admin(),
)
assert raised.value.status_code == 404
assert prisma.model_table.updates == []
def _another_session_holds_a_member_row(model_table):
model_table.rows_locked_by_another_session = frozenset({"leaves"})
def _another_edit_holds_the_group(model_table):
model_table.group_locked_by_another_session = True
@pytest.mark.asyncio
@pytest.mark.parametrize(
"hold", [_another_session_holds_a_member_row, _another_edit_holds_the_group], ids=["member_row", "group"]
)
async def test_an_access_group_update_answers_409_and_writes_nothing_while_another_session_keeps_a_lock_it_needs(
hold,
):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
_rewrite_membership_once_unlocked,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="leaves", model_name="gpt-leaves"),
],
)
hold(prisma.model_table)
clock = _FakeClock()
with pytest.raises(HTTPException) as raised:
await _rewrite_membership_once_unlocked(
prisma_client=prisma,
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["stays"]),
retry_budget_seconds=0.5,
sleep=clock.sleep,
clock=clock,
)
assert raised.value.status_code == 409
assert 0 < clock.now <= 0.5
assert prisma.model_table.lock_refusals == len(clock.slept) + 1
assert prisma.model_table.updates == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"hold", [_another_session_holds_a_member_row, _another_edit_holds_the_group], ids=["member_row", "group"]
)
async def test_an_access_group_update_waits_out_a_lock_held_for_over_ten_seconds_and_writes_the_edit(hold):
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
_rewrite_membership_once_unlocked,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="leaves", model_name="gpt-leaves"),
],
)
hold(prisma.model_table)
prisma.model_table.other_session_commits_after_refusals = 20
clock = _FakeClock()
_, rewrites = await _rewrite_membership_once_unlocked(
prisma_client=prisma,
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["stays"]),
sleep=clock.sleep,
clock=clock,
)
assert clock.now > 10
assert [model_id for model_id, _ in rewrites] == ["leaves"]
assert [where["model_id"] for where, _ in prisma.model_table.updates] == ["leaves"]
@pytest.mark.asyncio
async def test_updating_a_missing_access_group_answers_404_before_checking_the_model_names():
from fastapi import HTTPException
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[], deployments=[_deployment(model_id="other", model_name="gpt-other", access_groups=("other-group",))]
)
with _proxy_with_stubbed_reload(prisma):
_serve_model_groups(proxy_server.llm_router, ["gpt-other"])
with pytest.raises(HTTPException) as raised:
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_names=["no-such-model"]),
user_api_key_dict=_admin(),
)
assert raised.value.status_code == 404
assert prisma.model_table.updates == []
@pytest.mark.asyncio
async def test_an_access_group_update_answers_500_when_the_deployments_cannot_be_read():
from fastapi import HTTPException
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient([], deployments=[_deployment(model_id="stays")])
prisma.model_table.find_many = AsyncMock(side_effect=RuntimeError("database unreachable"))
with _proxy_with_stubbed_reload(prisma), pytest.raises(HTTPException) as raised:
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["stays"]),
user_api_key_dict=_admin(),
)
assert raised.value.status_code == 500
assert prisma.model_table.updates == []
@pytest.mark.asyncio
@pytest.mark.parametrize("model_ids", [["no-such-deployment"], ["joins", "no-such-deployment"]])
async def test_updating_an_access_group_with_an_unknown_model_id_writes_no_deployment(model_ids):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[_deployment(model_id="stays"), _deployment(model_id="joins", access_groups=())],
)
with _proxy_with_stubbed_reload(prisma), pytest.raises(HTTPException) as raised:
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=model_ids),
user_api_key_dict=_admin(),
)
assert raised.value.status_code == 400
assert prisma.model_table.updates == []
@pytest.mark.asyncio
async def test_updating_an_access_group_no_deployment_carries_answers_404_and_writes_nothing():
from fastapi import HTTPException
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient([], deployments=[_deployment(model_id="other", access_groups=("other-group",))])
with _proxy_with_stubbed_reload(prisma), pytest.raises(HTTPException) as raised:
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_ids=["other"]),
user_api_key_dict=_admin(),
)
assert raised.value.status_code == 404
assert prisma.model_table.updates == []
@pytest.mark.asyncio
async def test_updating_an_access_group_with_a_model_name_only_the_config_serves_writes_no_deployment():
from fastapi import HTTPException
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
update_access_group,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
UpdateModelGroupRequest,
)
prisma = _FakePrismaClient(
[],
deployments=[
_deployment(model_id="stays", model_name="gpt-stays"),
_deployment(model_id="joins", model_name="gpt-joins", access_groups=()),
],
)
with _proxy_with_stubbed_reload(prisma):
_serve_model_groups(proxy_server.llm_router, ["gpt-stays", "gpt-joins", "config-only-model"])
with pytest.raises(HTTPException) as raised:
await update_access_group(
access_group="prod-models",
data=UpdateModelGroupRequest(model_names=["gpt-joins", "config-only-model"]),
user_api_key_dict=_admin(),
)
assert raised.value.status_code == 400
assert prisma.model_table.updates == []

View file

@ -6508,7 +6508,11 @@ class TestStrategyRouterWriteValidation:
assert fake.tx_obj.raw_calls == []
return
assert handle is fake.tx_obj.litellm_proxymodeltable
published.assert_awaited_once_with(redis_cache=None, object_type="litellm_proxymodeltable")
published.assert_awaited_once_with(
redis_cache=None,
object_type="litellm_proxymodeltable",
model_ids=(model_id,) if model_id else (),
)
(lock_sql, lock_params), (count_sql, count_params) = fake.tx_obj.raw_calls
assert "pg_advisory_xact_lock($1)" in lock_sql and "count" not in lock_sql
assert lock_params == (AUTO_ROUTER_CAPABILITY_SLOT_LOCK_KEY,)

View file

@ -699,8 +699,8 @@ export interface paths {
* @description Update an access group's model names.
*
* This will:
* 1. Remove the access group from all current deployments
* 2. Add the access group to all deployments for the new model_names list
* 1. Add the access group to all deployments for the new model_names list
* 2. Remove the access group from every other deployment
*
* Example:
* ```bash
@ -722,6 +722,7 @@ export interface paths {
* Raises:
* - HTTPException 400: If any model names don't exist
* - HTTPException 404: If access group not found
* - HTTPException 409: If another edit holds the group or one of its models for 30 s
*/
put: operations["update_access_group_access_group__access_group__update_put"];
post?: never;