mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
* test(integration): isolate every multi-worker integration suite by default Per-worker database, Redis, upstream and proxy were opt-in for management, accounting, providers and extensions. mcp, security and cost ran 4 to 8 xdist workers against one shared proxy, database and Redis, and those suites hold most of the integration flakes CircleCI records. Every run with more than one worker now builds a private stack per worker, run_integration.sh no longer starts or exports the shared proxy, upstream, database or Redis in that mode, and each worker's scripted upstream runs in its own process instead of a thread in the pytest controller. Worker gateways get what the shared proxy used to provide: a peer for management and mcp, the scripted cost map for cost, stdio MCP servers, and coverage for mcp. mcp coverage now measures lines through sys.monitoring, because branch measurement on CPython 3.12 uses the trace function and that made a covered proxy take about 4x as long to become ready, which is why the shared mcp proxy kept missing its 90 s readiness wait. Routing parity snapshots now also read the worker databases, since its cost and mcp runs use several workers. The security per-worker fixture is redundant and removed * test(integration): give the nested probe root the files a worker gateway needs Two worker probes now run isolated, so the nested controller boots a real per-worker upstream and proxy from the probe root. That upstream imports the fake OpenAI server and the cost map modules, and the proxy reads proxy_config.yaml, none of which the probe copied. The nested run also drops INTEGRATION_COVERAGE_FILE so it never writes into an outer coverage file * test(integration): budget nested multi-worker runs for their worker stack boots A nested run.py database run with more than one worker now boots a private database, Redis, upstream and proxy per worker, one after another, before any case runs. The fixed 60 s subprocess timeout left no room for that, so the 2 worker probes sat at 57-60 s on CircleCI. The subprocess budget is now the old 60 s plus 30 s per worker stack, and the outer pytest timeout keeps its previous 30 s of slack on top. 30 s is the slowest worker proxy readiness recorded in startup.json on the integration-cost job of workflow e09b108b (21.6-30.3 s on 4 CPUs, 2026-10-12) --------- Co-authored-by: Yuneng Jiang <yuneng@berri.ai>
217 lines
8.8 KiB
Python
217 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import socket
|
|
import time
|
|
import uuid
|
|
from collections.abc import Iterator, Mapping
|
|
from contextlib import contextmanager, nullcontext
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import Final, Protocol
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
import httpx
|
|
import psycopg
|
|
from psycopg import sql
|
|
from pydantic import TypeAdapter
|
|
from redis import Redis
|
|
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
|
|
from tests.integration._support.client import Gateway
|
|
from tests.integration._support.database import scratch_database
|
|
from tests.integration._support.redis_process import owned_redis
|
|
from tests.integration._support.routing import WORKER_DATABASE_PREFIX
|
|
|
|
READINESS_OBJECT: Final = TypeAdapter(dict[str, object])
|
|
SUBSCRIBER_COUNTS: Final = TypeAdapter(tuple[tuple[str, int], ...])
|
|
|
|
|
|
class ServiceSubscribers(Protocol):
|
|
def pubsub_numsub(self, *channels: str) -> object: ...
|
|
|
|
|
|
def readiness_failure(
|
|
client: httpx.Client, upstream: str, proxies: tuple[Gateway, ...], cache: ServiceSubscribers
|
|
) -> str | None:
|
|
try:
|
|
if client.get(upstream + "/health", timeout=2).status_code != 200:
|
|
return "scripted upstream health"
|
|
for index, proxy in enumerate(proxies):
|
|
if proxy.client.get("/health/readiness", timeout=2).status_code != 200:
|
|
return f"proxy {index} readiness"
|
|
response: Final = proxy.client.get(
|
|
"/cache/ping", headers={"Authorization": f"Bearer {proxy.key}"}, timeout=2
|
|
)
|
|
if response.status_code != 200:
|
|
return f"proxy {index} Redis health HTTP {response.status_code}"
|
|
result: Final = READINESS_OBJECT.validate_json(response.content)
|
|
if (
|
|
result.get("status") != "healthy"
|
|
or result.get("cache_type") != "redis"
|
|
or result.get("ping_response") is not True
|
|
or result.get("set_cache_response") != "success"
|
|
):
|
|
return f"proxy {index} Redis read/write health"
|
|
subscribers: Final = SUBSCRIBER_COUNTS.validate_python(
|
|
cache.pubsub_numsub("litellm_proxy.auth_cache_invalidation")
|
|
)
|
|
if subscribers[0][1] < len(proxies):
|
|
return f"auth-cache subscribers {subscribers[0][1]}/{len(proxies)}"
|
|
except (httpx.TransportError, RedisConnectionError, RedisTimeoutError) as error:
|
|
return f"service transport {type(error).__name__}"
|
|
return None
|
|
|
|
|
|
def wait_for_services(upstream: str, proxies: tuple[Gateway, ...], cache: Redis) -> None:
|
|
with httpx.Client(trust_env=False, timeout=2) as client:
|
|
deadline: Final = time.monotonic() + 90
|
|
while True:
|
|
failure: Final = readiness_failure(client, upstream, proxies, cache)
|
|
if failure is None:
|
|
return
|
|
assert time.monotonic() < deadline, f"Integration services did not become ready: {failure}"
|
|
time.sleep(0.2)
|
|
|
|
|
|
def worker_endpoints() -> Mapping[str, str]:
|
|
with socket.socket() as upstream, socket.socket() as provider:
|
|
upstream.bind(("127.0.0.1", 0))
|
|
provider.bind(("127.0.0.1", 0))
|
|
return MappingProxyType(
|
|
{
|
|
"INTEGRATION_UPSTREAM_URL": f"http://127.0.0.1:{upstream.getsockname()[1]}",
|
|
"INTEGRATION_PROVIDER_PORT": str(provider.getsockname()[1]),
|
|
}
|
|
)
|
|
|
|
|
|
def _replica_urls(database: str) -> Mapping[str, str]:
|
|
writer: Final = urlsplit(os.environ["INTEGRATION_PROXY_DATABASE_URL"])
|
|
reader: Final = urlsplit(os.environ["INTEGRATION_PROXY_READ_REPLICA_URL"])
|
|
database_path: Final = urlsplit(database).path
|
|
assert writer.username is not None and reader.username is not None
|
|
with psycopg.connect(database, autocommit=True) as admin:
|
|
admin.execute(
|
|
sql.SQL("ALTER DATABASE {} OWNER TO {}").format(
|
|
sql.Identifier(database_path.removeprefix("/")), sql.Identifier(writer.username)
|
|
)
|
|
)
|
|
admin.execute(
|
|
sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format(
|
|
sql.Identifier(database_path.removeprefix("/")), sql.Identifier(reader.username)
|
|
)
|
|
)
|
|
admin.execute(sql.SQL("GRANT USAGE ON SCHEMA public TO {}").format(sql.Identifier(reader.username)))
|
|
admin.execute(
|
|
sql.SQL("ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA public GRANT SELECT ON TABLES TO {}").format(
|
|
sql.Identifier(writer.username), sql.Identifier(reader.username)
|
|
)
|
|
)
|
|
return MappingProxyType(
|
|
{
|
|
"INTEGRATION_PROXY_DATABASE_URL": urlunsplit(writer._replace(path=database_path)),
|
|
"INTEGRATION_PROXY_READ_REPLICA_URL": urlunsplit(reader._replace(path=database_path)),
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class GatewayProfile:
|
|
peer: bool
|
|
replica: bool
|
|
cost_map: bool
|
|
coverage_file: Path | None
|
|
|
|
|
|
def gateway_profile(environment: Mapping[str, str]) -> GatewayProfile:
|
|
coverage: Final = environment.get("INTEGRATION_COVERAGE_FILE", "")
|
|
return GatewayProfile(
|
|
peer=environment.get("INTEGRATION_WORKER_PEER") == "1",
|
|
replica=bool(environment.get("INTEGRATION_PROXY_READ_REPLICA_URL")),
|
|
cost_map=environment.get("INTEGRATION_WORKER_COST_MAP") == "1",
|
|
coverage_file=Path(coverage) if coverage else None,
|
|
)
|
|
|
|
|
|
def cost_map_environment(upstream: str) -> Mapping[str, str]:
|
|
return MappingProxyType(
|
|
{
|
|
"LITELLM_MODEL_COST_MAP_URL": f"{upstream}/_cost_map",
|
|
"MODEL_COST_MAP_MIN_MODEL_COUNT": "1",
|
|
"MODEL_COST_MAP_MAX_SHRINK_RATIO": "0",
|
|
"GEMINI_API_BASE": upstream,
|
|
"ANTHROPIC_API_BASE": upstream,
|
|
"GEMINI_API_KEY": "sk-scripted-provider",
|
|
"ANTHROPIC_API_KEY": "sk-scripted-provider",
|
|
}
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def worker_services(directory: Path, *, replica: bool = False) -> Iterator[Mapping[str, str]]:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
with (
|
|
scratch_database(WORKER_DATABASE_PREFIX) as database,
|
|
owned_redis(directory) as cache,
|
|
):
|
|
yield MappingProxyType(
|
|
{
|
|
"DATABASE_URL": database,
|
|
"INTEGRATION_PROXY_DATABASE_URL": database,
|
|
"INTEGRATION_PROXY_READ_REPLICA_URL": "",
|
|
"REDIS_HOST": cache.host,
|
|
"REDIS_PORT": str(cache.port),
|
|
**(_replica_urls(database) if replica else {}),
|
|
}
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def worker_gateway(directory: Path, profile: GatewayProfile) -> Iterator[Mapping[str, str]]:
|
|
from tests.integration._support.process import owned_proxy, owned_upstream
|
|
|
|
upstream: Final = os.environ["INTEGRATION_UPSTREAM_URL"]
|
|
port: Final = urlsplit(upstream).port
|
|
assert port is not None
|
|
key: Final = f"sk-{uuid.uuid4().hex}"
|
|
removed: Final = ("LITELLM_LOCAL_MODEL_COST_MAP",) if profile.cost_map else ()
|
|
with worker_services(directory, replica=profile.replica) as services:
|
|
overrides: Final = {
|
|
**services,
|
|
"DATABASE_URL": services["INTEGRATION_PROXY_DATABASE_URL"],
|
|
**(
|
|
{"DATABASE_URL_READ_REPLICA": services["INTEGRATION_PROXY_READ_REPLICA_URL"]} if profile.replica else {}
|
|
),
|
|
"LITELLM_ENABLE_MCP_STDIO": "true",
|
|
**(cost_map_environment(upstream) if profile.cost_map else {}),
|
|
}
|
|
with (
|
|
owned_upstream(directory, port=port),
|
|
httpx.Client(base_url="http://127.0.0.1:1", trust_env=False) as client,
|
|
owned_proxy(
|
|
Gateway(client, key, upstream),
|
|
directory,
|
|
overrides,
|
|
remove_environment=removed,
|
|
coverage_file=profile.coverage_file,
|
|
) as primary,
|
|
owned_proxy(primary, directory, overrides, remove_environment=removed, coverage_file=profile.coverage_file)
|
|
if profile.peer
|
|
else nullcontext(None) as secondary,
|
|
):
|
|
with Redis(host=services["REDIS_HOST"], port=int(services["REDIS_PORT"]), socket_timeout=2) as cache:
|
|
wait_for_services(upstream, (primary, secondary) if secondary is not None else (primary,), cache)
|
|
primary.post("/config/update", {"router_settings": {"num_retries": 0}})
|
|
yield MappingProxyType(
|
|
{
|
|
**services,
|
|
"INTEGRATION_PROXY_URL": str(primary.client.base_url),
|
|
"INTEGRATION_PEER_URL": str(secondary.client.base_url) if secondary is not None else "",
|
|
"INTEGRATION_MASTER_KEY": key,
|
|
"LITELLM_MASTER_KEY": key,
|
|
"INTEGRATION_UPSTREAM_URL": upstream,
|
|
}
|
|
)
|