Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
MutableMapping,
Sequence,
)
from dataclasses import asdict
from datetime import datetime, timedelta, timezone
from types import MappingProxyType, UnionType
from typing import (
Expand Down Expand Up @@ -6510,7 +6511,7 @@ def _add_deployment(self, db_models: list) -> int:

added_models = 0
## ADD MODEL LOGIC
for m in db_models:
for m in sorted(db_models, key=lambda m: (m.created_at or datetime.min, m.model_id)):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sorting breaks row-like callers

Sorting reads created_at before processing, so existing row-like callers without that attribute raise immediately and no deployments are loaded

Knowledge Base Used: Proxy configuration and deployment

_litellm_params = m.litellm_params
if isinstance(_litellm_params, dict):
# decrypt values
Expand Down Expand Up @@ -15046,9 +15047,15 @@ def _get_proxy_model_info(model: dict) -> dict:
return _translate_model_name_for_response(model)


def _model_info_json_response(data: Sequence[Mapping[str, object]] | Mapping[str, object]) -> Response:
def _model_info_json_response(
data: Sequence[Mapping[str, object]] | Mapping[str, object],
dropped_deployments: Sequence[Mapping[str, object]] | None = None,
) -> Response:
response_data: Final = (
{"data": data} if dropped_deployments is None else {"data": data, "dropped_deployments": dropped_deployments}
)
return Response(
content=orjson.dumps({"data": data}, default=jsonable_encoder, option=orjson.OPT_NON_STR_KEYS),
content=orjson.dumps(response_data, default=jsonable_encoder, option=orjson.OPT_NON_STR_KEYS),
media_type="application/json",
)

Expand Down Expand Up @@ -15267,7 +15274,12 @@ async def model_info_v1(
visible_models: Final = [model for model in all_models if model.get("model_name") not in hidden_names]

verbose_proxy_logger.debug("all_models: %s", visible_models)
return _model_info_json_response(visible_models)
dropped_deployments: Final = (
[asdict(dropped_deployment) for dropped_deployment in llm_router.dropped_deployments.values()]
if _user_has_admin_view(user_api_key_dict)
else None
)
Comment on lines +15277 to +15281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Exception text bypasses masking

Raw exception strings bypass deployment credential masking and reach view-only administrators, risking exposure of sensitive invalid configuration

Knowledge Base Used:

return _model_info_json_response(visible_models, dropped_deployments=dropped_deployments)


@router.get(
Expand Down
25 changes: 22 additions & 3 deletions litellm/proxy/route_llm_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,25 @@ def _raise_if_model_fully_blocked(llm_router: LitellmRouter, model_name: object,


class ProxyModelNotFoundError(HTTPException):
def __init__(self, route: str, model_name: str, retryable_with_model_read_through: bool = True):
self.retryable_with_model_read_through: Final = retryable_with_model_read_through
def __init__(
self,
route: str,
model_name: str,
retryable_with_model_read_through: bool = True,
configured_but_failed_to_load: bool = False,
):
self.retryable_with_model_read_through: Final = (
retryable_with_model_read_through and not configured_but_failed_to_load
)
self.spend_log_error_message: Final = f"{route}: Invalid model name passed in. {_AVAILABLE_MODELS_HINT}"
detail: Final = {"error": f"{route}: Invalid model name passed in model={model_name}. {_AVAILABLE_MODELS_HINT}"}
detail: Final = {
"error": (
f"{route}: model={model_name} is configured on the proxy but failed to load. "
"Ask your proxy admin to check `/model/info` for the reason."
if configured_but_failed_to_load
else f"{route}: Invalid model name passed in model={model_name}. {_AVAILABLE_MODELS_HINT}"
)
}
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)


Expand Down Expand Up @@ -713,7 +728,11 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr

# if no route found then it's a bad request
route_name: Final = ROUTE_ENDPOINT_MAPPING.get(route_type, route_type)
dropped_deployment: Final = (
llm_router.dropped_deployment_for_model_name(data.get("model", "")) if llm_router is not None else None
)
raise ProxyModelNotFoundError(
route=route_name,
model_name=data.get("model", ""),
configured_but_failed_to_load=dropped_deployment is not None,
)
46 changes: 45 additions & 1 deletion litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@
Deployment,
DeploymentModelListingInfo,
DeploymentTypedDict,
DroppedDeployment,
FallbackAccessCheck,
GuardrailTypedDict,
LiteLLM_Params,
Expand Down Expand Up @@ -908,6 +909,7 @@ def __init__(
self.complexity_routers: dict[str, list[TaggedPreRoutingStrategy[ComplexityRouter]]] = {}
self.adaptive_routers: dict[str, list[TaggedPreRoutingStrategy[AdaptiveRouter]]] = {}
self.quality_routers: dict[str, list[TaggedPreRoutingStrategy[QualityRouter]]] = {}
self.dropped_deployments: Mapping[str, DroppedDeployment] = MappingProxyType({})
self.routing_plugins: list[RoutingPlugin] = list(plugins) if plugins else []

# Initialize model_group_alias early since it's used in set_model_list
Expand Down Expand Up @@ -8950,6 +8952,16 @@ def _create_deployment(
verbose_router_logger.exception(
"Error creating deployment: %s, ignoring and continuing with other deployments.", e
)
self.dropped_deployments = MappingProxyType(
{
**self.dropped_deployments,
str(_model_info.get("id") or ""): DroppedDeployment(
model_name=_model_name,
model_id=str(_model_info.get("id") or ""),
error=str(e),
),
}
)
return None
else:
raise e
Expand Down Expand Up @@ -9884,15 +9896,33 @@ def upsert_deployment(self, deployment: Deployment) -> Deployment | None:
)
):
self._finalize_adaptive_router_if_configured()
if _deployment_model_id in self.dropped_deployments:
self.dropped_deployments = MappingProxyType(
{
model_id: dropped
for model_id, dropped in self.dropped_deployments.items()
if model_id != _deployment_model_id
}
)
return deployment
except Exception as e:
if self.ignore_invalid_deployments:
verbose_router_logger.warning(
verbose_router_logger.error(
"Error upserting deployment %s (id=%s): %s. Dropping it and continuing with other deployments.",
deployment.model_name,
deployment.model_info.id,
e,
)
self.dropped_deployments = MappingProxyType(
{
**self.dropped_deployments,
_deployment_model_id: DroppedDeployment(
model_name=deployment.model_name,
model_id=_deployment_model_id,
error=str(e),
),
}
)
self._restore_deployment_after_failed_upsert(
previous_deployment=_deployment_on_router, model_id=_deployment_model_id
)
Expand Down Expand Up @@ -10079,6 +10109,10 @@ def delete_deployment(self, id: str) -> Deployment | None:
- The deleted deployment
- OR None (if deleted deployment not found)
"""
if id in self.dropped_deployments:
self.dropped_deployments = MappingProxyType(
Comment on lines 10109 to +10113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dropped records outlive deletion

DB reconciliation only deletes live router IDs, so dropped-only records persist and make removed names appear configured while disabling registry read-through

Knowledge Base Used:

{model_id: dropped for model_id, dropped in self.dropped_deployments.items() if model_id != id}
)
deployment_idx = None
if id in self.model_id_to_deployment_index_map:
deployment_idx = self.model_id_to_deployment_index_map[id]
Expand Down Expand Up @@ -10109,6 +10143,16 @@ def delete_deployment(self, id: str) -> Deployment | None:
except Exception:
return None

def dropped_deployment_for_model_name(self, model_name: str) -> DroppedDeployment | None:
return next(
(
dropped_deployment
for dropped_deployment in self.dropped_deployments.values()
if dropped_deployment.model_name == model_name
),
None,
)

def _get_router_deployment_budget_limiter(
self,
) -> RouterBudgetLimiting | None:
Expand Down
7 changes: 7 additions & 0 deletions litellm/types/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,13 @@ def __setitem__(self, key, value) -> None:
setattr(self, key, value)


@dataclass(frozen=True, slots=True)
class DroppedDeployment:
model_name: str
model_id: str
error: str


@dataclass(frozen=True, slots=True)
class DeploymentModelListingInfo:
"""What the deployments behind a model name contribute to its OpenAI-compatible listing entry.
Expand Down
86 changes: 81 additions & 5 deletions tests/test_litellm/proxy/test_proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5206,11 +5206,12 @@ async def test_model_info_v1_oci_secrets_not_leaked():
from litellm.proxy.proxy_server import model_info_v1

# Mock user authentication
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
mock_user_api_key_dict.user_id = "test-user"
mock_user_api_key_dict.api_key = "test-key"
mock_user_api_key_dict.team_models = []
mock_user_api_key_dict.models = ["oci-grok-test"]
mock_user_api_key_dict = UserAPIKeyAuth(
user_id="test-user",
api_key="test-key",
team_models=[],
models=["oci-grok-test"],
)

# Mock model data with OCI sensitive information
mock_model_data = {
Expand Down Expand Up @@ -5280,6 +5281,81 @@ async def test_model_info_v1_oci_secrets_not_leaked():
assert "/path/to/oci_api_key.pem" not in result_str


@pytest.mark.asyncio
async def test_model_info_v1_lists_dropped_deployments_for_admins_only(monkeypatch):
from litellm.types.router import DroppedDeployment

model_data: Final = {
"model_name": "gpt-4o",
"litellm_params": {"model": "openai/gpt-4o"},
"model_info": {"id": "model-id"},
}
mock_router = MagicMock()
mock_router.model_list = [model_data]
mock_router.get_model_list_from_model_alias.return_value = []
mock_router.get_model_names.return_value = ["gpt-4o"]
mock_router.get_model_access_groups.return_value = {}
mock_router.dropped_deployments = {
"dropped-id": DroppedDeployment(
model_name="dropped-model",
model_id="dropped-id",
error="At most 1 auto-router",
)
}
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_model_list", [model_data])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"infer_model_from_keys": False})
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)

admin_response = await proxy_server_module.model_info_v1(
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
litellm_model_id=None,
)
admin_payload = json.loads(admin_response.body.decode())
assert admin_payload["dropped_deployments"] == [
{
"model_name": "dropped-model",
"model_id": "dropped-id",
"error": "At most 1 auto-router",
}
]

user_response = await proxy_server_module.model_info_v1(
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER),
litellm_model_id=None,
)
assert "dropped_deployments" not in json.loads(user_response.body.decode())


def test_add_deployment_processes_db_rows_in_created_at_order(monkeypatch):
from datetime import datetime

config = proxy_server_module.ProxyConfig()
earlier: Final = types.SimpleNamespace(
created_at=datetime(2025, 1, 1),
model_id="id-earlier",
model_name="earlier",
litellm_params={"model": "openai/gpt-4o-mini"},
)
later: Final = types.SimpleNamespace(
created_at=datetime(2025, 1, 2),
model_id="id-later",
model_name="later",
litellm_params={"model": "openai/gpt-4o"},
)
mock_router = MagicMock()
mock_router.upsert_deployment.return_value = object()
monkeypatch.setattr(proxy_server_module, "llm_router", mock_router)
config.get_model_info_with_id = MagicMock(side_effect=lambda model, db_model: {"id": model.model_id})

assert config._add_deployment([later, earlier]) == 2
assert [call.kwargs["deployment"].model_name for call in mock_router.upsert_deployment.call_args_list] == [
"earlier",
"later",
]


def test_model_info_v1_list_skips_fastapi_jsonable_encoder(monkeypatch):
"""
/model/info serializes its multi-megabyte listing itself with orjson. FastAPI must not
Expand Down
71 changes: 67 additions & 4 deletions tests/test_litellm/proxy/test_route_llm_request.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@

import pytest



from datetime import datetime
from typing import Final
from unittest.mock import MagicMock

import pytest

from fastapi import HTTPException

from litellm.proxy.route_llm_request import ProxyModelNotFoundError, route_request
Expand Down Expand Up @@ -43,6 +42,69 @@ async def test_route_request_dynamic_credentials(route_type, required_body_param
getattr(llm_router, route_type).assert_called_once_with(**data)


@pytest.mark.asyncio
async def test_route_request_distinguishes_dropped_deployments_from_unknown_models():
import litellm

router = litellm.Router(
model_list=[
{
"model_name": "pool",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "fake"},
},
{
"model_name": "live",
"litellm_params": {
"model": "auto_router/complexity_router",
"complexity_router_config": {
"classifier_type": "heuristic_v2",
"tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"},
},
},
"model_info": {"id": "id-live"},
},
],
auto_router_capability_limit=lambda: 1,
ignore_invalid_deployments=True,
)
from litellm.types.router import Deployment

assert router.upsert_deployment(
Deployment(
model_name="x",
litellm_params={
"model": "auto_router/complexity_router",
"complexity_router_config": {
"classifier_type": "heuristic_v2",
"tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"},
},
},
model_info={"id": "id-x"},
)
) is None
assert "id-x" in router.dropped_deployments

with pytest.raises(HTTPException) as dropped_error:
await route_request(
data={"model": "x", "messages": [{"role": "user", "content": "Hello"}]},
llm_router=router,
user_model=None,
route_type="acompletion",
)
assert dropped_error.value.status_code == 400
assert "configured on the proxy but failed to load" in str(dropped_error.value.detail)

with pytest.raises(HTTPException) as unknown_error:
await route_request(
data={"model": "unknown", "messages": [{"role": "user", "content": "Hello"}]},
llm_router=router,
user_model=None,
route_type="acompletion",
)
assert unknown_error.value.status_code == 400
assert "Invalid model name" in str(unknown_error.value.detail)


@pytest.mark.asyncio
async def test_route_request_proxy_admin_can_call_all_team_scoped_deployments_without_team_id():
import litellm
Expand Down Expand Up @@ -1146,6 +1208,7 @@ def _db_model_row(model_name: str, mock_response: str):
litellm_params={"model": "openai/gpt-4o", "api_key": "fake", "mock_response": mock_response},
model_info={},
blocked=False,
created_at=datetime.min,
)


Expand Down
Loading
Loading