mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
* 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
223 lines
7.6 KiB
Python
223 lines
7.6 KiB
Python
import json
|
|
from collections.abc import Iterator, Sequence
|
|
from dataclasses import dataclass
|
|
from typing import Final
|
|
|
|
import pytest
|
|
|
|
from datadog_reader import DdLogsReader
|
|
from datadog_reader import _DdAuthHeaders # pyright: ignore[reportPrivateUsage] # verifies private auth-header serialization
|
|
from e2e_config import DD_SEARCH_INTERVAL, POLL_TIMEOUT
|
|
from e2e_http import StreamingResponse
|
|
|
|
|
|
def test_failure_diagnostics_hide_credentials_without_changing_auth_headers() -> None:
|
|
api_key: Final = "test-datadog-api-secret"
|
|
app_key: Final = "test-datadog-app-secret"
|
|
reader: Final = DdLogsReader(site="datadoghq.com", api_key=api_key, app_key=app_key)
|
|
headers: Final = _DdAuthHeaders(api_key=api_key, app_key=app_key)
|
|
|
|
for value in (reader, headers):
|
|
assert api_key not in repr(value)
|
|
assert app_key not in repr(value)
|
|
|
|
assert headers.model_dump(by_alias=True) == {
|
|
"DD-API-KEY": api_key,
|
|
"DD-APPLICATION-KEY": app_key,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Clock:
|
|
elapsed: float = 0.0
|
|
|
|
def now(self) -> float:
|
|
return self.elapsed
|
|
|
|
def sleep(self, seconds: float) -> None:
|
|
self.elapsed += seconds
|
|
|
|
|
|
@dataclass
|
|
class Search:
|
|
responses: Iterator[StreamingResponse]
|
|
calls: tuple[tuple[str, float], ...] = ()
|
|
|
|
def __call__(self, query: str, timeout: float) -> StreamingResponse:
|
|
self.calls += ((query, timeout),)
|
|
return next(self.responses)
|
|
|
|
|
|
def _page(*event_ids: str) -> StreamingResponse:
|
|
return StreamingResponse(
|
|
status_code=200,
|
|
body=json.dumps({"data": [{"attributes": {"attributes": {"id": event_id}}} for event_id in event_ids]}),
|
|
)
|
|
|
|
|
|
def _reader(responses: Sequence[StreamingResponse], clock: Clock) -> tuple[DdLogsReader, Search]:
|
|
search: Final = Search(iter(responses))
|
|
return DdLogsReader(
|
|
site="us5.datadoghq.com",
|
|
api_key="test-api-secret",
|
|
app_key="test-app-secret",
|
|
search=search,
|
|
now=clock.now,
|
|
sleep=clock.sleep,
|
|
jitter=lambda: 0.25,
|
|
), search
|
|
|
|
|
|
def test_429_honors_server_reset_and_preserves_duplicate_events() -> None:
|
|
clock: Final = Clock()
|
|
reader, search = _reader(
|
|
(StreamingResponse(status_code=429, body="", headers={"x-ratelimit-reset": "6"}), _page("first", "duplicate")),
|
|
clock,
|
|
)
|
|
|
|
events: Final = reader.events_for_query("test-marker")
|
|
|
|
assert tuple(event.attributes["id"] for event in events) == ("first", "duplicate")
|
|
assert clock.elapsed == 6.25
|
|
assert search.calls == (("test-marker", 30.0), ("test-marker", 30.0))
|
|
|
|
|
|
@pytest.mark.parametrize("reset", ("", "invalid", "nan", "inf", "-1"))
|
|
def test_invalid_reset_uses_search_interval(reset: str) -> None:
|
|
clock: Final = Clock()
|
|
reader, _ = _reader(
|
|
(StreamingResponse(status_code=429, body="", headers={"x-ratelimit-reset": reset}), _page()), clock
|
|
)
|
|
|
|
assert reader.events_for_query("test-marker") == []
|
|
assert clock.elapsed == DD_SEARCH_INTERVAL + 0.25
|
|
|
|
|
|
def test_zero_reset_cannot_create_a_busy_retry_loop() -> None:
|
|
clock: Final = Clock()
|
|
reader, _ = _reader(
|
|
(StreamingResponse(status_code=429, body="", headers={"x-ratelimit-reset": "0"}), _page()), clock
|
|
)
|
|
|
|
assert reader.events_for_query("test-marker") == []
|
|
assert clock.elapsed == 1.25
|
|
|
|
|
|
def test_retry_after_is_not_shortened_by_an_earlier_reset() -> None:
|
|
clock: Final = Clock()
|
|
reader, _ = _reader(
|
|
(StreamingResponse(status_code=429, body="", headers={"x-ratelimit-reset": "2", "retry-after": "8"}), _page()),
|
|
clock,
|
|
)
|
|
|
|
assert reader.events_for_query("test-marker") == []
|
|
assert clock.elapsed == 8.25
|
|
|
|
|
|
def test_rate_limit_wait_stops_at_deadline_without_issuing_another_request() -> None:
|
|
clock: Final = Clock()
|
|
reader, search = _reader(
|
|
(StreamingResponse(status_code=429, body="", headers={"x-ratelimit-reset": str(POLL_TIMEOUT * 10)}),), clock
|
|
)
|
|
|
|
with pytest.raises(pytest.fail.Exception, match="remained rate-limited"):
|
|
reader.events_for_query("test-marker")
|
|
|
|
assert clock.elapsed == POLL_TIMEOUT
|
|
assert search.calls == (("test-marker", 30.0),)
|
|
|
|
|
|
def test_late_retry_cannot_receive_a_fresh_request_timeout() -> None:
|
|
clock: Final = Clock()
|
|
reader, search = _reader(
|
|
(StreamingResponse(status_code=429, body="", headers={"x-ratelimit-reset": str(POLL_TIMEOUT - 5)}), _page()),
|
|
clock,
|
|
)
|
|
|
|
assert reader.events_for_query("test-marker") == []
|
|
assert search.calls == (("test-marker", 30.0), ("test-marker", 4.75))
|
|
|
|
|
|
@pytest.mark.parametrize("status", (-1, 401, 403, 500))
|
|
def test_non_quota_failures_are_not_retried_or_treated_as_empty_results(status: int) -> None:
|
|
clock: Final = Clock()
|
|
reader, search = _reader((StreamingResponse(status_code=status, body=""), _page()), clock)
|
|
|
|
with pytest.raises(pytest.fail.Exception, match=f"failed with HTTP {status}"):
|
|
reader.events_for_query("test-marker")
|
|
|
|
assert search.calls == (("test-marker", 30.0),)
|
|
assert clock.elapsed == 0
|
|
|
|
|
|
def test_polling_quota_retries_share_the_original_deadline() -> None:
|
|
clock: Final = Clock()
|
|
reader, search = _reader(
|
|
(_page(), StreamingResponse(status_code=429, body="", headers={"x-ratelimit-reset": str(POLL_TIMEOUT)})),
|
|
clock,
|
|
)
|
|
|
|
with pytest.raises(pytest.fail.Exception, match="remained rate-limited"):
|
|
reader.poll_events_for_query("test-marker")
|
|
|
|
assert clock.elapsed == POLL_TIMEOUT
|
|
assert len(search.calls) == 2
|
|
|
|
|
|
def test_empty_polling_does_not_start_a_final_search_after_its_deadline() -> None:
|
|
clock: Final = Clock()
|
|
attempts: Final = int(POLL_TIMEOUT / DD_SEARCH_INTERVAL)
|
|
reader, search = _reader((_page(),) * attempts, clock)
|
|
|
|
assert reader.poll_events_for_query("test-marker") == []
|
|
assert clock.elapsed == POLL_TIMEOUT
|
|
assert len(search.calls) == attempts
|
|
|
|
|
|
def test_settlement_quota_retries_keep_the_remaining_readback_budget() -> None:
|
|
clock: Final = Clock()
|
|
empty_reads: Final = int(POLL_TIMEOUT / DD_SEARCH_INTERVAL) - 2
|
|
reader, search = _reader(
|
|
(_page(),) * empty_reads
|
|
+ (_page("first"), StreamingResponse(status_code=429, body="", headers={"x-ratelimit-reset": str(POLL_TIMEOUT)})),
|
|
clock,
|
|
)
|
|
|
|
with pytest.raises(pytest.fail.Exception, match="remained rate-limited"):
|
|
reader.poll_events_for_query("test-marker")
|
|
|
|
assert clock.elapsed == POLL_TIMEOUT
|
|
assert search.calls[-1] == ("test-marker", DD_SEARCH_INTERVAL)
|
|
assert len(search.calls) == empty_reads + 2
|
|
|
|
|
|
def test_settlement_detects_a_duplicate_on_the_final_search() -> None:
|
|
clock: Final = Clock()
|
|
reader, _ = _reader((_page("first"), _page("first"), _page(), _page("first", "duplicate")), clock)
|
|
|
|
events: Final = reader.poll_events_for_query("test-marker")
|
|
|
|
assert tuple(event.attributes["id"] for event in events) == ("first", "duplicate")
|
|
assert clock.elapsed == 30
|
|
|
|
|
|
def test_settlement_keeps_confirmed_events_through_empty_searches() -> None:
|
|
clock: Final = Clock()
|
|
reader, _ = _reader((_page("first"), _page(), _page(), _page()), clock)
|
|
|
|
events: Final = reader.poll_events_for_query("test-marker")
|
|
|
|
assert tuple(event.attributes["id"] for event in events) == ("first",)
|
|
assert clock.elapsed == 30
|
|
|
|
|
|
def test_late_delivery_cannot_pass_without_a_complete_settle_window() -> None:
|
|
clock: Final = Clock()
|
|
empty_reads: Final = int(POLL_TIMEOUT / DD_SEARCH_INTERVAL) - 2
|
|
reader, search = _reader((_page(),) * empty_reads + (_page("first"), _page("first")), clock)
|
|
|
|
with pytest.raises(pytest.fail.Exception, match="duplicate-detection window"):
|
|
reader.poll_events_for_query("test-marker")
|
|
|
|
assert clock.elapsed == POLL_TIMEOUT
|
|
assert len(search.calls) == empty_reads + 2
|