litellm/tests/e2e/test_e2e_gateway.py
mubashir1osmani 24082bc07d
test(e2e): probe the full spend read surface including schema-hidden routes (#32267)
* fix(e2e): route model management to the control plane and restore Gateway.create_model

The split-transport routing table listed only /model/info as a control-plane
prefix, so /model/new and /model/delete were sent to the data-plane gateway,
which does not serve management routes and 404s them. Every suite that
registers deployments at runtime (llm_translation, batches, access_control)
failed on the split stage deployment because of this. Widen the prefix to
/model/ so all model-management routes reach the control plane while /models
stays on the data plane.

Separately, batch_client.py and several llm_translation tests call
gateway.create_model, but Gateway never had that method, so all 17 batch tests
errored at fixture setup with AttributeError. Add create_model/delete_model to
Gateway (with the optional mode that batches needs) and make EndpointsClient
delegate to it instead of carrying its own copy.

Regression tests cover both: the routing predicate for management vs LLM paths
and the Gateway model-management surface via a typed fake Transport. Both fail
on the previous code

* test(e2e): make the fake transport payload depend on response_type

The recording fake always answered with {"model_id": ...} even when the
caller asked for NoBody, which only validated because pydantic ignores extra
fields by default. Return an empty payload for response types that carry no
fields so a future extra="forbid" on NoBody cannot turn the delete test into
a ValidationError inside the fake

* test(e2e): probe the full spend read surface including schema-hidden routes

The curated spend-route list missed twelve read endpoints, most of them
include_in_schema=False and therefore invisible to the schema-discovery test:
/spend/logs/v2, /spend/logs/session/ui, /global/all_end_users,
/global/activity/exceptions/deployment, and the per-entity daily activity
family (user, user aggregated, team, organization, customer, end_user, tag).
Add them all, verified responsive against the live split stage deployment.

/end_user was missing from CONTROL_PLANE_PREFIXES, so /end_user/daily/activity
would have been routed to the data plane and 404ed like /model/new used to;
add the prefix and pin it plus the daily-activity routes in the transport
routing test.

/provider/budgets stays excluded with a documented reason: it returns 500
whenever router_settings.provider_budget_config is absent, so probing it on a
proxy without provider budget routing configured can never be green
2026-07-06 14:02:05 -07:00

148 lines
4.5 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
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,
)
from models import (
LiteLLMParamsBody,
ModelDeleteBody,
ModelNewBody,
ModelNewResponse,
)
@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."""
posts: list[tuple[str, BaseModel]] = 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))
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]:
raise AssertionError("get is not part of model management")
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)
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"
assert body.model_info.id == "e2e-test-model"
assert body.model_info.mode is None
def test_batch_client_create_model_registers_a_batch_mode_deployment() -> None:
transport = _RecordingTransport()
client = BatchClient(gateway=Gateway(transport=transport))
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"