mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #34861 from BerriAI/litellm_lit4872_surface_reload_drop
fix(proxy): report when a model write does not survive the post-write reload
This commit is contained in:
commit
1a6642ee2e
7 changed files with 635 additions and 142 deletions
|
|
@ -6,6 +6,7 @@ Endpoints here:
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
|
@ -16,6 +17,9 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|||
|
||||
# Clear cache and reload models to pick up the access group changes
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
live_model_ids_snapshot,
|
||||
model_info_as_mapping,
|
||||
reload_serving_verdict,
|
||||
clear_cache,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
|
@ -72,11 +76,92 @@ def add_access_group_to_deployment(model_info: Dict[str, Any], access_group: str
|
|||
return model_info, True
|
||||
|
||||
|
||||
def _raise_http_if_reload_degraded_serving(
|
||||
before: frozenset[str],
|
||||
written_models: Sequence[tuple[str, object]],
|
||||
access_group: str,
|
||||
) -> None:
|
||||
"""Same verdict as the model-write endpoints, expressed through this file's
|
||||
HTTPException error convention, with the metadata-only obligation: these writes
|
||||
change group membership, not the models themselves, so a row that was already not
|
||||
serving before the reload is never blamed here; only a model this reload stopped
|
||||
serving is reported."""
|
||||
missing, collateral = reload_serving_verdict(before=before, written_models=written_models, written_must_serve=False)
|
||||
gone = tuple(dict.fromkeys((*missing, *collateral)))
|
||||
if not gone:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"error": (
|
||||
f"Access group '{access_group}' was saved to the database, but model id(s) {list(gone)} that "
|
||||
"this pod was serving are no longer live after the reload it triggered. Other pods reload on "
|
||||
"their own interval. Check server logs for 'Error upserting deployment' for the cause."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _tag_deployment_with_access_group(
|
||||
model_id: str,
|
||||
model_info: object,
|
||||
access_group: str,
|
||||
prisma_client: PrismaClient,
|
||||
) -> tuple[str, Mapping[str, object]] | None:
|
||||
"""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,
|
||||
)
|
||||
if not was_modified:
|
||||
return None
|
||||
await ModelRepository(prisma_client).table.update(
|
||||
where={"model_id": model_id},
|
||||
data={"model_info": json.dumps(updated_model_info)},
|
||||
)
|
||||
verbose_proxy_logger.debug(f"Updated deployment {model_id} with access group: {access_group}")
|
||||
return (model_id, updated_model_info)
|
||||
|
||||
|
||||
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
|
||||
absent value stays a legitimate empty start."""
|
||||
parsed = model_info_as_mapping(model_info)
|
||||
if parsed is None and model_info is not None:
|
||||
raise ValueError(f"model_info for deployment {model_id} is not a readable JSON object; refusing to rewrite it")
|
||||
return parsed or {}
|
||||
|
||||
|
||||
async def _strip_access_group_from_deployment(
|
||||
model_id: str,
|
||||
model_info: object,
|
||||
access_group: str,
|
||||
prisma_client: PrismaClient,
|
||||
) -> tuple[str, Mapping[str, object]] | None:
|
||||
"""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,
|
||||
)
|
||||
if not was_modified:
|
||||
return None
|
||||
await ModelRepository(prisma_client).table.update(
|
||||
where={"model_id": model_id},
|
||||
data={"model_info": json.dumps(updated_model_info)},
|
||||
)
|
||||
return (model_id, updated_model_info)
|
||||
|
||||
|
||||
async def update_deployments_with_access_group(
|
||||
model_names: List[str],
|
||||
access_group: str,
|
||||
prisma_client: PrismaClient,
|
||||
) -> int:
|
||||
) -> tuple[tuple[str, Mapping[str, object]], ...]:
|
||||
"""
|
||||
Update all deployments for the given model names to include the access group.
|
||||
|
||||
|
|
@ -86,20 +171,15 @@ async def update_deployments_with_access_group(
|
|||
prisma_client: Database client
|
||||
|
||||
Returns:
|
||||
int: Number of deployments updated
|
||||
The (model_id, updated model_info) pair of every deployment actually written,
|
||||
so callers can verify each one survived the post-write reload
|
||||
"""
|
||||
models_updated = 0
|
||||
deployments = await ModelRepository(prisma_client).table.find_many(where={"model_name": {"in": model_names}})
|
||||
verbose_proxy_logger.debug(f"Found {len(deployments)} deployments for model_names: {model_names}")
|
||||
|
||||
found_names = {deployment.model_name for deployment in deployments}
|
||||
for model_name in model_names:
|
||||
verbose_proxy_logger.debug(f"Updating deployments for model_name: {model_name}")
|
||||
|
||||
# Get all deployments with this model_name
|
||||
deployments = await ModelRepository(prisma_client).table.find_many(where={"model_name": model_name})
|
||||
|
||||
verbose_proxy_logger.debug(f"Found {len(deployments)} deployments for model_name: {model_name}")
|
||||
|
||||
# If no deployments found, this is a config model (not in DB)
|
||||
if len(deployments) == 0:
|
||||
if model_name not in found_names:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
|
|
@ -107,65 +187,52 @@ async def update_deployments_with_access_group(
|
|||
},
|
||||
)
|
||||
|
||||
# Update each deployment
|
||||
for deployment in deployments:
|
||||
model_info = deployment.model_info or {}
|
||||
|
||||
# Add access group using helper
|
||||
updated_model_info, was_modified = add_access_group_to_deployment(
|
||||
model_info=model_info,
|
||||
access_group=access_group,
|
||||
)
|
||||
|
||||
# Only update in DB if modified
|
||||
if was_modified:
|
||||
await ModelRepository(prisma_client).table.update(
|
||||
where={"model_id": deployment.model_id},
|
||||
data={"model_info": json.dumps(updated_model_info)},
|
||||
)
|
||||
|
||||
models_updated += 1
|
||||
verbose_proxy_logger.debug(
|
||||
f"Updated deployment {deployment.model_id} with access group: {access_group}"
|
||||
)
|
||||
|
||||
return models_updated
|
||||
tagged = [
|
||||
await _tag_deployment_with_access_group(
|
||||
model_id=deployment.model_id,
|
||||
model_info=deployment.model_info,
|
||||
access_group=access_group,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
for deployment in deployments
|
||||
]
|
||||
return tuple(pair for pair in tagged if pair is not None)
|
||||
|
||||
|
||||
async def update_specific_deployments_with_access_group(
|
||||
model_ids: List[str],
|
||||
access_group: str,
|
||||
prisma_client: PrismaClient,
|
||||
) -> int:
|
||||
) -> tuple[tuple[str, Mapping[str, object]], ...]:
|
||||
"""
|
||||
Update specific deployments (by model_id) to include the access group.
|
||||
|
||||
Unlike update_deployments_with_access_group which tags ALL deployments sharing
|
||||
a model_name, this function only tags the specific deployments identified by
|
||||
their unique model_id.
|
||||
their unique model_id. Returns the (model_id, updated model_info) pair of every
|
||||
deployment actually written.
|
||||
"""
|
||||
models_updated = 0
|
||||
for model_id in model_ids:
|
||||
verbose_proxy_logger.debug(f"Updating specific deployment model_id: {model_id}")
|
||||
deployment = await ModelRepository(prisma_client).table.find_unique(where={"model_id": model_id})
|
||||
if deployment is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": f"Deployment with model_id '{model_id}' not found in Database."},
|
||||
)
|
||||
model_info = deployment.model_info or {}
|
||||
updated_model_info, was_modified = add_access_group_to_deployment(
|
||||
model_info=model_info,
|
||||
verbose_proxy_logger.debug(f"Updating specific deployment model_ids: {model_ids}")
|
||||
tagged = [
|
||||
await _tag_deployment_with_access_group(
|
||||
model_id=model_id,
|
||||
model_info=(await _find_deployment_or_400(model_id=model_id, prisma_client=prisma_client)),
|
||||
access_group=access_group,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
if was_modified:
|
||||
await ModelRepository(prisma_client).table.update(
|
||||
where={"model_id": model_id},
|
||||
data={"model_info": json.dumps(updated_model_info)},
|
||||
)
|
||||
models_updated += 1
|
||||
verbose_proxy_logger.debug(f"Updated deployment {model_id} with access group: {access_group}")
|
||||
return models_updated
|
||||
for model_id in model_ids
|
||||
]
|
||||
return tuple(pair for pair in tagged if pair is not None)
|
||||
|
||||
|
||||
async def _find_deployment_or_400(model_id: str, prisma_client: PrismaClient) -> Mapping[str, object] | None:
|
||||
deployment = await ModelRepository(prisma_client).table.find_unique(where={"model_id": model_id})
|
||||
if deployment is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": f"Deployment with model_id '{model_id}' not found in Database."},
|
||||
)
|
||||
return deployment.model_info
|
||||
|
||||
|
||||
def remove_access_group_from_deployment(model_info: Dict[str, Any], access_group: str) -> Tuple[Dict[str, Any], bool]:
|
||||
|
|
@ -335,20 +402,28 @@ async def create_model_group(
|
|||
# Update deployments using the appropriate method
|
||||
if use_model_ids:
|
||||
assert data.model_ids is not None
|
||||
models_updated = await update_specific_deployments_with_access_group(
|
||||
updated_pairs = await update_specific_deployments_with_access_group(
|
||||
model_ids=data.model_ids,
|
||||
access_group=data.access_group,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
else:
|
||||
assert data.model_names is not None
|
||||
models_updated = await update_deployments_with_access_group(
|
||||
updated_pairs = await update_deployments_with_access_group(
|
||||
model_names=data.model_names,
|
||||
access_group=data.access_group,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
models_updated = len(updated_pairs)
|
||||
|
||||
live_before_reload = live_model_ids_snapshot()
|
||||
|
||||
await clear_cache()
|
||||
_raise_http_if_reload_degraded_serving(
|
||||
before=live_before_reload,
|
||||
written_models=updated_pairs,
|
||||
access_group=data.access_group,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"Successfully created access group '{data.access_group}' with {models_updated} models updated"
|
||||
|
|
@ -573,38 +648,42 @@ async def update_access_group(
|
|||
# Step 1: Remove access group from ALL DB deployments (skip config models)
|
||||
all_deployments = await ModelRepository(prisma_client).table.find_many()
|
||||
|
||||
for deployment in all_deployments:
|
||||
model_info = deployment.model_info or {}
|
||||
|
||||
updated_model_info, was_modified = remove_access_group_from_deployment(
|
||||
model_info=model_info,
|
||||
stripped = [
|
||||
await _strip_access_group_from_deployment(
|
||||
model_id=deployment.model_id,
|
||||
model_info=deployment.model_info,
|
||||
access_group=access_group,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
if was_modified:
|
||||
await ModelRepository(prisma_client).table.update(
|
||||
where={"model_id": deployment.model_id},
|
||||
data={"model_info": json.dumps(updated_model_info)},
|
||||
)
|
||||
for deployment in all_deployments
|
||||
]
|
||||
stripped_pairs = 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
|
||||
models_updated = await update_specific_deployments_with_access_group(
|
||||
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
|
||||
models_updated = await update_deployments_with_access_group(
|
||||
updated_pairs = await update_deployments_with_access_group(
|
||||
model_names=data.model_names,
|
||||
access_group=access_group,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
models_updated = len(updated_pairs)
|
||||
|
||||
# Clear cache and reload models to pick up the access group changes
|
||||
live_before_reload = live_model_ids_snapshot()
|
||||
await clear_cache()
|
||||
_raise_http_if_reload_degraded_serving(
|
||||
before=live_before_reload,
|
||||
written_models=list({**dict(stripped_pairs), **dict(updated_pairs)}.items()),
|
||||
access_group=access_group,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"Successfully updated access group '{access_group}' with {models_updated} models updated"
|
||||
|
|
@ -686,25 +765,27 @@ async def delete_access_group(
|
|||
try:
|
||||
# Remove access group from all DB deployments (skip config models)
|
||||
all_deployments = await ModelRepository(prisma_client).table.find_many()
|
||||
models_updated = 0
|
||||
|
||||
for deployment in all_deployments:
|
||||
model_info = deployment.model_info or {}
|
||||
|
||||
updated_model_info, was_modified = remove_access_group_from_deployment(
|
||||
model_info=model_info,
|
||||
removed = [
|
||||
await _strip_access_group_from_deployment(
|
||||
model_id=deployment.model_id,
|
||||
model_info=deployment.model_info,
|
||||
access_group=access_group,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
if was_modified:
|
||||
await ModelRepository(prisma_client).table.update(
|
||||
where={"model_id": deployment.model_id},
|
||||
data={"model_info": json.dumps(updated_model_info)},
|
||||
)
|
||||
models_updated += 1
|
||||
for deployment in all_deployments
|
||||
]
|
||||
removed_pairs = tuple(pair for pair in removed if pair is not None)
|
||||
models_updated = len(removed_pairs)
|
||||
|
||||
# Clear cache and reload models to pick up the access group changes
|
||||
live_before_reload = live_model_ids_snapshot()
|
||||
await clear_cache()
|
||||
_raise_http_if_reload_degraded_serving(
|
||||
before=live_before_reload,
|
||||
written_models=removed_pairs,
|
||||
access_group=access_group,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"Successfully deleted access group '{access_group}' from {models_updated} deployments"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ model/{model_id}/update - PATCH endpoint for model update.
|
|||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
|
||||
|
|
@ -273,6 +274,7 @@ async def patch_model(
|
|||
)
|
||||
|
||||
# Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates)
|
||||
live_before_reload = live_model_ids_snapshot()
|
||||
await clear_cache()
|
||||
|
||||
## CREATE AUDIT LOG ##
|
||||
|
|
@ -289,6 +291,12 @@ async def patch_model(
|
|||
)
|
||||
)
|
||||
|
||||
raise_if_reload_degraded_serving(
|
||||
before=live_before_reload,
|
||||
written_models=[(model_id, getattr(updated_model, "model_info", None))],
|
||||
action="update",
|
||||
)
|
||||
|
||||
return updated_model
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -371,6 +379,7 @@ async def _set_model_blocked_status(
|
|||
},
|
||||
)
|
||||
|
||||
live_before_reload = live_model_ids_snapshot()
|
||||
await clear_cache()
|
||||
|
||||
asyncio.create_task(
|
||||
|
|
@ -388,6 +397,12 @@ async def _set_model_blocked_status(
|
|||
)
|
||||
)
|
||||
|
||||
raise_if_reload_degraded_serving(
|
||||
before=live_before_reload,
|
||||
written_models=[(data.model_id, getattr(updated_model, "model_info", None))],
|
||||
action=action,
|
||||
)
|
||||
|
||||
return updated_model
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -714,13 +729,8 @@ async def _get_team_deployments(
|
|||
# Confirm team_id in model_info (defensive check)
|
||||
result = []
|
||||
for row in response:
|
||||
model_info = row.model_info
|
||||
if isinstance(model_info, str):
|
||||
try:
|
||||
model_info = json.loads(model_info)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if isinstance(model_info, dict) and model_info.get("team_id") == team_id:
|
||||
model_info = model_info_as_mapping(row.model_info)
|
||||
if model_info is not None and model_info.get("team_id") == team_id:
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
|
@ -771,13 +781,8 @@ async def _get_team_public_model_names(
|
|||
deployments = await _get_team_deployments(team_id, prisma_client)
|
||||
public_names: Set[str] = set()
|
||||
for row in deployments:
|
||||
model_info = row.model_info
|
||||
if isinstance(model_info, str):
|
||||
try:
|
||||
model_info = json.loads(model_info)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if isinstance(model_info, dict):
|
||||
model_info = model_info_as_mapping(row.model_info)
|
||||
if model_info is not None:
|
||||
public_name = model_info.get("team_public_model_name")
|
||||
if public_name:
|
||||
public_names.add(public_name)
|
||||
|
|
@ -879,18 +884,11 @@ async def _update_existing_team_model_assignment(
|
|||
def _get_team_public_model_name(
|
||||
model_info: Optional[Union[dict, str]],
|
||||
) -> Optional[str]:
|
||||
if isinstance(model_info, dict):
|
||||
value = model_info.get("team_public_model_name")
|
||||
return value if isinstance(value, str) else None
|
||||
if isinstance(model_info, str):
|
||||
try:
|
||||
parsed = json.loads(model_info)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if isinstance(parsed, dict):
|
||||
value = parsed.get("team_public_model_name")
|
||||
return value if isinstance(value, str) else None
|
||||
return None
|
||||
parsed = model_info_as_mapping(model_info)
|
||||
if parsed is None:
|
||||
return None
|
||||
value = parsed.get("team_public_model_name")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
old_public_name = db_model.model_info.team_public_model_name if db_model.model_info else None
|
||||
|
||||
|
|
@ -1302,6 +1300,7 @@ async def add_new_model(
|
|||
- store keys separately
|
||||
"""
|
||||
|
||||
live_before_reload = live_model_ids_snapshot()
|
||||
try:
|
||||
_original_litellm_model_name = model_params.model_name
|
||||
if model_params.model_info.team_id is None:
|
||||
|
|
@ -1357,6 +1356,12 @@ async def add_new_model(
|
|||
)
|
||||
)
|
||||
|
||||
raise_if_reload_degraded_serving(
|
||||
before=live_before_reload,
|
||||
written_models=[(model_response.model_id, getattr(model_response, "model_info", None))],
|
||||
action="create",
|
||||
)
|
||||
|
||||
return model_response
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1477,8 +1482,8 @@ async def update_model(
|
|||
)
|
||||
|
||||
# Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates)
|
||||
live_before_reload = live_model_ids_snapshot()
|
||||
await clear_cache()
|
||||
|
||||
## CREATE AUDIT LOG ##
|
||||
asyncio.create_task(
|
||||
create_object_audit_log(
|
||||
|
|
@ -1501,6 +1506,12 @@ async def update_model(
|
|||
)
|
||||
)
|
||||
|
||||
raise_if_reload_degraded_serving(
|
||||
before=live_before_reload,
|
||||
written_models=[(_model_id, getattr(model_response, "model_info", None))],
|
||||
action="update",
|
||||
)
|
||||
|
||||
return model_response
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
|
|
@ -1704,6 +1715,114 @@ def _deduplicate_litellm_router_models(models: List[Dict]) -> List[Dict]:
|
|||
return unique_models
|
||||
|
||||
|
||||
def model_info_as_mapping(model_info: object) -> Mapping[str, object] | None:
|
||||
"""A DB row's model_info column arrives as a dict or as its JSON string depending on
|
||||
the query path, and every consumer needs the mapping. Single owner of that parse:
|
||||
returns None when no usable mapping exists (None, an unparseable string, or JSON
|
||||
that is not an object), and callers choose what None means for them."""
|
||||
if isinstance(model_info, Mapping):
|
||||
return model_info
|
||||
if not isinstance(model_info, str):
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(model_info)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, Mapping) else None
|
||||
|
||||
|
||||
def _expects_liveness_on_this_pod(model_info: object) -> bool:
|
||||
from litellm.router import model_info_is_active_for_environment
|
||||
|
||||
try:
|
||||
return model_info_is_active_for_environment(model_info=model_info_as_mapping(model_info))
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
def live_model_ids_snapshot() -> frozenset[str]:
|
||||
"""The ids this pod's router is currently serving, read fresh from the module global
|
||||
because a reload can rebind it. The empirical ground truth every verdict below is
|
||||
computed from; an absent router serves nothing."""
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
if llm_router is None:
|
||||
return frozenset()
|
||||
return frozenset(llm_router.get_model_ids())
|
||||
|
||||
|
||||
def reload_serving_verdict(
|
||||
before: frozenset[str],
|
||||
written_models: Sequence[tuple[str, object]],
|
||||
written_must_serve: bool,
|
||||
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""Judge a write-triggered reload by diffing the router's serving state instead of
|
||||
trusting any layer of the reload stack to report its own failure.
|
||||
|
||||
The full cell matrix, per id:
|
||||
- written, must-serve (the write's purpose is this model's serving state): live now
|
||||
is fine; not live is reported unless the row is deliberately inactive for this
|
||||
pod's LITELLM_ENVIRONMENT; a row whose model_info cannot be read counts as
|
||||
expecting to serve, so its drop is still reported
|
||||
- written, metadata-only (must_not_degrade): live before and gone now is reported;
|
||||
a row that was already not serving stays silent, because its deadness predates
|
||||
this write and blaming it would block unrelated metadata fixes
|
||||
- not written but live before and gone now: collateral degradation of this pod
|
||||
caused by the reload this request triggered (a wholesale re-add failure, or a
|
||||
newly introduced conflict), always reported
|
||||
|
||||
Returns (written ids violating their obligation, collateral ids no longer served).
|
||||
Best effort under concurrent admin writes: the snapshot spans only this request.
|
||||
"""
|
||||
now = live_model_ids_snapshot()
|
||||
written_ids = frozenset(model_id for model_id, _ in written_models)
|
||||
if written_must_serve:
|
||||
missing = tuple(
|
||||
model_id
|
||||
for model_id, model_info in written_models
|
||||
if model_id not in now and _expects_liveness_on_this_pod(model_info)
|
||||
)
|
||||
else:
|
||||
missing = tuple(model_id for model_id, _ in written_models if model_id in before and model_id not in now)
|
||||
collateral = tuple(sorted(before - now - written_ids))
|
||||
return (missing, collateral)
|
||||
|
||||
|
||||
def raise_if_reload_degraded_serving(
|
||||
before: frozenset[str],
|
||||
written_models: Sequence[tuple[str, object]],
|
||||
action: str,
|
||||
) -> None:
|
||||
"""The caller-visible error this pod's model-write endpoints owe their caller when
|
||||
the model they wrote is not being served after the reload they triggered. The DB
|
||||
write is durable either way and every other pod reloads on its own interval; this
|
||||
speaks only for the handling pod."""
|
||||
missing, collateral = reload_serving_verdict(before=before, written_models=written_models, written_must_serve=True)
|
||||
if not missing and not collateral:
|
||||
return
|
||||
missing_clause = (
|
||||
f"the model id(s) {list(missing)} are not live in this pod's router after the reload and are not "
|
||||
"being served by this pod."
|
||||
if missing
|
||||
else "the reload it triggered degraded this pod's serving state."
|
||||
)
|
||||
collateral_clause = (
|
||||
f" Previously served model id(s) {list(collateral)} are also no longer being served by this pod."
|
||||
if collateral
|
||||
else ""
|
||||
)
|
||||
raise ProxyException(
|
||||
message=(
|
||||
f"Model {action} was saved to the database, but {missing_clause}{collateral_clause} "
|
||||
"Other pods reload on their own interval. Check server logs for 'Error upserting deployment' or "
|
||||
"'Error creating deployment' for the cause."
|
||||
),
|
||||
type=ProxyErrorTypes.internal_server_error,
|
||||
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
param=None,
|
||||
)
|
||||
|
||||
|
||||
async def clear_cache():
|
||||
"""
|
||||
Clear router caches and reload models.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import threading
|
|||
import time
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from functools import lru_cache
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
@ -270,6 +271,43 @@ def _cost_value_as_float(value: Union[str, int, float, None]) -> Optional[float]
|
|||
return None
|
||||
|
||||
|
||||
def model_info_is_active_for_environment(model_info: Mapping[str, object] | None) -> bool:
|
||||
"""Single owner of the environment-gating rule: a deployment whose model_info names
|
||||
`supported_environments` loads only on pods whose LITELLM_ENVIRONMENT is in that list.
|
||||
`Router.deployment_is_active_for_environment` delegates here, and the model-write
|
||||
endpoints consult the same rule to tell a deliberately inactive model from one that
|
||||
was dropped by a failed reload."""
|
||||
if model_info is None:
|
||||
return True
|
||||
supported_environments = model_info.get("supported_environments")
|
||||
if supported_environments is None:
|
||||
return True
|
||||
if not isinstance(supported_environments, (list, tuple)):
|
||||
raise ValueError(
|
||||
f"supported_environments must be a list of {VALID_LITELLM_ENVIRONMENTS}. "
|
||||
f"but set as: {supported_environments} for model_info: {model_info}"
|
||||
)
|
||||
litellm_environment = get_secret_str(secret_name="LITELLM_ENVIRONMENT")
|
||||
if litellm_environment is None:
|
||||
raise ValueError("Set 'supported_environments' for model but not 'LITELLM_ENVIRONMENT' set in .env")
|
||||
|
||||
if litellm_environment not in VALID_LITELLM_ENVIRONMENTS:
|
||||
raise ValueError(
|
||||
f"LITELLM_ENVIRONMENT must be one of {VALID_LITELLM_ENVIRONMENTS}. but set as: {litellm_environment}"
|
||||
)
|
||||
|
||||
for _env in supported_environments:
|
||||
if _env not in VALID_LITELLM_ENVIRONMENTS:
|
||||
raise ValueError(
|
||||
f"supported_environments must be one of {VALID_LITELLM_ENVIRONMENTS}. but set as: {_env} "
|
||||
f"for model_info: {model_info}"
|
||||
)
|
||||
|
||||
if litellm_environment in supported_environments:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_PreRoutingStrategyT = TypeVar("_PreRoutingStrategyT")
|
||||
|
||||
|
||||
|
|
@ -7982,30 +8020,7 @@ class Router:
|
|||
- ValueError: If LITELLM_ENVIRONMENT is not set in .env or not one of the valid values
|
||||
- ValueError: If supported_environments is not set in model_info or not one of the valid values
|
||||
"""
|
||||
if (
|
||||
deployment.model_info is None
|
||||
or "supported_environments" not in deployment.model_info
|
||||
or deployment.model_info["supported_environments"] is None
|
||||
):
|
||||
return True
|
||||
litellm_environment = get_secret_str(secret_name="LITELLM_ENVIRONMENT")
|
||||
if litellm_environment is None:
|
||||
raise ValueError("Set 'supported_environments' for model but not 'LITELLM_ENVIRONMENT' set in .env")
|
||||
|
||||
if litellm_environment not in VALID_LITELLM_ENVIRONMENTS:
|
||||
raise ValueError(
|
||||
f"LITELLM_ENVIRONMENT must be one of {VALID_LITELLM_ENVIRONMENTS}. but set as: {litellm_environment}"
|
||||
)
|
||||
|
||||
for _env in deployment.model_info["supported_environments"]:
|
||||
if _env not in VALID_LITELLM_ENVIRONMENTS:
|
||||
raise ValueError(
|
||||
f"supported_environments must be one of {VALID_LITELLM_ENVIRONMENTS}. but set as: {_env} for deployment: {deployment}"
|
||||
)
|
||||
|
||||
if litellm_environment in deployment.model_info["supported_environments"]:
|
||||
return True
|
||||
return False
|
||||
return model_info_is_active_for_environment(model_info=deployment.model_info)
|
||||
|
||||
def set_model_list(self, model_list: list):
|
||||
original_model_list = copy.deepcopy(model_list)
|
||||
|
|
|
|||
|
|
@ -160,7 +160,9 @@ async def test_create_access_group_with_model_names_tags_all_deployments():
|
|||
{
|
||||
"model_name": "gpt-4o",
|
||||
"litellm_params": {"model": "gpt-4o", "api_key": "fake-key"},
|
||||
"model_info": {"id": deployment_id, "db_model": True},
|
||||
}
|
||||
for deployment_id in ("deploy-A", "deploy-B", "deploy-C")
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -318,3 +320,113 @@ async def test_create_access_group_invalid_model_id_returns_400():
|
|||
await create_model_group(data=request_data, user_api_key_dict=mock_user)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "non-existent-id" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_access_group_surfaces_dropped_models():
|
||||
"""An access-group write whose reload does not leave the tagged models live on this
|
||||
pod must report the drop through this file's HTTPException contract, not a 200."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
create_model_group,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
NewModelGroupRequest,
|
||||
)
|
||||
|
||||
deploy_a = MagicMock(model_id="deploy-A", model_name="gpt-4o", model_info={})
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=deploy_a)
|
||||
mock_prisma.db.litellm_proxymodeltable.update = AsyncMock()
|
||||
|
||||
mock_user = UserAPIKeyAuth(user_id="test_admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
wiped_router = MagicMock()
|
||||
wiped_router.get_model_ids.side_effect = [["deploy-A"], []]
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", wiped_router),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.model_access_group_management_endpoints.clear_cache",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_model_group(
|
||||
data=NewModelGroupRequest(access_group="production-models", model_ids=["deploy-A"]),
|
||||
user_api_key_dict=mock_user,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "deploy-A" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_deployment_parses_string_model_info_and_refuses_corrupt():
|
||||
"""The model_info column can arrive as its JSON string; tagging must parse it rather
|
||||
than crash, and must refuse to rewrite a present-but-unreadable value."""
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
_tag_deployment_with_access_group,
|
||||
)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_proxymodeltable.update = AsyncMock()
|
||||
|
||||
pair = await _tag_deployment_with_access_group(
|
||||
model_id="deploy-str",
|
||||
model_info='{"access_groups": ["existing"]}',
|
||||
access_group="new-group",
|
||||
prisma_client=mock_prisma,
|
||||
)
|
||||
assert pair is not None
|
||||
assert pair[0] == "deploy-str"
|
||||
assert pair[1]["access_groups"] == ["existing", "new-group"]
|
||||
|
||||
with pytest.raises(ValueError, match="deploy-corrupt"):
|
||||
await _tag_deployment_with_access_group(
|
||||
model_id="deploy-corrupt",
|
||||
model_info="{not json",
|
||||
access_group="new-group",
|
||||
prisma_client=mock_prisma,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_access_group_ignores_models_that_were_already_dead():
|
||||
"""A metadata-only strip over a model this pod never served must not fail the write;
|
||||
the model's deadness predates the request, and blaming it here would make a broken
|
||||
model block every access-group fix that touches it."""
|
||||
deploy_broken = MagicMock(
|
||||
model_id="deploy-broken", model_name="broken-model", model_info={"access_groups": ["doomed-group"]}
|
||||
)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[deploy_broken])
|
||||
mock_prisma.db.litellm_proxymodeltable.update = AsyncMock()
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
delete_access_group,
|
||||
)
|
||||
|
||||
never_served_router = MagicMock()
|
||||
never_served_router.get_model_ids.return_value = []
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", never_served_router),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.model_access_group_management_endpoints.clear_cache",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
response = await delete_access_group(
|
||||
access_group="doomed-group",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
assert response.models_updated == 1
|
||||
mock_prisma.db.litellm_proxymodeltable.update.assert_awaited_once()
|
||||
|
|
|
|||
|
|
@ -819,6 +819,7 @@ class TestUpdateModel:
|
|||
)
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_ids.return_value = [model_id]
|
||||
admin_user = UserAPIKeyAuth(
|
||||
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
|
|
@ -838,7 +839,7 @@ class TestUpdateModel:
|
|||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.model_management_endpoints.clear_cache",
|
||||
new=AsyncMock(return_value=None),
|
||||
new=AsyncMock(return_value=True),
|
||||
) as mock_clear_cache,
|
||||
):
|
||||
await update_model(
|
||||
|
|
@ -1888,6 +1889,7 @@ class TestAddAndDeleteModelLifecycle:
|
|||
|
||||
mock_router = MagicMock()
|
||||
mock_router.delete_deployment = MagicMock()
|
||||
mock_router.get_model_ids.return_value = [model_id]
|
||||
|
||||
_PS = "litellm.proxy.proxy_server"
|
||||
_ENCRYPT = "litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper"
|
||||
|
|
@ -3166,7 +3168,7 @@ class TestPatchModelBlockedAuthGate:
|
|||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock(**{"get_model_ids.return_value": ["m1"]})),
|
||||
patch("litellm.proxy.proxy_server.store_model_in_db", True),
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch(
|
||||
|
|
@ -3212,7 +3214,7 @@ class TestPatchModelBlockedAuthGate:
|
|||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.llm_router", MagicMock(**{"get_model_ids.return_value": ["m1"]})),
|
||||
patch("litellm.proxy.proxy_server.store_model_in_db", True),
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch(
|
||||
|
|
@ -3221,7 +3223,7 @@ class TestPatchModelBlockedAuthGate:
|
|||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.model_management_endpoints.clear_cache",
|
||||
new=AsyncMock(return_value=None),
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
result = await patch_model(
|
||||
|
|
@ -3231,3 +3233,100 @@ class TestPatchModelBlockedAuthGate:
|
|||
)
|
||||
assert result is updated_row
|
||||
mock_prisma.db.litellm_proxymodeltable.update.assert_awaited_once()
|
||||
|
||||
|
||||
class TestWriteSurfacesReloadDrop:
|
||||
"""A model-write endpoint may report success only if every row it wrote is, after the
|
||||
reload it triggered, live in this pod's router or deliberately environment-inactive."""
|
||||
|
||||
def test_reload_serving_verdict_matrix(self, monkeypatch):
|
||||
import litellm
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
reload_serving_verdict,
|
||||
)
|
||||
|
||||
live_router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"litellm_params": {"model": "gpt-4o"},
|
||||
"model_info": {"id": "m-live", "db_model": True},
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", live_router)
|
||||
monkeypatch.setenv("LITELLM_ENVIRONMENT", "development")
|
||||
|
||||
written = [
|
||||
("m-live", {"id": "m-live"}),
|
||||
("m-gone", {"id": "m-gone"}),
|
||||
("m-env", {"id": "m-env", "supported_environments": ["production"]}),
|
||||
("m-env-str", '{"id": "m-env-str", "supported_environments": ["production"]}'),
|
||||
("m-env-misconfigured", {"id": "m-env-misconfigured", "supported_environments": ["bogus"]}),
|
||||
("m-corrupt", "{not json"),
|
||||
]
|
||||
missing, collateral = reload_serving_verdict(
|
||||
before=frozenset({"m-live", "m-collateral"}), written_models=written, written_must_serve=True
|
||||
)
|
||||
assert missing == ("m-gone", "m-env-misconfigured", "m-corrupt")
|
||||
assert collateral == ("m-collateral",)
|
||||
|
||||
missing, collateral = reload_serving_verdict(
|
||||
before=frozenset({"m-live", "m-was-live"}),
|
||||
written_models=[("m-live", None), ("m-was-live", None), ("m-never-lived", None)],
|
||||
written_must_serve=False,
|
||||
)
|
||||
assert missing == ("m-was-live",)
|
||||
assert collateral == ()
|
||||
|
||||
def test_raise_if_reload_degraded_serving_contract(self, monkeypatch):
|
||||
import litellm
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
raise_if_reload_degraded_serving,
|
||||
)
|
||||
|
||||
live_router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"litellm_params": {"model": "gpt-4o"},
|
||||
"model_info": {"id": "m-live", "db_model": True},
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", live_router)
|
||||
|
||||
assert (
|
||||
raise_if_reload_degraded_serving(
|
||||
before=frozenset({"m-live"}), written_models=[("m-live", None)], action="update"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException, match="m-gone"):
|
||||
raise_if_reload_degraded_serving(
|
||||
before=frozenset(), written_models=[("m-gone", None)], action="update"
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException, match="m-collateral"):
|
||||
raise_if_reload_degraded_serving(
|
||||
before=frozenset({"m-live", "m-collateral"}), written_models=[("m-live", None)], action="update"
|
||||
)
|
||||
|
||||
|
||||
class TestModelInfoAsMapping:
|
||||
"""The model_info column reaches consumers as a dict or as its JSON string; this is
|
||||
the single owner of that parse, and None means no usable mapping."""
|
||||
|
||||
def test_contract(self):
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
model_info_as_mapping,
|
||||
)
|
||||
|
||||
assert model_info_as_mapping({"id": "m1"}) == {"id": "m1"}
|
||||
assert model_info_as_mapping('{"id": "m1"}') == {"id": "m1"}
|
||||
assert model_info_as_mapping(None) is None
|
||||
assert model_info_as_mapping("{not json") is None
|
||||
assert model_info_as_mapping('["a", "b"]') is None
|
||||
assert model_info_as_mapping(42) is None
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ def _setup_model_block_mocks(monkeypatch, *, updated_blocked: bool):
|
|||
mock_prisma_client.db.litellm_proxymodeltable = model_table
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_deployment.return_value = None
|
||||
mock_router.get_model_ids.return_value = [model_id]
|
||||
|
||||
mock_clear_cache = AsyncMock(return_value=None)
|
||||
mock_clear_cache = AsyncMock(return_value=True)
|
||||
mock_audit_log = AsyncMock(return_value=None)
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
|
@ -197,3 +197,51 @@ async def test_route_request_returns_403_when_model_is_fully_blocked(monkeypatch
|
|||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Model is blocked" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_block_surfaces_wholesale_reload_failure(monkeypatch):
|
||||
"""The write endpoints owe the caller an error when the pod failed to reload at all;
|
||||
the DB row is saved but this pod is not serving the change."""
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import block_model
|
||||
|
||||
model_id, model_table, updated_row, mock_clear_cache, mock_audit_log = _setup_model_block_mocks(
|
||||
monkeypatch, updated_blocked=True
|
||||
)
|
||||
wiped_router = MagicMock()
|
||||
wiped_router.get_model_ids.side_effect = [[model_id], []]
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", wiped_router)
|
||||
|
||||
with pytest.raises(ProxyException, match=model_id):
|
||||
await block_model(
|
||||
data=BlockModelRequest(model_id=model_id),
|
||||
http_request=MagicMock(),
|
||||
user_api_key_dict=_proxy_admin(),
|
||||
litellm_changed_by="operator@example.com",
|
||||
)
|
||||
|
||||
assert mock_audit_log.call_args.kwargs["object_id"] == model_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_block_surfaces_model_dropped_by_reload(monkeypatch):
|
||||
"""A reload that completes but drops the written model (ignore_invalid_deployments
|
||||
swallowed its re-add) must not produce an unqualified success."""
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import block_model
|
||||
|
||||
model_id, model_table, updated_row, mock_clear_cache, _ = _setup_model_block_mocks(
|
||||
monkeypatch, updated_blocked=True
|
||||
)
|
||||
dropped_router = MagicMock()
|
||||
dropped_router.get_model_ids.return_value = []
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", dropped_router)
|
||||
|
||||
with pytest.raises(ProxyException, match=model_id):
|
||||
await block_model(
|
||||
data=BlockModelRequest(model_id=model_id),
|
||||
http_request=MagicMock(),
|
||||
user_api_key_dict=_proxy_admin(),
|
||||
litellm_changed_by="operator@example.com",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6379,3 +6379,22 @@ class TestPreRoutingStrategyRegistryLifecycle:
|
|||
litellm_params=LiteLLM_Params(**params)
|
||||
)
|
||||
assert actual is expected, params["model"]
|
||||
|
||||
|
||||
def test_model_info_is_active_for_environment_matrix(monkeypatch):
|
||||
"""The model-write endpoints consult this predicate to tell a deliberately
|
||||
environment-inactive model from one dropped by a failed reload; the Router's own
|
||||
deployment gate delegates to it, so the two can never diverge."""
|
||||
from litellm.router import model_info_is_active_for_environment
|
||||
|
||||
assert model_info_is_active_for_environment(model_info=None) is True
|
||||
assert model_info_is_active_for_environment(model_info={"id": "m1"}) is True
|
||||
assert model_info_is_active_for_environment(model_info={"supported_environments": None}) is True
|
||||
|
||||
monkeypatch.setenv("LITELLM_ENVIRONMENT", "development")
|
||||
assert model_info_is_active_for_environment(model_info={"supported_environments": ["development"]}) is True
|
||||
assert model_info_is_active_for_environment(model_info={"supported_environments": ["production"]}) is False
|
||||
|
||||
monkeypatch.delenv("LITELLM_ENVIRONMENT")
|
||||
with pytest.raises(ValueError, match="LITELLM_ENVIRONMENT"):
|
||||
model_info_is_active_for_environment(model_info={"supported_environments": ["production"]})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue