mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
* test(integration): give each owned proxy a private database and Redis by default owned_proxy and owned_proxy_process now boot on a scratch database and a per-proxy redis-server unless the caller opts into the worker's state with state=OwnedState.SHARED_WITH_WORKER, passes its own DATABASE_URL, or strips DATABASE_URL. Under the replica profile the private database gets the writer and reader roles so the reader path stays covered. Gateway carries the database_url its proxy writes to, and the Scenario cleanup checks and delete_key_if_present read from it, so they keep checking something on a private database. The 189 test files with owned proxy sites that depend on the worker's database or Redis opt in with a shares_worker_state module marker, which a module-scoped conftest fixture turns into the shared state. The 7 helper modules with such sites opt in per call, since conftest fixtures and other test files reach them. * test(integration): reach session fixtures with the shared marker and keep the worker retry default The module-scoped fixture that applied shares_worker_state ran after session-scoped fixtures, so proxies those booted went private in marked files. The marker is now applied around each marked item's setup, call and teardown, which covers fixtures of every scope. Owned proxies on the worker database inherited the worker gateway's router_settings.num_retries of 0 from it. A private proxy now gets the same default in its config copy unless the config sets num_retries itself. Five providers files read the owned proxy's spend rows or response cache through the worker's database or Redis and opt into sharing.
263 lines
11 KiB
Python
263 lines
11 KiB
Python
import asyncio
|
|
import json
|
|
import re
|
|
import threading
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from queue import SimpleQueue
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
import psutil
|
|
import pytest
|
|
import yaml
|
|
from integration._support.client import Gateway, eventually
|
|
from integration._support.database import read_rows
|
|
from integration._support.process import owned_proxy_process
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from integration._support.ports import allocate_port
|
|
from pydantic import JsonValue, TypeAdapter
|
|
|
|
pytestmark: Final = pytest.mark.shares_worker_state
|
|
|
|
_BACKEND: Final = "pplx-decider-v1-27b"
|
|
_CONFIG_MODEL: Final = "decisions-chaos"
|
|
_API_KEY: Final = "synthetic-decisions-key"
|
|
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
|
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
|
|
_QUESTIONS: Final[dict[str, JsonValue]] = {"fine": {"type": "noul", "instructions": "Is the state fine?"}}
|
|
_ROUTES: Final = ("/v1/systemone", "/systemone")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Call:
|
|
route: str
|
|
marker: str
|
|
fail: bool
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Served:
|
|
call: _Call
|
|
status: int
|
|
call_id: str
|
|
model_group: str
|
|
text: str
|
|
|
|
|
|
def _calls(count: int, *, fail: bool) -> tuple[_Call, ...]:
|
|
return tuple(
|
|
_Call(
|
|
route=_ROUTES[index % len(_ROUTES)],
|
|
marker=f"{'fail' if fail and index % 2 else 'ok'}-{uuid.uuid4().hex}",
|
|
fail=fail and index % 2 == 1,
|
|
)
|
|
for index in range(count)
|
|
)
|
|
|
|
|
|
def _marker_of(request: Request) -> str:
|
|
state: Final = _JSON_OBJECT.validate_json(request.body)["state"]
|
|
assert isinstance(state, str), request.body
|
|
return state
|
|
|
|
|
|
def _reply(request: Request) -> Reply:
|
|
marker: Final = _marker_of(request)
|
|
if marker.startswith("fail-"):
|
|
return Reply(status=500, body=json.dumps({"error": {"message": f"scripted outage {marker}"}}).encode())
|
|
answer: Final = {
|
|
"model": f"model-{marker}",
|
|
"answers": {"fine": {"type": "noul", "noul": 0.5}},
|
|
"usage": {"input_tokens": 12, "output_tokens": 1},
|
|
}
|
|
return Reply(body=json.dumps(answer).encode())
|
|
|
|
|
|
async def _send(client: httpx.AsyncClient, key: str, model: str | None, call: _Call) -> _Served:
|
|
body: Final[dict[str, JsonValue]] = {
|
|
**({"model": model} if model is not None else {}),
|
|
"state": call.marker,
|
|
"questions": _QUESTIONS,
|
|
"num_retries": 0,
|
|
}
|
|
response: Final = await client.post(call.route, json=body, headers={"Authorization": f"Bearer {key}"})
|
|
return _Served(
|
|
call=call,
|
|
status=response.status_code,
|
|
call_id=response.headers.get("x-litellm-call-id", ""),
|
|
model_group=response.headers.get("x-litellm-model-group", ""),
|
|
text=response.text,
|
|
)
|
|
|
|
|
|
async def _burst(
|
|
base_url: str, key: str, model: str | None, calls: tuple[_Call, ...], *, tolerate_transport_errors: bool = False
|
|
) -> tuple[_Served, ...]:
|
|
async with httpx.AsyncClient(base_url=base_url, timeout=60, trust_env=False) as client:
|
|
results: Final = await asyncio.gather(
|
|
*(_send(client, key, model, call) for call in calls), return_exceptions=tolerate_transport_errors
|
|
)
|
|
for result in results:
|
|
assert not isinstance(result, BaseException) or isinstance(result, httpx.TransportError), repr(result)
|
|
return tuple(result for result in results if isinstance(result, _Served))
|
|
|
|
|
|
def _assert_served_its_own(served: _Served) -> None:
|
|
assert served.call_id, served.text
|
|
if served.call.fail:
|
|
assert served.status == 500, (served.status, served.text)
|
|
assert served.call.marker in served.text, served.text
|
|
return
|
|
assert served.status == 200, (served.status, served.text)
|
|
assert _JSON_OBJECT.validate_json(served.text)["model"] == f"model-{served.call.marker}", served.text
|
|
|
|
|
|
def _statuses_by_call_id(call_ids: tuple[str, ...]) -> dict[str, JsonValue]:
|
|
placeholders: Final = ", ".join("%s" for _ in call_ids)
|
|
rows: Final = eventually(
|
|
lambda: read_rows(
|
|
f'SELECT request_id, status FROM "LiteLLM_SpendLogs" WHERE request_id IN ({placeholders})', call_ids
|
|
),
|
|
lambda found: len(found) >= len(call_ids),
|
|
seconds=70,
|
|
)
|
|
assert len(rows) == len(call_ids), rows
|
|
return {str(row["request_id"]): row["status"] for row in rows}
|
|
|
|
|
|
def _expected_statuses(served: tuple[_Served, ...]) -> dict[str, JsonValue]:
|
|
return {item.call_id: "failure" if item.call.fail else "success" for item in served}
|
|
|
|
|
|
def _health(gateway: Gateway, model: str) -> tuple[int, int]:
|
|
health: Final = gateway.request("GET", "/health", params={"model": model})
|
|
assert health.status_code in (200, 503), health.text
|
|
report: Final = health.json()
|
|
return (report["healthy_count"], report["unhealthy_count"])
|
|
|
|
|
|
def _chaos_config(wire: Wire, tmp_path: Path) -> Path:
|
|
config: Final = {
|
|
**_JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())),
|
|
"model_list": [
|
|
{
|
|
"model_name": _CONFIG_MODEL,
|
|
"litellm_params": {"model": f"perplexity/{_BACKEND}", "api_base": wire.url, "api_key": _API_KEY},
|
|
}
|
|
],
|
|
}
|
|
path: Final = tmp_path / "decisions-chaos.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
return path
|
|
|
|
|
|
def _worker_startups(log: Path) -> tuple[tuple[int, ...], int]:
|
|
text: Final = log.read_text()
|
|
return tuple(int(pid) for pid in _STARTED_WORKER.findall(text)), text.count("Application startup complete.")
|
|
|
|
|
|
def _open_upstream_connections(pid: int, upstream: str) -> int:
|
|
port: Final = urlsplit(upstream).port
|
|
return sum(
|
|
1
|
|
for connection in psutil.Process(pid).net_connections(kind="tcp")
|
|
if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port
|
|
)
|
|
|
|
|
|
@pytest.mark.timeout(180)
|
|
async def test_burst_over_both_routes_bills_each_call_once_with_its_own_status(gateway: Gateway) -> None:
|
|
calls: Final = _calls(30, fail=True)
|
|
with wire_server(_reply) as wire, gateway.scenario() as scenario:
|
|
model: Final = scenario.model(model=f"perplexity/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
|
|
served: Final = await _burst(str(gateway.client.base_url), gateway.key, model, calls)
|
|
assert len(served) == 30
|
|
for item in served:
|
|
_assert_served_its_own(item)
|
|
assert len({item.call_id for item in served}) == 30
|
|
assert _statuses_by_call_id(tuple(item.call_id for item in served)) == _expected_statuses(served)
|
|
received: Final = wire.drain()
|
|
assert sorted(_marker_of(request) for request in received) == sorted(call.marker for call in calls)
|
|
assert {request.target for request in received} == {"/v1/decisions"}, received
|
|
|
|
|
|
@pytest.mark.timeout(420)
|
|
async def test_worker_sigkill_mid_burst_leaves_the_sibling_serving_the_default_model(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
calls: Final = _calls(20, fail=False)
|
|
release: Final = threading.Event()
|
|
held_markers: Final[SimpleQueue[str]] = SimpleQueue()
|
|
|
|
def held(request: Request) -> Reply:
|
|
held_markers.put(_marker_of(request))
|
|
assert release.wait(timeout=60), "The burst was never released"
|
|
return _reply(request)
|
|
|
|
with wire_server(held) as wire:
|
|
path: Final = _chaos_config(wire, tmp_path)
|
|
with owned_proxy_process(
|
|
gateway, tmp_path, {}, config=path, workers=2, extra_arguments=("--model", _CONFIG_MODEL)
|
|
) as owned:
|
|
candidate: Final = owned.gateway
|
|
base_url: Final = str(candidate.client.base_url)
|
|
workers, _ = eventually(
|
|
lambda: _worker_startups(owned.log),
|
|
lambda found: len(found[0]) == 2 and found[1] == 2,
|
|
seconds=120,
|
|
)
|
|
burst: Final = asyncio.create_task(
|
|
_burst(base_url, candidate.key, None, calls, tolerate_transport_errors=True)
|
|
)
|
|
await asyncio.to_thread(eventually, held_markers.qsize, lambda size: size == 20, 60)
|
|
held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire.url) for pid in workers})
|
|
assert sum(held_by.values()) == 20, held_by
|
|
victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__)
|
|
with owned.replacing_worker(victim_pid) as kill:
|
|
kill.freeze()
|
|
await asyncio.to_thread(kill)
|
|
release.set()
|
|
served: Final = await burst
|
|
assert len(served) == held_by[survivor_pid], (held_by, len(served))
|
|
follow_up: Final = _Call(route="/systemone", marker=f"ok-{uuid.uuid4().hex}", fail=False)
|
|
(answered,) = await _burst(base_url, candidate.key, None, (follow_up,))
|
|
await asyncio.to_thread(
|
|
eventually,
|
|
lambda: _worker_startups(owned.log),
|
|
lambda found: len(found[0]) == 3 and found[1] == 3,
|
|
180,
|
|
)
|
|
for item in (*served, answered):
|
|
_assert_served_its_own(item)
|
|
assert item.model_group == _CONFIG_MODEL, item.model_group
|
|
call_ids: Final = tuple(item.call_id for item in (*served, answered))
|
|
assert set(_statuses_by_call_id(call_ids).values()) == {"success"}
|
|
assert len({_marker_of(request) for request in wire.drain()}) == 21
|
|
|
|
|
|
@pytest.mark.timeout(180)
|
|
async def test_upstream_outage_fails_its_calls_and_recovery_on_the_same_port_restores_them(gateway: Gateway) -> None:
|
|
base_url: Final = str(gateway.client.base_url)
|
|
port: Final = allocate_port()
|
|
with gateway.scenario() as scenario:
|
|
with wire_server(_reply, port=port) as wire:
|
|
model: Final = scenario.model(model=f"perplexity/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
|
|
before: Final = await _burst(base_url, gateway.key, model, _calls(5, fail=False))
|
|
assert _health(gateway, model) == (1, 0)
|
|
during: Final = await _burst(base_url, gateway.key, model, _calls(5, fail=False))
|
|
assert [item.status for item in during] == [500] * 5, [item.text for item in during]
|
|
assert _health(gateway, model) == (0, 1)
|
|
with wire_server(_reply, port=port):
|
|
after: Final = await _burst(base_url, gateway.key, model, _calls(5, fail=False))
|
|
assert _health(gateway, model) == (1, 0)
|
|
for item in (*before, *after):
|
|
_assert_served_its_own(item)
|
|
statuses: Final = _statuses_by_call_id(tuple(item.call_id for item in (*before, *during, *after)))
|
|
assert statuses == {
|
|
**{item.call_id: "success" for item in (*before, *after)},
|
|
**{item.call_id: "failure" for item in during},
|
|
}
|