Skip to content
Closed
80 changes: 79 additions & 1 deletion tests/e2e/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,14 +317,78 @@ def token_cost(self, prompt_tokens: int, completion_tokens: int) -> float:
return prompt_tokens * self.input_cost_per_token + completion_tokens * self.output_cost_per_token


class DeploymentParams(BaseModel):
"""The configured litellm_params a /model/info row reports for a deployment,
mirroring litellm's LiteLLM_Params (litellm/types/router.py) field for field
so tests can assert any configured knob. Same pricing field names as
CustomPricing, so pricing tests read them unchanged. Secrets (api_key et al)
come back encrypted, so tests assert presence, never the value. Deliberately
omitted because they have no typed JSON shape to pin: mock_response,
model_info (surfaced as ModelInfoEntry.model_info), the *_router_config
blobs, and configurable_clientside_auth_params."""

model_config = ConfigDict(extra="ignore", protected_namespaces=())

model: str | None = None
custom_llm_provider: str | None = None

api_key: str | None = None
api_base: str | None = None
api_version: str | None = None
organization: str | None = None
litellm_credential_name: str | None = None

tpm: int | None = None
rpm: int | None = None
itpm: int | None = None
otpm: int | None = None
max_parallel_requests: int | None = None
order: int | None = None
weight: int | None = None

timeout: float | str | None = None
stream_timeout: float | str | None = None
max_retries: int | None = None

max_budget: float | None = None
budget_duration: str | None = None
default_api_key_tpm_limit: int | None = None
default_api_key_rpm_limit: int | None = None

input_cost_per_token: float | None = None
output_cost_per_token: float | None = None
input_cost_per_second: float | None = None
output_cost_per_second: float | None = None
cache_read_input_token_cost: float | None = None
cache_creation_input_token_cost: float | None = None

tags: list[str] | None = None
tag_regex: list[str] | None = None

use_in_pass_through: bool | None = None
use_litellm_proxy: bool | None = None
use_chat_completions_api: bool | None = None
use_xai_oauth: bool | None = None
merge_reasoning_content_in_choices: bool | None = None

region_name: str | None = None
aws_region_name: str | None = None
vertex_project: str | None = None
vertex_location: str | None = None
watsonx_region_name: str | None = None

max_file_size_mb: float | None = None
litellm_trace_id: str | None = None


class ModelInfoEntry(BaseModel):
"""One /model/info row. `litellm_params` is the configured deployment (carries
any custom-pricing override); `model_info` is the price the proxy resolved for
it - the override merged over the cost-map defaults."""

model_config = ConfigDict(protected_namespaces=())
model_name: str
litellm_params: CustomPricing = CustomPricing()
litellm_params: DeploymentParams = DeploymentParams()
model_info: CustomPricing = CustomPricing()
Comment thread
mubashir1osmani marked this conversation as resolved.


Expand Down Expand Up @@ -384,6 +448,7 @@ class LiteLLMParamsBody(BaseModel):
aws_batch_role_arn: str | None = None
input_cost_per_token: float | None = None
output_cost_per_token: float | None = None
tpm: int | None = None


ModelMode = Literal["batch", "realtime", "image_generation"]
Expand All @@ -408,3 +473,16 @@ class ModelNewResponse(BaseModel):

class ModelDeleteBody(BaseModel):
id: str


class ModelUpdateParams(BaseModel):
"""POST /model/update litellm_params: only the fields being changed; the proxy
merges them over the deployment's stored params."""

tpm: int | None = None


class ModelUpdateBody(BaseModel):
model_config = ConfigDict(protected_namespaces=())
litellm_params: ModelUpdateParams
model_info: ModelInfoBody
17 changes: 17 additions & 0 deletions tests/e2e/models_mgmt/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Models-management suite client fixture; lifecycle/skip/marker live in the parent conftest."""

import pytest

from models_mgmt_client import ModelsMgmtClient, build_client


def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
"covers: registry cell a test covers, e.g. mgmt.model.add.persists",
)


@pytest.fixture(scope="session")
def client() -> ModelsMgmtClient:
return build_client()
107 changes: 107 additions & 0 deletions tests/e2e/models_mgmt/models_mgmt_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Client for the model-management e2e suite: the shared Gateway plus the
/model/update write, a strict /model/delete, a /model/info lookup by name, and
the raw-status calls the tests judge by HTTP outcome (a forbidden /model/new, a
chat against a deleted model).
"""

from __future__ import annotations

from dataclasses import dataclass

from e2e_gateway import Gateway, build_gateway
from e2e_http import NoBody, StreamingResponse, unwrap
from models import (
ChatBody,
ChatMessage,
KeyGenerateBody,
LiteLLMParamsBody,
ModelDeleteBody,
ModelInfoBody,
ModelInfoEntry,
ModelNewBody,
ModelUpdateBody,
ModelUpdateParams,
)

ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route"
UNKNOWN_MODEL_MARKER = "Invalid model name passed in model="
NO_DEPLOYMENTS_MARKER = "There are no healthy deployments"


def is_deleted_model_rejection(outcome: StreamingResponse, model: str) -> bool:
"""True if the gateway refused the call because `model` is gone: a 400 naming
the model, either the proxy's unknown-model shape (the data plane never knew
the group) or the router's no-healthy-deployments shape (the group name
outlives its last deployment in the router until restart)."""
if outcome.status_code != 400 or model not in outcome.body:
return False
return UNKNOWN_MODEL_MARKER in outcome.body or NO_DEPLOYMENTS_MARKER in outcome.body


@dataclass(frozen=True, slots=True)
class ModelsMgmtClient:
gateway: Gateway

def llm_only_key(self) -> str:
return self.gateway.generate_key(
KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"])
)

def find_deployment(self, model_name: str) -> ModelInfoEntry | None:
return next(
(entry for entry in self.gateway.model_info() if entry.model_name == model_name),
None,
)

def update_model_tpm(self, model_id: str, tpm: int) -> None:
_ = unwrap(
self.gateway.transport.post(
"/model/update",
headers=self.gateway.transport.master,
json=ModelUpdateBody(
litellm_params=ModelUpdateParams(tpm=tpm),
model_info=ModelInfoBody(id=model_id),
),
response_type=NoBody,
)
)

def delete_model(self, model_id: str) -> None:
"""Strict delete for the act phase of a test: a failed delete is a hard
failure, unlike the warn-only Gateway.delete_model used at teardown."""
_ = unwrap(
self.gateway.transport.post(
"/model/delete",
headers=self.gateway.transport.master,
json=ModelDeleteBody(id=model_id),
response_type=NoBody,
)
)

def create_model_status(
self, key: str, model_name: str, litellm_params: LiteLLMParamsBody
) -> StreamingResponse:
return self.gateway.transport.send(
"/model/new",
headers=self.gateway.transport.bearer(key),
json=ModelNewBody(
model_name=model_name,
litellm_params=litellm_params,
model_info=ModelInfoBody(id=model_name),
),
)

def chat_status(self, key: str, model: str, content: str) -> StreamingResponse:
return self.gateway.transport.send(
"/chat/completions",
headers=self.gateway.transport.bearer(key),
json=ChatBody(
model=model,
messages=[ChatMessage(role="user", content=content)],
max_tokens=16,
),
)


def build_client() -> ModelsMgmtClient:
return ModelsMgmtClient(gateway=build_gateway())
Loading
Loading