mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* test: replace 61 live logging and otel tests with offline unit and integration coverage * test: restore datadog formatting, deliver datadog logs and redis failures over the wire, assert full router hook payloads, tighten stream usage and otel checks Restores the pre-existing test_datadog.py lines the branch had reflowed. Datadog success, failure and redis-failure replacements now assert the gzip body posted to the intake, with redis failing through a real cache on a closed local port. Router hook sequence recorder checks the legacy field types and asserts concrete payloads, exact streaming and fallback sequences. Stream usage asserts the default include_usage request body and the redacted messages value. Otel asserts response id and token counts. * test: assert budget envelope figures, guardrail inspection and exact prometheus samples per request * test: isolate the prometheus latency test on its own deployment so counts do not depend on order * test: cover timed slack delivery, keep the redis failure test off the network, and make new payload types read-only * test: drain the redis test's logging and scope otel span checks to the test's own trace * test: drive the periodic slack flush without a wall-clock interval * test: scope router hook events to the test, script the db clock, split a nested comprehension --------- Co-authored-by: yuneng <yuneng@berri.ai>
153 lines
6.1 KiB
Python
153 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
import yaml
|
|
|
|
from tests.integration._support.client import JSON_OBJECT, Gateway, gateway_from_environment, object_value, string_value
|
|
from tests.integration._support.process import owned_proxy
|
|
from tests.integration._support.wire import Reply, Request, Wire, wire_server
|
|
|
|
_ATTACHABLE: Final = "attachable-during-guard"
|
|
_WORDS: Final = "custom-words-during-guard"
|
|
_HEADER: Final = "x-litellm-applied-guardrails"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Rig:
|
|
gateway: Gateway
|
|
policy: Wire
|
|
upstream: Wire
|
|
model: str
|
|
|
|
|
|
def _completion(request: Request) -> Reply:
|
|
assert request.target == "/v1/chat/completions", request.target
|
|
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": "guarded"}, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4},
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def _allow(request: Request) -> Reply:
|
|
assert request.target == "/beta/litellm_basic_guardrail_api", request.target
|
|
return Reply(body=json.dumps({"action": "NONE"}).encode())
|
|
|
|
|
|
def _config(directory: Path, policy: Wire) -> Path:
|
|
shutil.copy(Path("litellm/proxy/example_config_yaml/custom_guardrail.py"), directory / "custom_guardrail.py")
|
|
configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
configuration["guardrails"] = [
|
|
{
|
|
"guardrail_name": _ATTACHABLE,
|
|
"litellm_params": {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "during_call",
|
|
"api_base": policy.url,
|
|
"api_key": "synthetic-guardrail-key",
|
|
},
|
|
},
|
|
{
|
|
"guardrail_name": _WORDS,
|
|
"litellm_params": {"guardrail": "custom_guardrail.myCustomGuardrail", "mode": "during_call"},
|
|
},
|
|
]
|
|
path: Final = directory / "guardrail-attachment.yaml"
|
|
path.write_text(yaml.safe_dump(configuration))
|
|
return path
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
|
directory: Final = tmp_path_factory.mktemp("guardrail-attachment")
|
|
with (
|
|
wire_server(_allow) as policy,
|
|
wire_server(_completion) as upstream,
|
|
gateway_from_environment() as shared,
|
|
owned_proxy(shared, directory, {}, config=_config(directory, policy)) as owned,
|
|
owned.scenario() as scenario,
|
|
):
|
|
yield _Rig(owned, policy, upstream, scenario.model(api_base=f"{upstream.url}/v1", api_key="sk-fixture"))
|
|
|
|
|
|
def _prompt(text: str) -> str:
|
|
return f"{text} {uuid.uuid4().hex}"
|
|
|
|
|
|
def _ask(rig: _Rig, key: str | None, prompt: str, guardrails: list[str] | None = None) -> httpx.Response:
|
|
body: Final = {"model": rig.model, "messages": [{"role": "user", "content": prompt}]}
|
|
return rig.gateway.request(
|
|
"POST", "/v1/chat/completions", body if guardrails is None else {**body, "guardrails": guardrails}, key=key
|
|
)
|
|
|
|
|
|
def _served_without_guardrail(rig: _Rig, response: httpx.Response, prompt: str) -> None:
|
|
assert response.status_code == 200, response.text
|
|
assert _HEADER not in response.headers, dict(response.headers)
|
|
assert rig.policy.drain() == ()
|
|
forwarded: Final = rig.upstream.drain()
|
|
assert len(forwarded) == 1 and prompt in forwarded[0].body.decode(), forwarded
|
|
|
|
|
|
def _served_with_attachable(rig: _Rig, response: httpx.Response, prompt: str) -> None:
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers[_HEADER] == _ATTACHABLE, dict(response.headers)
|
|
inspected: Final = rig.policy.drain()
|
|
assert len(inspected) == 1 and prompt in inspected[0].body.decode(), inspected
|
|
forwarded: Final = rig.upstream.drain()
|
|
assert len(forwarded) == 1 and prompt in forwarded[0].body.decode(), forwarded
|
|
|
|
|
|
def test_a_request_with_an_empty_guardrail_list_is_served_without_the_applied_header(rig: _Rig) -> None:
|
|
prompt: Final = _prompt("no guardrails")
|
|
_served_without_guardrail(rig, _ask(rig, None, prompt, []), prompt)
|
|
|
|
|
|
def test_a_key_carrying_a_guardrail_applies_it_and_a_plain_key_does_not(rig: _Rig) -> None:
|
|
with rig.gateway.scenario() as scenario:
|
|
plain: Final = scenario.key()
|
|
guarded: Final = scenario.key(guardrails=[_ATTACHABLE])
|
|
plain_prompt: Final = _prompt("plain key")
|
|
_served_without_guardrail(rig, _ask(rig, plain, plain_prompt), plain_prompt)
|
|
guarded_prompt: Final = _prompt("guarded key")
|
|
_served_with_attachable(rig, _ask(rig, guarded, guarded_prompt), guarded_prompt)
|
|
|
|
|
|
def test_a_team_carrying_a_guardrail_applies_it_to_its_keys_only(rig: _Rig) -> None:
|
|
with rig.gateway.scenario() as scenario:
|
|
team: Final = scenario.team(guardrails=[_ATTACHABLE])
|
|
outside: Final = scenario.key()
|
|
member: Final = scenario.key(team_id=team)
|
|
outside_prompt: Final = _prompt("outside team")
|
|
_served_without_guardrail(rig, _ask(rig, outside, outside_prompt), outside_prompt)
|
|
member_prompt: Final = _prompt("team key")
|
|
_served_with_attachable(rig, _ask(rig, member, member_prompt), member_prompt)
|
|
|
|
|
|
def test_a_during_call_custom_guardrail_rejects_a_request_naming_the_banned_word(rig: _Rig) -> None:
|
|
unguarded_prompt: Final = _prompt("what is litellm")
|
|
_served_without_guardrail(rig, _ask(rig, None, unguarded_prompt), unguarded_prompt)
|
|
refused: Final = _ask(rig, None, _prompt("what is litellm"), [_WORDS])
|
|
rig.upstream.drain()
|
|
assert refused.status_code >= 400, refused.text
|
|
error: Final = object_value(JSON_OBJECT.validate_json(refused.content)["error"])
|
|
assert "Guardrail failed words - `litellm` detected" in string_value(error["message"]), refused.text
|
|
assert rig.policy.drain() == ()
|