litellm/tests/integration/security/_sinks.py
yuneng-jiang 9b53ee878f
perf(ci): isolate and shard integration suites (#44146)
* perf(ci): distribute unit cases and reuse security sweep connections

* revert(ci): reject first speed trial after new failures

* perf(ci): avoid repeated test discovery and parsing

* perf(ci): isolate parallel security workers

* revert(ci): restore measured security execution isolation

* perf(tests): reuse source scans and deduplicate cache cleanup

* perf(ci): restore parallel security with safe log batching

* test(ci): isolate fixture state and schema comparisons

* test(ci): make schema and scheduler fixtures deterministic

* test(ci): drain queued logging before fixture teardown

* test(ci): preserve logging delivery without idle worker restarts

* test(ci): keep guardrail sinks available during parallel bursts

* test(ci): isolate full proxy lifecycle from gateway imports

* test(ci): preserve first failure responses and sweep evidence

* test(ci): restore documentation URL settings between cases

* test(ci): isolate open MCP tools in private gateways

* test(ci): group private MCP cases and handle exited workers

* test(ci): retain proxy exits and MCP failure details

* test(ci): repair merge interactions with main

Key the schema shard's full clone on artifact-name, since main removed the
unit-flag input. Give each owned Redis its own log file again. Hand the
realtime transcription tests main added a logging object whose dispatch
handlers are awaitable, so the per-test logging drain no longer waits on a
MagicMock task forever

* perf(ci): shard owned integration cases and close lifecycle leaks

* fix(ci): qualify shard execution and harden interrupted cleanup

* fix(ci): bound worker supervision and own fault traffic

* perf(ci): reuse unparametrized fixtures across bounded case groups

* fix(ci): preserve boot fault allowances and complete exit records

* test(ci): tolerate startup retries and retain readiness details

* perf(ci): balance integration shards by recorded duration

* fix(ci): load migration database helpers only for workers
2026-10-10 11:15:19 -07:00

301 lines
13 KiB
Python

"""The owned proxy every canary scenario runs against, with its provider and sink doubles.
``canary_rig(root)`` starts a provider double and a ``generic_api`` sink double, writes an owned
config derived from ``tests/integration/proxy_config.yaml`` and starts an owned proxy on it:
- ``store_prompts_in_spend_logs`` is on, so the stored request body exists for every sweep;
- Redis response-cache entries live 600 s, longer than any scenario's sweeps;
- spend logs flush every second (``proxy_batch_write_at``) and callbacks flush every second
(``DEFAULT_FLUSH_INTERVAL_SECONDS``), so ``eventually`` converges quickly;
- provider-default routes (file, batch, container lists with no deployment) resolve to the
provider double through ``OPENAI_BASE_URL``, and the remote catalogs (cost map, blog posts,
beta headers, autorouter presets, policy templates) are read from the package, so a route
sweep never leaves the machine;
- ``HTTP(S)_PROXY`` points at an egress trap that answers every connection with 403 and
records its first line; the rig fails on exit if the proxy tried to reach any non-loopback
host (``Rig.egress()`` lists the attempts so far);
- the config ``model_list`` declares ``CONFIG_MODEL`` whose ``api_key`` is a fresh slot B1
canary, reaching the provider double at ``<provider>/v1``.
API:
- ``canary_rig(root, *, configure=None, environment=None, upstream=None, sink_token=SINK_TOKEN)
-> Iterator[Rig]``. ``configure(config, provider_url)`` may edit the parsed config before it
is written (add deployments, settings, callbacks); ``environment`` adds or overrides proxy
environment variables; ``upstream`` replaces ``chat_upstream`` as the provider double's
handler. ``sink_token`` is the bearer the ``generic_api`` double requires and
``GENERIC_LOGGER_HEADERS`` sends; pass a ``Canary`` (slot G1 style) to plant a sink credential,
and ``Rig.own_headers`` then allows that one header to carry it (pass it to ``sweep_all``).
- ``Rig.model_id``: the router's ``model_info.id`` for ``CONFIG_MODEL`` (read from
``/model/info`` once the proxy is up). Pass it as ``ids["model_id"]`` so the
``{model_id}`` routes (``/credentials/by_model/{model_id}``, ...) resolve the deployment.
- ``Rig.proxy``: the owned proxy ``Gateway`` with its master key (the ``LITELLM_MASTER_KEY``
from ``environment`` when a scenario overrides it). ``Rig.canaries``: config-held
canaries by slot id. ``Rig.provider`` and ``Rig.sinks[name]``: ``Recorder`` objects whose
``requests()`` returns every request received so far (the underlying queue is drained into a
list, so repeated polls keep earlier requests).
- ``chat_upstream(request)``: an OpenAI chat double that echoes the last user message and
answers HTTP 400 when the message contains ``PROVIDER_4XX``.
- ``SINK_TOKEN``: the default static bearer the ``generic_api`` sink authenticates with.
- ``team_caller(scenario) -> Caller``: a team, an ``internal_user`` on it and a virtual key for
that user on that team (allowed ``CONFIG_MODEL``). Scenarios send traffic with ``Caller.key``
and pass ``Caller.callers(rig)`` to ``sweep_all`` so S2 reads every route as the admin and as
the internal user.
- ``settle(rig, request_id, marker)``: wait (bounded) until the spend row for ``request_id``
is written and every sink double has received an event carrying ``marker``, so the sweeps
that follow read the finished state instead of racing the asynchronous writers.
"""
from __future__ import annotations
import json
import socket
import threading
import uuid
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from types import MappingProxyType
from pathlib import Path
from typing import Final
import yaml
from integration._support.client import Gateway, Scenario, eventually, gateway_from_environment
from integration._support.database import read_rows
from integration._support.process import OwnedProxy, owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from integration.security._canary import Canary, canary
STOCK_CONFIG: Final = Path("tests/integration/proxy_config.yaml")
CONFIG_MODEL: Final = "canary-config-deployment"
PROVIDER_4XX: Final = "canary-provider-4xx"
SINK_TOKEN: Final = "synthetic-canary-sink-token"
GENERIC_SINK: Final = "generic_api"
LOCAL_CATALOGS: Final = MappingProxyType(
{
name: "True"
for name in (
"LITELLM_LOCAL_MODEL_COST_MAP",
"LITELLM_LOCAL_BLOG_POSTS",
"LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS",
"LITELLM_LOCAL_AUTOROUTER_PRESETS",
"LITELLM_LOCAL_POLICY_TEMPLATES",
)
}
)
@dataclass(slots=True)
class Recorder:
wire: Wire
seen: list[Request] = field(default_factory=list) # mutable-ok: drain() consumes the queue
@property
def url(self) -> str:
return self.wire.url
def requests(self) -> tuple[Request, ...]:
self.seen.extend(self.wire.drain())
return tuple(self.seen)
def carrying(self, text: str) -> tuple[Request, ...]:
"""Requests whose body contains ``text``."""
return tuple(request for request in self.requests() if text.encode() in request.body)
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
owned: OwnedProxy
provider: Recorder
sinks: Mapping[str, Recorder]
canaries: Mapping[str, Canary]
own_headers: Mapping[str, tuple[str, str]] = field(default_factory=lambda: MappingProxyType({}))
egress: Callable[[], tuple[bytes, ...]] = field(default=lambda: ())
model_id: str = ""
def chat_upstream(request: Request) -> Reply:
body: Final = json.loads(request.body or b"{}")
messages: Final = body.get("messages") or [{"content": ""}]
text: Final = str(messages[-1].get("content", ""))
if PROVIDER_4XX in text:
return Reply(
status=400,
body=json.dumps(
{"error": {"type": "invalid_request_error", "code": "canary_rejected", "message": "rejected"}}
).encode(),
)
return Reply(
body=json.dumps(
{
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "echo " + text}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
}
).encode()
)
def _sink_for(token: str) -> Callable[[Request], Reply]:
def sink(request: Request) -> Reply:
assert request.headers.get("authorization") == f"Bearer {token}", "sink double got a foreign bearer"
return Reply()
return sink
@contextmanager
def _egress_trap() -> Iterator[tuple[str, Callable[[], tuple[bytes, ...]]]]:
"""A forward-proxy stand-in: records the first line of every connection, answers 403."""
attempts: Final[list[bytes]] = [] # mutable-ok: appended by the accept thread
server: Final = socket.create_server(("127.0.0.1", 0))
server.settimeout(0.2)
stopped: Final = threading.Event()
def serve() -> None:
while not stopped.is_set():
try:
connection, _ = server.accept()
except TimeoutError:
continue
except OSError:
return
with connection:
connection.settimeout(2)
try:
attempts.append(connection.recv(512).split(b"\r\n", 1)[0])
connection.sendall(b"HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
except OSError:
pass
thread: Final = threading.Thread(target=serve, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.getsockname()[1]}", lambda: tuple(attempts)
finally:
stopped.set()
thread.join(timeout=5)
server.close()
def _config(
root: Path, provider_url: str, b1: Canary, configure: Callable[[dict[str, object], str], None] | None
) -> Path:
config: Final = yaml.safe_load(STOCK_CONFIG.read_text())
config["model_list"] = [
{
"model_name": CONFIG_MODEL,
"litellm_params": {"model": "openai/gpt-4o-mini", "api_base": provider_url + "/v1", "api_key": b1.value},
}
]
config["general_settings"]["store_prompts_in_spend_logs"] = True
config["litellm_settings"]["cache_params"]["ttl"] = 600
config["litellm_settings"].update({"callbacks": [GENERIC_SINK], "DEFAULT_FLUSH_INTERVAL_SECONDS": 1})
if configure is not None:
configure(config, provider_url)
path: Final = root / f"canary-{uuid.uuid4().hex}.yaml"
path.write_text(yaml.safe_dump(config))
return path
@contextmanager
def canary_rig(
root: Path,
*,
configure: Callable[[dict[str, object], str], None] | None = None,
environment: Mapping[str, str] | None = None,
upstream: Callable[[Request], Reply] | None = None,
sink_token: str | Canary = SINK_TOKEN,
) -> Iterator[Rig]:
b1: Final = canary("B1")
token: Final = sink_token.value if isinstance(sink_token, Canary) else sink_token
own_headers: Final = MappingProxyType(
{GENERIC_SINK: ("authorization", sink_token.slot)} if isinstance(sink_token, Canary) else {}
)
planted: Final = {"B1": b1, **({sink_token.slot: sink_token} if isinstance(sink_token, Canary) else {})}
with (
gateway_from_environment() as gateway,
wire_server(upstream or chat_upstream) as provider,
wire_server(_sink_for(token)) as sink,
_egress_trap() as (trap_url, egress),
):
config: Final = _config(root, provider.url, b1, configure)
overrides: Final = {
**LOCAL_CATALOGS,
**{name: trap_url for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")},
**{name: "127.0.0.1,localhost" for name in ("NO_PROXY", "no_proxy")},
"OPENAI_BASE_URL": provider.url + "/v1",
"OPENAI_API_BASE": provider.url + "/v1",
"ANTHROPIC_API_BASE": provider.url,
"ANTHROPIC_BASE_URL": provider.url,
"ANTHROPIC_API_KEY": b1.value,
"ANTHROPIC_AUTH_TOKEN": "",
"GENERIC_LOGGER_ENDPOINT": sink.url,
"GENERIC_LOGGER_HEADERS": f"Authorization=Bearer {token}",
**(environment or {}),
}
with owned_proxy_process(gateway, root, overrides, config=config) as owned:
admin = Gateway(
owned.gateway.client, overrides.get("LITELLM_MASTER_KEY", owned.gateway.key), owned.gateway.upstream_url
)
yield Rig(
admin,
owned,
Recorder(provider),
MappingProxyType({GENERIC_SINK: Recorder(sink)}),
MappingProxyType(planted),
own_headers,
egress,
config_model_id(admin),
)
assert egress() == (), f"Owned proxy tried to reach external hosts: {sorted(set(egress()))}"
def config_model_id(gateway: Gateway) -> str:
"""The router's ``model_info.id`` for the ``CONFIG_MODEL`` deployment."""
data: Final = gateway.get("/model/info").get("data")
assert isinstance(data, list), data
found: Final = tuple(
info["id"]
for entry in data
if isinstance(entry, dict)
and entry.get("model_name") == CONFIG_MODEL
and isinstance(info := entry.get("model_info"), dict)
and isinstance(info.get("id"), str)
)
assert len(found) == 1, f"expected one {CONFIG_MODEL} deployment in /model/info, got {found}"
return str(found[0])
@dataclass(frozen=True, slots=True)
class Caller:
team_id: str
user_id: str
key: str
def callers(self, rig: Rig) -> Mapping[str, str]:
return {"admin": rig.proxy.key, "internal_user": self.key}
def team_caller(scenario: Scenario) -> Caller:
team: Final = scenario.team()
user: Final = scenario.user(user_role="internal_user")
scenario.gateway.post("/team/member_add", {"team_id": team, "member": {"user_id": user, "role": "user"}})
key: Final = scenario.key(team_id=team, user_id=user, models=[CONFIG_MODEL])
return Caller(team, user, key)
def settle(rig: Rig, request_id: str, marker: Canary) -> None:
eventually(
lambda: read_rows('SELECT request_id FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (request_id,)),
lambda rows: len(rows) == 1,
seconds=70,
)
for sink in rig.sinks.values():
eventually(lambda sink=sink: sink.carrying(marker.core), bool, seconds=30)