mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
create_model now waits until the new deployment is servable on the data plane (polls /v1/models) before returning, instead of assuming /model/new makes it instantly callable. On a split control/data-plane proxy the gateway only sees a model after its next DB reload, so an immediate call raced the reload and 400'd with "Invalid model name passed" (embeddings, responses, messages, ocr, ...). It also stops pinning model_info.id to the model_name, letting the proxy assign a unique model_id. Re-registering a fixed-name deployment (the batch suite's openai-batch et al.) after a failed teardown no longer collides on the model_id unique constraint (prisma UniqueViolationError surfaced as the generic 500 "Failed to add model to db", erroring every batch_lifecycle case at setup)
204 lines
7.1 KiB
Python
204 lines
7.1 KiB
Python
"""Unit coverage for the Gateway model-management surface (create_model /
|
|
delete_model).
|
|
|
|
The batches conftest and several llm_translation tests register deployments at
|
|
runtime through gateway.create_model; when that method went missing, every batch
|
|
test errored at fixture setup (AttributeError) before a single request reached
|
|
the proxy. This pins the surface with a typed fake Transport so a rename or
|
|
signature drift fails here instead of in a live stage run.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from batches.batch_client import BatchClient
|
|
from e2e_gateway import Gateway
|
|
from e2e_http import (
|
|
AuthHeaders,
|
|
FileUploadForm,
|
|
ProbeResult,
|
|
Result,
|
|
StreamingResponse,
|
|
Success,
|
|
UnknownApiError,
|
|
)
|
|
from models import (
|
|
LiteLLMParamsBody,
|
|
ModelDeleteBody,
|
|
ModelNewBody,
|
|
ModelNewResponse,
|
|
ModelsListResponse,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _RecordingTransport:
|
|
"""Typed fake fulfilling the Transport protocol; records every post and
|
|
answers with a canned success so the test asserts on what was sent.
|
|
|
|
`get("/v1/models")` reports a created model as servable only after
|
|
`servable_after_gets` polls, so a test can drive the data-plane wait in
|
|
create_model."""
|
|
|
|
posts: list[tuple[str, BaseModel]] = field(default_factory=list)
|
|
servable_after_gets: int = 0
|
|
models_error: UnknownApiError | None = None
|
|
model_gets: int = 0
|
|
_created: list[str] = field(default_factory=list)
|
|
|
|
def post[R: BaseModel](
|
|
self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
|
|
) -> Result[R]:
|
|
self.posts.append((path, json))
|
|
if path == "/model/new" and isinstance(json, ModelNewBody):
|
|
self._created.append(json.model_name)
|
|
payload = (
|
|
{"model_id": "registered-id"} if response_type is ModelNewResponse else {}
|
|
)
|
|
return Success(data=response_type.model_validate(payload))
|
|
|
|
def stream(
|
|
self, path: str, *, headers: BaseModel, json: BaseModel
|
|
) -> StreamingResponse:
|
|
raise AssertionError("stream is not part of model management")
|
|
|
|
def send(
|
|
self,
|
|
path: str,
|
|
*,
|
|
headers: BaseModel,
|
|
json: BaseModel,
|
|
params: BaseModel | None = None,
|
|
stream: bool = False,
|
|
) -> StreamingResponse:
|
|
raise AssertionError("send is not part of model management")
|
|
|
|
def get[R: BaseModel](
|
|
self,
|
|
path: str,
|
|
*,
|
|
headers: BaseModel,
|
|
params: BaseModel,
|
|
response_type: type[R],
|
|
) -> Result[R]:
|
|
if path == "/v1/models" and response_type is ModelsListResponse:
|
|
self.model_gets += 1
|
|
if self.models_error is not None:
|
|
return self.models_error
|
|
visible = self._created if self.model_gets > self.servable_after_gets else []
|
|
return Success(
|
|
data=response_type.model_validate({"data": [{"id": name} for name in visible]})
|
|
)
|
|
raise AssertionError(f"unexpected get: {path}")
|
|
|
|
def delete[R: BaseModel](
|
|
self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
|
|
) -> Result[R]:
|
|
raise AssertionError("delete is not part of model management")
|
|
|
|
def probe(self, path: str, *, params: BaseModel) -> ProbeResult:
|
|
raise AssertionError("probe is not part of model management")
|
|
|
|
def upload[R: BaseModel](
|
|
self,
|
|
path: str,
|
|
*,
|
|
headers: BaseModel,
|
|
form: FileUploadForm,
|
|
filename: str,
|
|
content: bytes,
|
|
params: BaseModel | None = None,
|
|
response_type: type[R],
|
|
) -> Result[R]:
|
|
raise AssertionError("upload is not part of model management")
|
|
|
|
def download(self, path: str, *, headers: BaseModel) -> StreamingResponse:
|
|
raise AssertionError("download is not part of model management")
|
|
|
|
def bearer(self, key: str) -> AuthHeaders:
|
|
return AuthHeaders(authorization=f"Bearer {key}")
|
|
|
|
@property
|
|
def master(self) -> AuthHeaders:
|
|
return self.bearer("sk-test-master")
|
|
|
|
|
|
def test_gateway_create_model_registers_deployment_and_returns_model_id() -> None:
|
|
transport = _RecordingTransport()
|
|
gateway = Gateway(transport=transport, poll_interval=0.0)
|
|
|
|
model_id = gateway.create_model(
|
|
"e2e-test-model", LiteLLMParamsBody(model="openai/gpt-4o-mini")
|
|
)
|
|
|
|
assert model_id == "registered-id"
|
|
path, body = transport.posts[0]
|
|
assert path == "/model/new"
|
|
assert isinstance(body, ModelNewBody)
|
|
assert body.model_name == "e2e-test-model"
|
|
# No pinned model_id: the proxy assigns a unique one, so a fixed-name model
|
|
# re-registered after a failed teardown can't collide on the id constraint.
|
|
assert body.model_info.id is None
|
|
assert body.model_info.mode is None
|
|
# It confirmed data-plane visibility before returning.
|
|
assert transport.model_gets >= 1
|
|
|
|
|
|
def test_gateway_create_model_waits_until_servable_on_the_data_plane() -> None:
|
|
# The model shows up on /v1/models only on the third poll (simulating the
|
|
# gateway's delayed DB reload in a split deployment); create_model must keep
|
|
# polling instead of returning after /model/new.
|
|
transport = _RecordingTransport(servable_after_gets=2)
|
|
gateway = Gateway(transport=transport, poll_interval=0.0)
|
|
|
|
gateway.create_model("e2e-late-model", LiteLLMParamsBody(model="openai/gpt-4o-mini"))
|
|
|
|
assert transport.model_gets == 3
|
|
|
|
|
|
def test_gateway_create_model_fails_loudly_when_never_servable() -> None:
|
|
transport = _RecordingTransport(servable_after_gets=10**9)
|
|
gateway = Gateway(transport=transport, poll_timeout=0.05, poll_interval=0.0)
|
|
|
|
with pytest.raises(AssertionError, match="never became servable"):
|
|
gateway.create_model("e2e-ghost-model", LiteLLMParamsBody(model="openai/gpt-4o-mini"))
|
|
|
|
|
|
def test_gateway_create_model_surfaces_the_last_data_plane_error() -> None:
|
|
transport = _RecordingTransport(
|
|
models_error=UnknownApiError(status_code=503, body="data plane down")
|
|
)
|
|
gateway = Gateway(transport=transport, poll_timeout=0.05, poll_interval=0.0)
|
|
|
|
with pytest.raises(AssertionError, match="data plane down") as excinfo:
|
|
gateway.create_model("e2e-flaky-model", LiteLLMParamsBody(model="openai/gpt-4o-mini"))
|
|
assert "503" in str(excinfo.value)
|
|
|
|
|
|
def test_batch_client_create_model_registers_a_batch_mode_deployment() -> None:
|
|
transport = _RecordingTransport()
|
|
client = BatchClient(gateway=Gateway(transport=transport, poll_interval=0.0))
|
|
|
|
model_id = client.create_model(
|
|
"e2e-batch-model", LiteLLMParamsBody(model="openai/gpt-4o-mini")
|
|
)
|
|
|
|
assert model_id == "registered-id"
|
|
path, body = transport.posts[0]
|
|
assert path == "/model/new"
|
|
assert isinstance(body, ModelNewBody)
|
|
assert body.model_info.mode == "batch"
|
|
|
|
|
|
def test_gateway_delete_model_posts_the_model_id() -> None:
|
|
transport = _RecordingTransport()
|
|
gateway = Gateway(transport=transport)
|
|
|
|
gateway.delete_model("registered-id")
|
|
|
|
path, body = transport.posts[0]
|
|
assert path == "/model/delete"
|
|
assert isinstance(body, ModelDeleteBody)
|
|
assert body.id == "registered-id"
|