mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
* test(integration): wait on the right signal in five flaky integration contracts The Prometheus shared-label tests waited for the latency metric and then read the request counter, which the logger writes after an await. They now wait for the counter The auto-router session poll returned on the first 200 while the caller expected two turns, so it now waits for the expected turn count The S3 queue cap test let its failing sink recover on a 15s clock that could expire mid-flush. The sink now stays down until a flush fails after every event is queued K3 normally takes 168-175s against a 180s limit, so the limit is now 300s The MCP env var browser spec reused an idle keep-alive socket that the proxy closes after 5s, so its API calls now send Connection: close * test(integration): add until_asserted so a wait polls the exact asserts it guards eventually() takes a predicate separate from the asserts that follow it, which lets a test wait on one value and then assert on another written later. until_asserted reruns a block of asserts until they all pass and re-raises the last failure on timeout The Prometheus shared-label tests and the auto-router session check now use it
395 lines
16 KiB
Python
395 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import uuid
|
|
from collections import Counter
|
|
from collections.abc import Iterator, Sequence
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from hashlib import sha256
|
|
from pathlib import Path
|
|
from typing import Final
|
|
from urllib.parse import urlsplit
|
|
|
|
import pytest
|
|
import yaml
|
|
from integration._support import claude_code as cc
|
|
from integration._support import responses_vendor as rv
|
|
from integration._support.client import Gateway, Scenario, gateway_from_environment, until_asserted
|
|
from integration._support.process import owned_proxy
|
|
from integration._support.prometheus_series import Sample, scrape
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from pydantic import JsonValue
|
|
|
|
_GPT: Final = "prometheus-shared-label-gpt"
|
|
_CLAUDE: Final = "prometheus-shared-label-claude"
|
|
_DOWN: Final = "prometheus-shared-label-down"
|
|
_DOWN_UPSTREAM: Final = "gpt-4o-down"
|
|
_SHARED_LABELS: Final = ("project_alias", "team", "requested_model", "metadata.department")
|
|
_RPM: Final = 600
|
|
_TPM: Final = 1_000_000
|
|
_ALLOWED: Final = "litellm_project_model_rate_limit_allowed_metric"
|
|
_USED: Final = "litellm_project_model_rate_limit_used_metric"
|
|
_TOTAL_REQUESTS: Final = "litellm_proxy_total_requests_metric_total"
|
|
_FAILED_REQUESTS: Final = "litellm_proxy_failed_requests_metric_total"
|
|
_LATENCY_COUNT: Final = "litellm_request_total_latency_metric_count"
|
|
_CALLBACK_FAILURES: Final = "litellm_callback_logging_failures_metric_total"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Upstream:
|
|
vendor: rv.ResponsesVendor
|
|
outage: threading.Event
|
|
|
|
def respond(self, request: Request) -> Reply:
|
|
if request.method == "POST" and (
|
|
self.outage.is_set() or json.loads(request.body).get("model") == _DOWN_UPSTREAM
|
|
):
|
|
return rv.error(503, "synthetic upstream outage", "server_error")
|
|
return self.vendor.respond(request)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Rig:
|
|
gateway: Gateway
|
|
upstream: Wire
|
|
outage: threading.Event
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Call:
|
|
route: str
|
|
model: str
|
|
stream: bool
|
|
|
|
@property
|
|
def upstream(self) -> tuple[str, str, bool]:
|
|
if self.model == _CLAUDE:
|
|
return ("/v1/messages", cc.OPUS, self.stream)
|
|
return (
|
|
"/v1/chat/completions" if self.route == "/v1/chat/completions" else "/v1/responses",
|
|
"gpt-4o-mini",
|
|
self.stream,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _ProjectKey:
|
|
key: str
|
|
team: str
|
|
project: str
|
|
alias: str
|
|
model: str
|
|
|
|
@property
|
|
def hashed(self) -> str:
|
|
return sha256(self.key.encode()).hexdigest()
|
|
|
|
|
|
_CALLS: Final = (
|
|
_Call("/v1/chat/completions", _GPT, stream=False),
|
|
_Call("/v1/chat/completions", _GPT, stream=True),
|
|
_Call("/v1/messages", _GPT, stream=False),
|
|
_Call("/v1/messages", _GPT, stream=True),
|
|
_Call("/v1/messages", _CLAUDE, stream=False),
|
|
_Call("/v1/messages", _CLAUDE, stream=True),
|
|
_Call("/v1/responses", _GPT, stream=False),
|
|
_Call("/v1/responses", _GPT, stream=True),
|
|
)
|
|
|
|
|
|
def _config(directory: Path, upstream: Wire, custom_labels: Sequence[str], **settings: JsonValue) -> Path:
|
|
configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
configuration["model_list"] = [
|
|
{
|
|
"model_name": _GPT,
|
|
"litellm_params": {
|
|
"model": "openai/gpt-4o-mini",
|
|
"api_key": "sk-fixture",
|
|
"api_base": f"{upstream.url}/v1",
|
|
},
|
|
},
|
|
{
|
|
"model_name": _CLAUDE,
|
|
"litellm_params": {
|
|
"model": f"anthropic/{cc.OPUS}",
|
|
"api_key": cc.ANTHROPIC_API_KEY,
|
|
"api_base": upstream.url,
|
|
},
|
|
},
|
|
{
|
|
"model_name": _DOWN,
|
|
"litellm_params": {
|
|
"model": f"openai/{_DOWN_UPSTREAM}",
|
|
"api_key": "sk-fixture",
|
|
"api_base": f"{upstream.url}/v1",
|
|
},
|
|
},
|
|
]
|
|
configuration["litellm_settings"]["callbacks"] = ["prometheus"]
|
|
configuration["litellm_settings"]["custom_prometheus_metadata_labels"] = list(custom_labels)
|
|
configuration["litellm_settings"].update(settings)
|
|
configuration.setdefault("router_settings", {}).update({"num_retries": 0, "disable_cooldowns": True})
|
|
path: Final = directory / "prometheus-shared-custom-labels.yaml"
|
|
path.write_text(yaml.safe_dump(configuration))
|
|
return path
|
|
|
|
|
|
@contextmanager
|
|
def _owned_rig(directory: Path, custom_labels: Sequence[str], **settings: JsonValue) -> Iterator[_Rig]:
|
|
samples: Final = directory / "prometheus-multiproc"
|
|
samples.mkdir()
|
|
outage: Final = threading.Event()
|
|
with (
|
|
wire_server(_Upstream(rv.ResponsesVendor(), outage).respond) as upstream,
|
|
gateway_from_environment() as shared,
|
|
owned_proxy(
|
|
shared,
|
|
directory,
|
|
{"PROMETHEUS_MULTIPROC_DIR": str(samples)},
|
|
config=_config(directory, upstream, custom_labels, **settings),
|
|
workers=2,
|
|
) as owned,
|
|
):
|
|
yield _Rig(owned, upstream, outage)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
|
with _owned_rig(tmp_path_factory.mktemp("prometheus-shared-custom-labels"), _SHARED_LABELS) as owned:
|
|
yield owned
|
|
|
|
|
|
def _project_key(scenario: Scenario, model: str) -> _ProjectKey:
|
|
team: Final = scenario.team()
|
|
alias: Final = f"integration-project-{uuid.uuid4().hex}"
|
|
project: Final = scenario.project(
|
|
team, project_alias=alias, model_rpm_limit={model: _RPM}, model_tpm_limit={model: _TPM}
|
|
)
|
|
return _ProjectKey(scenario.key(team_id=team, project_id=project), team, project, alias, model)
|
|
|
|
|
|
def _body(call: _Call, **extra: JsonValue) -> dict[str, JsonValue]:
|
|
text: Final = f"say marker-{uuid.uuid4().hex}"
|
|
if call.route == "/v1/responses":
|
|
return {"model": call.model, "input": text, "stream": call.stream, **extra}
|
|
return {
|
|
"model": call.model,
|
|
"max_tokens": 16,
|
|
"messages": [{"role": "user", "content": text}],
|
|
"stream": call.stream,
|
|
**extra,
|
|
}
|
|
|
|
|
|
def _send(gateway: Gateway, call: _Call, key: str, **extra: JsonValue) -> int:
|
|
with gateway.client.stream(
|
|
"POST", call.route, json=_body(call, **extra), headers={"Authorization": f"Bearer {key}"}
|
|
) as response:
|
|
body: Final = response.read()
|
|
if response.status_code == 200 and call.stream:
|
|
assert b"data:" in body, body[:300]
|
|
return response.status_code
|
|
|
|
|
|
def _forwarded(request: Request) -> tuple[str, str, bool]:
|
|
body: Final = json.loads(request.body)
|
|
return (urlsplit(request.target).path, body["model"], bool(body.get("stream")))
|
|
|
|
|
|
def _series(samples: Sequence[Sample], name: str, **labels: str) -> tuple[Sample, ...]:
|
|
return tuple(
|
|
sample
|
|
for sample in samples
|
|
if sample.name == name and all(sample.labels.get(label) == value for label, value in labels.items())
|
|
)
|
|
|
|
|
|
def _total(samples: Sequence[Sample], name: str, **labels: str) -> float:
|
|
return sum(sample.value for sample in _series(samples, name, **labels))
|
|
|
|
|
|
def _project_gauges(samples: Sequence[Sample], project: str) -> tuple[Sample, ...]:
|
|
return _series(samples, _ALLOWED, project_id=project) + _series(samples, _USED, project_id=project)
|
|
|
|
|
|
def _shared_labels(sample: Sample) -> tuple[str | None, ...]:
|
|
return tuple(
|
|
sample.labels.get(label) for label in ("project_alias", "team", "requested_model", "metadata_department")
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"call", _CALLS, ids=lambda call: f"{call.route}-{call.model}-{'stream' if call.stream else 'json'}"
|
|
)
|
|
def test_a_project_key_call_on_every_endpoint_lands_in_the_request_latency_and_project_metrics(
|
|
rig: _Rig, call: _Call
|
|
) -> None:
|
|
rig.upstream.drain()
|
|
with rig.gateway.scenario() as scenario:
|
|
owner: Final = _project_key(scenario, call.model)
|
|
assert _send(rig.gateway, call, owner.key) == 200
|
|
forwarded: Final = rig.upstream.drain()
|
|
assert [_forwarded(request) for request in forwarded] == [call.upstream], forwarded
|
|
|
|
def landed() -> None:
|
|
samples: Final = scrape(rig.gateway)
|
|
assert _total(samples, _LATENCY_COUNT, hashed_api_key=owner.hashed) == 1
|
|
requests: Final = _series(samples, _TOTAL_REQUESTS, hashed_api_key=owner.hashed)
|
|
assert [(sample.labels["route"], sample.labels["status_code"], sample.value) for sample in requests] == [
|
|
(call.route, "200", 1.0)
|
|
], requests
|
|
assert [_shared_labels(sample) for sample in requests] == [(owner.alias, owner.team, call.model, "None")]
|
|
gauges: Final = _project_gauges(samples, owner.project)
|
|
assert len(gauges) == 4, gauges
|
|
assert {(sample.name, sample.labels["rate_limit_type"], int(sample.value)) for sample in gauges} >= {
|
|
(_ALLOWED, "requests", _RPM),
|
|
(_USED, "requests", 1),
|
|
(_ALLOWED, "tokens", _TPM),
|
|
}, gauges
|
|
assert {_shared_labels(sample) for sample in gauges} == {(owner.alias, owner.team, call.model, "None")}, (
|
|
gauges
|
|
)
|
|
|
|
until_asserted(landed, seconds=30)
|
|
|
|
|
|
def test_a_failed_project_key_call_counts_as_one_failed_request_with_each_shared_label_once(rig: _Rig) -> None:
|
|
rig.upstream.drain()
|
|
with rig.gateway.scenario() as scenario:
|
|
owner: Final = _project_key(scenario, _DOWN)
|
|
call: Final = _Call("/v1/chat/completions", _DOWN, stream=False)
|
|
status: Final = _send(rig.gateway, call, owner.key)
|
|
assert status >= 500, status
|
|
forwarded: Final = rig.upstream.drain()
|
|
assert len(forwarded) == 1, forwarded
|
|
|
|
def landed() -> None:
|
|
samples: Final = scrape(rig.gateway)
|
|
failed: Final = _series(samples, _FAILED_REQUESTS, hashed_api_key=owner.hashed)
|
|
assert [(_shared_labels(sample), sample.value) for sample in failed] == [
|
|
(("None", owner.team, _DOWN, "None"), 1.0)
|
|
], failed
|
|
requests: Final = _series(samples, _TOTAL_REQUESTS, hashed_api_key=owner.hashed)
|
|
assert [(sample.labels["status_code"], sample.value) for sample in requests] == [(str(status), 1.0)], (
|
|
requests
|
|
)
|
|
|
|
until_asserted(landed, seconds=30)
|
|
|
|
|
|
def test_request_metadata_fills_the_shared_labels_on_the_request_and_project_metrics(rig: _Rig) -> None:
|
|
rig.upstream.drain()
|
|
with rig.gateway.scenario() as scenario:
|
|
owner: Final = _project_key(scenario, _GPT)
|
|
call: Final = _Call("/v1/chat/completions", _GPT, stream=False)
|
|
metadata: Final[dict[str, JsonValue]] = {
|
|
"project_alias": "caller-alias",
|
|
"team": "caller-team",
|
|
"department": "eng",
|
|
}
|
|
assert _send(rig.gateway, call, owner.key, metadata=metadata) == 200
|
|
forwarded: Final = rig.upstream.drain()
|
|
assert len(forwarded) == 1, forwarded
|
|
expected: Final = ("caller-alias", "caller-team", _GPT, "eng")
|
|
|
|
def landed() -> None:
|
|
samples: Final = scrape(rig.gateway)
|
|
requests: Final = _series(samples, _TOTAL_REQUESTS, hashed_api_key=owner.hashed)
|
|
assert [(_shared_labels(sample), sample.value) for sample in requests] == [(expected, 1.0)], requests
|
|
gauges: Final = _project_gauges(samples, owner.project)
|
|
assert len(gauges) == 4, gauges
|
|
assert {_shared_labels(sample) for sample in gauges} == {expected}, gauges
|
|
|
|
until_asserted(landed, seconds=30)
|
|
|
|
|
|
def test_a_key_outside_any_team_or_project_still_counts_its_request(rig: _Rig) -> None:
|
|
rig.upstream.drain()
|
|
with rig.gateway.scenario() as scenario:
|
|
key: Final = scenario.key()
|
|
hashed: Final = sha256(key.encode()).hexdigest()
|
|
assert _send(rig.gateway, _Call("/v1/chat/completions", _GPT, stream=False), key) == 200
|
|
forwarded: Final = rig.upstream.drain()
|
|
assert len(forwarded) == 1, forwarded
|
|
|
|
def landed() -> None:
|
|
samples: Final = scrape(rig.gateway)
|
|
assert _total(samples, _LATENCY_COUNT, hashed_api_key=hashed) == 1
|
|
requests: Final = _series(samples, _TOTAL_REQUESTS, hashed_api_key=hashed)
|
|
assert [(_shared_labels(sample), sample.value) for sample in requests] == [
|
|
(("None", "None", _GPT, "None"), 1.0)
|
|
], requests
|
|
|
|
until_asserted(landed, seconds=30)
|
|
|
|
|
|
def test_a_mixed_burst_through_an_upstream_outage_counts_every_request_once_and_never_fails_the_callback(
|
|
rig: _Rig,
|
|
) -> None:
|
|
rig.upstream.drain()
|
|
with rig.gateway.scenario() as scenario:
|
|
owner: Final = _project_key(scenario, _GPT)
|
|
before: Final = _total(scrape(rig.gateway), _CALLBACK_FAILURES, callback_name="PrometheusLogger")
|
|
calls: Final = tuple(call for call in _CALLS if call.model == _GPT) * 2
|
|
|
|
def burst() -> list[tuple[str, int]]:
|
|
with ThreadPoolExecutor(max_workers=len(calls)) as pool:
|
|
return list(pool.map(lambda call: (call.route, _send(rig.gateway, call, owner.key)), calls))
|
|
|
|
healthy: Final = burst()
|
|
rig.outage.set()
|
|
try:
|
|
down: Final = burst()
|
|
finally:
|
|
rig.outage.clear()
|
|
recovered: Final = burst()
|
|
assert {status for _, status in healthy + recovered} == {200}, (healthy, recovered)
|
|
assert all(status >= 500 for _, status in down), down
|
|
rig.upstream.drain()
|
|
observed: Final = Counter((route, str(status)) for route, status in healthy + down + recovered)
|
|
|
|
def landed() -> None:
|
|
samples: Final = scrape(rig.gateway)
|
|
counted: Final = Counter(
|
|
{
|
|
(sample.labels["route"], sample.labels["status_code"]): int(sample.value)
|
|
for sample in _series(samples, _TOTAL_REQUESTS, hashed_api_key=owner.hashed)
|
|
}
|
|
)
|
|
assert counted == observed, (counted, observed)
|
|
assert _total(samples, _LATENCY_COUNT, hashed_api_key=owner.hashed) == len(healthy) + len(recovered)
|
|
assert _total(samples, _FAILED_REQUESTS, hashed_api_key=owner.hashed) == len(down)
|
|
assert _total(samples, _CALLBACK_FAILURES, callback_name="PrometheusLogger") == before
|
|
|
|
until_asserted(landed, seconds=60)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("custom_labels", "settings"),
|
|
[
|
|
pytest.param(("project-alias",), {}, id="a-spelling-that-sanitizes-to-the-built-in-name"),
|
|
pytest.param(("project_alias",), {"prometheus_metrics_max_series_per_metric": 500}, id="series-cap-on"),
|
|
],
|
|
)
|
|
def test_a_project_key_call_under_other_label_configurations_keeps_one_project_alias(
|
|
tmp_path: Path, custom_labels: tuple[str, ...], settings: dict[str, JsonValue]
|
|
) -> None:
|
|
with _owned_rig(tmp_path, custom_labels, **settings) as owned, owned.gateway.scenario() as scenario:
|
|
owner: Final = _project_key(scenario, _GPT)
|
|
assert _send(owned.gateway, _Call("/v1/chat/completions", _GPT, stream=False), owner.key) == 200
|
|
forwarded: Final = owned.upstream.drain()
|
|
assert len(forwarded) == 1, forwarded
|
|
|
|
def landed() -> None:
|
|
samples: Final = scrape(owned.gateway)
|
|
assert _total(samples, _LATENCY_COUNT, hashed_api_key=owner.hashed) == 1
|
|
requests: Final = _series(samples, _TOTAL_REQUESTS, hashed_api_key=owner.hashed)
|
|
assert [(sample.labels["project_alias"], sample.value) for sample in requests] == [(owner.alias, 1.0)]
|
|
gauges: Final = _project_gauges(samples, owner.project)
|
|
assert len(gauges) == 4, gauges
|
|
assert {sample.labels["project_alias"] for sample in gauges} == {owner.alias}, gauges
|
|
|
|
until_asserted(landed, seconds=30)
|