mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +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
215 lines
8.8 KiB
Python
215 lines
8.8 KiB
Python
"""Read-back for the DataDog logging tests against the real DataDog Logs
|
|
Search API.
|
|
|
|
Delivery is judged on what DataDog itself ingested: the proxy ships logs with
|
|
DD_API_KEY exactly as in production (no base-URL override, no local sink), and
|
|
the tests search the ingested events back with POST /api/v2/logs/events/search,
|
|
authenticated with the same DD_API_KEY plus a DD_APP_KEY application key. On
|
|
the cluster the secret manager injects both keys; locally tests/e2e/.env
|
|
provides them. Missing keys or a failed search call are hard failures, never an
|
|
empty result. External reads go through ``e2e_http``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import random
|
|
import time
|
|
from collections.abc import Callable, Mapping
|
|
from dataclasses import dataclass, field
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from e2e_config import (
|
|
DD_API_KEY,
|
|
DD_APP_KEY,
|
|
DD_SEARCH_FROM,
|
|
DD_SEARCH_INTERVAL,
|
|
DD_SETTLE_SECONDS,
|
|
DD_SITE,
|
|
POLL_TIMEOUT,
|
|
)
|
|
from e2e_http import URL, Headers, StreamingResponse, send
|
|
|
|
type SearchCall = Callable[[str, float], StreamingResponse]
|
|
|
|
|
|
def _seconds(value: str | None) -> float | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
seconds: Final = float(value)
|
|
except ValueError:
|
|
return None
|
|
return seconds if math.isfinite(seconds) and seconds >= 0 else None
|
|
|
|
|
|
def _rate_limit_delay(headers: Mapping[str, str]) -> float:
|
|
delays: Final = tuple(
|
|
delay
|
|
for name in ("x-ratelimit-reset", "retry-after")
|
|
if (delay := _seconds(headers.get(name))) is not None
|
|
)
|
|
return max(1.0, max(delays, default=DD_SEARCH_INTERVAL))
|
|
|
|
|
|
class _DdAuthHeaders(Headers):
|
|
api_key: str = Field(serialization_alias="DD-API-KEY", repr=False)
|
|
app_key: str = Field(serialization_alias="DD-APPLICATION-KEY", repr=False)
|
|
|
|
|
|
class _SearchFilter(BaseModel):
|
|
query: str
|
|
#: Wide enough to cover a full suite run plus DataDog's ingestion lag;
|
|
#: markers are unique per test, so a wide window cannot match foreign events.
|
|
#: Override via E2E_DD_SEARCH_FROM when CI lookback needs more than the default.
|
|
from_: str = Field(default_factory=lambda: DD_SEARCH_FROM, serialization_alias="from")
|
|
to: str = "now"
|
|
|
|
|
|
class _SearchPage(BaseModel):
|
|
limit: int = 100
|
|
|
|
|
|
class _SearchRequest(BaseModel):
|
|
filter: _SearchFilter
|
|
page: _SearchPage = _SearchPage()
|
|
sort: str = "timestamp"
|
|
|
|
|
|
class DdLogEvent(BaseModel):
|
|
"""One ingested log event as the search API returns it: the indexed
|
|
envelope (service/status/tags) plus ``attributes`` - DataDog's parse of the
|
|
JSON message the integration shipped, i.e. the StandardLoggingPayload
|
|
fields."""
|
|
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
service: str | None = None
|
|
status: str | None = None
|
|
tags: list[str] = []
|
|
attributes: dict[str, object] = {}
|
|
|
|
|
|
class _SearchEvent(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
attributes: DdLogEvent
|
|
|
|
|
|
class _SearchResponse(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
data: list[_SearchEvent] = []
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DdLogsReader:
|
|
site: str
|
|
api_key: str = field(repr=False)
|
|
app_key: str = field(repr=False)
|
|
search: SearchCall | None = field(default=None, repr=False)
|
|
now: Callable[[], float] = field(default=time.monotonic, repr=False)
|
|
sleep: Callable[[float], None] = field(default=time.sleep, repr=False)
|
|
jitter: Callable[[], float] = field(default=random.random, repr=False)
|
|
|
|
def events_for_marker(self, marker: str) -> list[DdLogEvent]:
|
|
"""Every ingested event whose attributes carry the marker. DataDog
|
|
consumes the shipped JSON message into ``attributes`` and leaves the
|
|
indexed ``message`` empty, so a plain full-text query matches nothing;
|
|
``*:`` extends the scan to every attribute (the marker sits in the
|
|
prompt, e.g. ``messages.content``, wherever the route's payload puts
|
|
it)."""
|
|
return self.events_for_query(f"*:*{marker}*")
|
|
|
|
def events_for_query(self, query: str) -> list[DdLogEvent]:
|
|
"""Every ingested event the search query matches (failure payloads
|
|
carry no prompt to mark, so failure scenarios query indexed attributes
|
|
like ``@model_group:...`` instead of a body marker). More than one hit
|
|
for one call IS the duplicate-delivery bug, so this never collapses to
|
|
a single event. A 429 backs off and retries - the search budget is
|
|
org-wide, so another consumer can empty it under us - while any other
|
|
failure stays a hard fail."""
|
|
return self._events_for_query(query, self.now() + POLL_TIMEOUT)
|
|
|
|
def _events_for_query(self, query: str, deadline: float) -> list[DdLogEvent]:
|
|
search: Final = self.search or self._search_page
|
|
while (remaining := deadline - self.now()) > 0:
|
|
if (result := search(query, min(30.0, remaining))).ok:
|
|
return [event.attributes for event in _SearchResponse.model_validate_json(result.body).data]
|
|
if result.status_code != 429:
|
|
pytest.fail(f"DataDog Logs Search API at api.{self.site} failed with HTTP {result.status_code}")
|
|
if (delay := min(_rate_limit_delay(result.headers) + self.jitter(), deadline - self.now())) > 0:
|
|
self.sleep(delay)
|
|
pytest.fail(
|
|
f"DataDog Logs Search API at api.{self.site} remained rate-limited for {POLL_TIMEOUT}s; "
|
|
"the org-wide logs_public_search_api budget is exhausted"
|
|
)
|
|
|
|
def _search_page(self, query: str, timeout: float) -> StreamingResponse:
|
|
return send(
|
|
URL(f"https://api.{self.site}/api/v2/logs/events/search"),
|
|
headers=_DdAuthHeaders(api_key=self.api_key, app_key=self.app_key),
|
|
json=_SearchRequest(filter=_SearchFilter(query=query)),
|
|
timeout=timeout,
|
|
)
|
|
|
|
def poll_events_for_marker(self, marker: str) -> list[DdLogEvent]:
|
|
"""``poll_events_for_query`` over the every-attribute marker scan."""
|
|
return self.poll_events_for_query(f"*:*{marker}*")
|
|
|
|
def poll_events_for_query(self, query: str) -> list[DdLogEvent]:
|
|
"""Poll until at least one matching event is searchable (the callback
|
|
flushes in periodic batches and DataDog ingestion adds seconds of lag),
|
|
then keep re-reading for DD_SETTLE_SECONDS so a late duplicate cannot
|
|
hide from the exactly-one assertion - real-DataDog jitter can surface
|
|
one call's two events tens of seconds apart. Searches pace at
|
|
DD_SEARCH_INTERVAL, not POLL_INTERVAL, to respect the search API's
|
|
request budget. Discovery, quota retries, and duplicate detection share
|
|
one POLL_TIMEOUT deadline; an incomplete settle window fails closed."""
|
|
deadline: Final = self.now() + POLL_TIMEOUT
|
|
while (remaining := deadline - self.now()) > 0:
|
|
events = self._events_for_query(query, deadline)
|
|
if events:
|
|
return self._settled_events_for_query(query, events, deadline)
|
|
if (remaining := deadline - self.now()) > 0:
|
|
self.sleep(min(DD_SEARCH_INTERVAL, remaining))
|
|
return []
|
|
|
|
def _settled_events_for_query(self, query: str, events: list[DdLogEvent], deadline: float) -> list[DdLogEvent]:
|
|
"""Re-read at every search interval until the settle window closes; a
|
|
duplicate ends the watch early because more waiting cannot clear it.
|
|
|
|
Keep the last non-empty result: a transient empty search (index lag)
|
|
must not erase events already confirmed earlier in the settle window.
|
|
A successful final search must reach the full settle window before the
|
|
shared read-back deadline; otherwise duplicate detection is incomplete.
|
|
"""
|
|
settle_deadline: Final = self.now() + DD_SETTLE_SECONDS
|
|
last_nonempty = events
|
|
if len(events) > 1:
|
|
return events
|
|
while (remaining := deadline - self.now()) > 0:
|
|
self.sleep(min(DD_SEARCH_INTERVAL, remaining))
|
|
if self.now() >= deadline:
|
|
break
|
|
latest = self._events_for_query(query, deadline)
|
|
if len(latest) > 1:
|
|
return latest
|
|
if latest:
|
|
last_nonempty = latest
|
|
if self.now() >= settle_deadline:
|
|
return last_nonempty
|
|
pytest.fail(f"DataDog log delivery could not complete its duplicate-detection window within {POLL_TIMEOUT}s")
|
|
|
|
|
|
def build_dd_logs_reader() -> DdLogsReader:
|
|
if not DD_API_KEY or not DD_APP_KEY:
|
|
pytest.fail(
|
|
"DD_API_KEY and DD_APP_KEY must be set: the DataDog tests deliver to and "
|
|
"read back from the real DataDog API (on the cluster the secret manager "
|
|
"injects them; locally set them in tests/e2e/.env)"
|
|
)
|
|
return DdLogsReader(site=DD_SITE, api_key=DD_API_KEY, app_key=DD_APP_KEY)
|