litellm/tests/e2e/test_proxy_client.py
yuneng-jiang 36bd7f1138
fix(mcp): honor an explicit null on toolset update, cover MCP lifecycle e2e (#40022)
* fix(mcp): honor an explicit null on toolset update, cover MCP lifecycle e2e

PUT /v1/mcp/toolset dumped its payload with exclude_none, so a field sent as
null looked exactly like one the caller left out and the stored value
survived. An admin could not clear a toolset's description: the save reported
success and the old text came straight back. It now dumps with exclude_unset,
so absent keeps and null clears, which is what PUT /v1/mcp/server already did.
A null tools list clears the selection to empty, and a null toolset_name is
ignored because a toolset always has a name.

Adds create, read, partial-update, clear and delete e2e coverage for MCP
servers and toolsets, with every read-back polled on every replica so an edit
that lands on one replica and not another fails the test, plus an enforcement
test proving a key granted a toolset lists exactly that toolset's tools
against the real Datadog upstream.

* fix(e2e): refuse a read-back that no replica serves

A read-back over an empty replica mapping satisfied every predicate and
returned as if it had converged, so it would have asserted nothing and
passed. No wiring can produce that today, since the replica list always
falls back to at least one URL, but a helper whose whole job is proving a
write reached every replica should not have a shape that passes vacuously.

* fix(mcp): keep a null tools list a no-op on toolset update

Treating a null tools list as a clear meant an existing client that sends
tools=null during a partial update, meaning "leave the selection alone",
silently lost every tool the toolset grants. That is a permission surface,
so the quiet version of it is the worst version.

A toolset always has a tool list, the same way it always has a name, so a
null on either is now a no-op. Emptying the selection is an explicit [],
which cannot be confused with a field the caller left out, and which is
what the dashboard already sends.

* fix(e2e): keep MCP admin routes on the data plane

/v1/mcp/* is a lazily mounted feature, so a gateway registers it on the first
matching request, which happens after the startup route trim that drops
management endpoints. Routing it to the control plane therefore sent every MCP
call to the one backend process: the new lifecycle read-backs proved a single
process rather than every replica, and mcp_client's await_registered barrier
waited on a registry that does not serve the tools/list call it guards, so the
existing MCP suites polled a gateway that had not synced yet until poll_timeout

Verified against a two-gateway split stack (backend on 4001, gateways on 4010
and 4011, one postgres): both gateways answer /v1/mcp/server and /v1/mcp/toolset,
and each served 6 server reads and 7 toolset reads over the run

* fix(e2e): grant the toolset by the tool's own name, not the wire name

tools/list serves a tool as <prefix><tool_name>, but a toolset grants by the
tool's own name: resolve_toolset_permissions reads toolset.tools[].tool_name
straight through, and the prefix is added on the way out. The test built the
toolset from the names tools/list reported, so the grant matched nothing, the
scoped key listed no tools, and await_tools ran out its whole poll_timeout
before failing

Measure the prefix off search_datadog_logs, whose own name is known, rather than
guessing it from the alias, since the proxy can be configured to prefix with a
short server id instead. The expectation compared against tools/list stays in
wire names; only what the toolset stores crosses back

* test(mcp): build immutable lifecycle updates and replica results

* test: validate opaque stream IDs and hide log-reader credentials

* test: isolate auto-router scenarios and clean partial setup

* test: honor Datadog search rate-limit reset headers

* test: share the Datadog read-back deadline across retries

* test: preserve captured MCP toolset update fields
2026-09-08 22:50:13 -07:00

276 lines
11 KiB
Python

"""Harness coverage for the barriers that gate on every replica.
No proxy needed and no ``e2e`` marker: this pins that a model registered through
the control plane only counts as servable once every configured replica lists it
on /v1/models, and that a management write only counts as read back once every
replica's read satisfies the caller's predicate, which is what keeps a two-gateway
stack from handing a test a model or a key that one gateway has not caught up on
yet. The fakes are plain pollers standing in for each replica's transport plus an
injected clock, so nothing here monkeypatches anything.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from itertools import chain, repeat
from types import MappingProxyType
from typing import Final, cast
import pytest
from e2e_config import parse_replica_urls
from e2e_http import Result, Success
from models import KeyInfo, KeyInfoResponse, ModelListEntry, ModelsListResponse
from proxy_client import (
ConvergeOutcome,
Converged,
EverywhereConverged,
ModelsPoller,
NeverConvergedOn,
NotConverged,
NotServableOn,
Poller,
ProxyClient,
ReplicaRead,
Servable,
await_converged_everywhere,
await_everywhere,
await_servable_everywhere,
build_proxy_client,
converge_timeout_message,
first_lagging_replica,
)
from transport import Transport
MODEL: Final = "gpt-under-test"
_NO_TRANSPORTS: Final = cast(Transport, None)
TIMEOUT: Final = 10.0
INTERVAL: Final = 2.0
RPM_BEFORE_UPDATE: Final = 100
RPM_AFTER_UPDATE: Final = 200
@dataclass
class FakeClock:
elapsed: float = 0.0
def now(self) -> float:
return self.elapsed
def sleep(self, seconds: float) -> None:
self.elapsed += seconds
def _listing(*model_ids: str) -> Success[ModelsListResponse]:
entries: Final = tuple(ModelListEntry(id=model_id) for model_id in model_ids)
return Success(status_code=200, data=ModelsListResponse(data=entries))
def _poller(results: Iterable[Success[ModelsListResponse]]) -> ModelsPoller:
it: Final = iter(results)
return lambda _timeout: next(it)
def _await(pollers: Mapping[str, ModelsPoller]) -> Servable | NotServableOn:
clock: Final = FakeClock()
return await_servable_everywhere(
pollers,
model_name=MODEL,
timeout=TIMEOUT,
interval=INTERVAL,
request_timeout=5.0,
db_sync_seconds=0.0,
now=clock.now,
sleep=clock.sleep,
)
class TestAwaitServableEverywhere:
@pytest.mark.parametrize("missing", ["gateway-1", "gateway-2"])
def test_fails_on_the_replica_that_never_lists_the_model(self, missing: str) -> None:
pollers: Final = {
"gateway-1": _poller(repeat(_listing(MODEL))),
"gateway-2": _poller(repeat(_listing(MODEL))),
} | {missing: _poller(repeat(_listing()))}
assert _await(pollers) == NotServableOn(replica=missing, last_result=_listing())
def test_passes_once_every_replica_lists_the_model(self) -> None:
pollers: Final = {
"gateway-1": _poller(repeat(_listing(MODEL))),
"gateway-2": _poller(chain(repeat(_listing(), 2), repeat(_listing(MODEL)))),
}
assert _await(pollers) == Servable()
def _key_info(rpm_limit: int) -> Success[KeyInfoResponse]:
return Success(status_code=200, data=KeyInfoResponse(info=KeyInfo(rpm_limit=rpm_limit)))
def _reads(results: Iterable[Result[KeyInfoResponse]]) -> Poller[Result[KeyInfoResponse]]:
it: Final = iter(results)
return lambda: next(it)
def _updated(result: Result[KeyInfoResponse]) -> bool:
return isinstance(result, Success) and result.data.info.rpm_limit == RPM_AFTER_UPDATE
def _converge(
pollers: Mapping[str, Poller[Result[KeyInfoResponse]]], clock: FakeClock
) -> Mapping[str, ConvergeOutcome[Result[KeyInfoResponse]]]:
return await_converged_everywhere(
pollers,
converged=_updated,
timeout=TIMEOUT,
interval=INTERVAL,
now=clock.now,
sleep=clock.sleep,
)
class TestAwaitConvergedEverywhere:
def test_waits_for_the_replica_that_lags_behind_the_write(self) -> None:
clock: Final = FakeClock()
pollers: Final = MappingProxyType(
{
"gateway-1": _reads(repeat(_key_info(RPM_AFTER_UPDATE))),
"gateway-2": _reads(
chain(repeat(_key_info(RPM_BEFORE_UPDATE), 2), repeat(_key_info(RPM_AFTER_UPDATE)))
),
}
)
outcomes: Final = _converge(pollers, clock)
assert outcomes == {
"gateway-1": Converged(result=_key_info(RPM_AFTER_UPDATE)),
"gateway-2": Converged(result=_key_info(RPM_AFTER_UPDATE)),
}
assert first_lagging_replica(outcomes) is None
assert clock.elapsed == 2 * INTERVAL
def test_names_the_replica_that_never_converges_with_its_last_read(self) -> None:
clock: Final = FakeClock()
pollers: Final = MappingProxyType(
{
"gateway-1": _reads(repeat(_key_info(RPM_AFTER_UPDATE))),
"gateway-2": _reads(repeat(_key_info(RPM_BEFORE_UPDATE))),
}
)
outcomes: Final = _converge(pollers, clock)
assert first_lagging_replica(outcomes) == (
"gateway-2",
NotConverged(last_result=_key_info(RPM_BEFORE_UPDATE)),
)
assert clock.elapsed == TIMEOUT
message: Final = converge_timeout_message(
what="GET /key/info",
replica="gateway-2",
timeout=TIMEOUT,
last_result=_key_info(RPM_BEFORE_UPDATE),
)
assert "gateway-2" in message and "/key/info" in message and str(RPM_BEFORE_UPDATE) in message
def test_each_replica_gets_its_own_full_budget(self) -> None:
"""A replica that converges late must not eat into the next replica's budget: both
need most of the timeout here, so one shared deadline would starve the second."""
clock: Final = FakeClock()
slow: Final = chain(repeat(_key_info(RPM_BEFORE_UPDATE), 3), repeat(_key_info(RPM_AFTER_UPDATE)))
pollers: Final = MappingProxyType(
{
"gateway-1": _reads(slow),
"gateway-2": _reads(
chain(repeat(_key_info(RPM_BEFORE_UPDATE), 3), repeat(_key_info(RPM_AFTER_UPDATE)))
),
}
)
outcomes: Final = _converge(pollers, clock)
assert first_lagging_replica(outcomes) is None
assert clock.elapsed == 2 * 3 * INTERVAL
class TestParseReplicaUrls:
def test_splits_and_trims_the_gateway_addresses(self) -> None:
raw: Final = " http://127.0.0.1:4010/, http://127.0.0.1:4011 "
assert parse_replica_urls(raw, "http://lb") == ("http://127.0.0.1:4010", "http://127.0.0.1:4011")
def test_falls_back_to_the_data_plane_address_when_unset(self) -> None:
assert parse_replica_urls("", "http://lb") == ("http://lb",)
def _answers(answers: Iterable[str]) -> ReplicaRead[str]:
it: Final = iter(answers)
return lambda _timeout: next(it)
def _await_everywhere(reads: Mapping[str, ReplicaRead[str]]) -> EverywhereConverged[str] | NeverConvergedOn[str]:
clock: Final = FakeClock()
return await_everywhere(
reads,
settled=lambda answer: answer == "renamed",
timeout=TIMEOUT,
interval=INTERVAL,
request_timeout=5.0,
now=clock.now,
sleep=clock.sleep,
)
class TestAwaitEverywhere:
def test_waits_for_the_lagging_replica_and_returns_every_settled_answer(self) -> None:
reads: Final = {
"gateway-1": _answers(repeat("renamed")),
"gateway-2": _answers(chain(repeat("stale", 2), repeat("renamed"))),
}
outcome: Final = _await_everywhere(reads)
assert isinstance(outcome, EverywhereConverged)
assert dict(outcome.answers) == {"gateway-1": "renamed", "gateway-2": "renamed"}
def test_names_the_replica_that_never_converges_with_what_it_last_served(self) -> None:
reads: Final = {
"gateway-1": _answers(repeat("renamed")),
"gateway-2": _answers(repeat("stale")),
}
assert _await_everywhere(reads) == NeverConvergedOn(replica="gateway-2", last="stale")
def test_polls_until_the_deadline_before_giving_up(self) -> None:
lagging: Final = chain(repeat("stale", int(TIMEOUT / INTERVAL)), repeat("renamed"))
outcome: Final = _await_everywhere({"gateway-1": _answers(lagging)})
assert isinstance(outcome, EverywhereConverged), outcome
class TestReplicasFor:
def test_split_deployment_reads_management_routes_back_from_the_control_plane(self) -> None:
client: Final = build_proxy_client(
base_url="http://lb",
control_plane_base_url="http://backend",
replica_urls=("http://gateway-1", "http://gateway-2"),
)
assert set(client.replicas_for("/key/info")) == {"http://backend"}
assert set(client.replicas_for("/v1/models")) == {"http://gateway-1", "http://gateway-2"}
def test_monolith_reads_management_routes_back_from_every_replica(self) -> None:
client: Final = build_proxy_client(
base_url="http://lb",
control_plane_base_url="http://lb",
replica_urls=("http://pod-1", "http://pod-2"),
)
assert set(client.replicas_for("/key/info")) == {"http://pod-1", "http://pod-2"}
def test_mcp_admin_routes_read_back_from_every_data_plane_replica(self) -> None:
"""/v1/mcp/* is a lazily mounted feature, so a data-plane replica serves it
too and answers from its own in-memory registry. Routing it to the control
plane would leave every replica but that one unproven, and would move the
tools/list barrier in mcp_client off the plane that serves tools/list."""
client: Final = build_proxy_client(
base_url="http://lb",
control_plane_base_url="http://backend",
replica_urls=("http://gateway-1", "http://gateway-2"),
)
assert set(client.replicas_for("/v1/mcp/server/abc")) == {"http://gateway-1", "http://gateway-2"}
assert set(client.replicas_for("/v1/mcp/toolset/abc")) == {"http://gateway-1", "http://gateway-2"}
def test_a_route_no_replica_serves_is_refused_rather_than_read_back_vacuously(self) -> None:
"""A read-back over zero replicas would satisfy every predicate and assert
nothing, so asking for one fails instead of passing silently."""
client: Final = ProxyClient(transport=_NO_TRANSPORTS, replicas={}, control_replicas={})
with pytest.raises(AssertionError, match="no replica is configured"):
_ = client.replicas_for("/v1/models")