mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +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.
858 lines
36 KiB
Python
858 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import uuid
|
|
from collections.abc import Iterator, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from itertools import chain
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import Final, Literal, TypeAlias
|
|
|
|
import httpx
|
|
import pytest
|
|
import yaml
|
|
from integration._support.client import Gateway, JsonValue, Scenario, eventually
|
|
from integration._support.database import read_rows, write_rows
|
|
from integration._support.process import owned_proxy_process
|
|
from integration._support.upstream import (
|
|
_aws_event_frame, # pyright: ignore[reportPrivateUsage] # project Bedrock event-stream encoder
|
|
)
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from pydantic import TypeAdapter
|
|
|
|
import litellm
|
|
|
|
pytestmark: Final = pytest.mark.shares_worker_state
|
|
|
|
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
|
EndpointScope: TypeAlias = Literal["streaming", "non_streaming"]
|
|
ModelKind: TypeAlias = Literal["router", "direct"]
|
|
StreamingAction: TypeAlias = Literal["converse-stream", "invoke-with-response-stream"]
|
|
NonStreamingAction: TypeAlias = Literal["converse", "invoke"]
|
|
BedrockAction: TypeAlias = StreamingAction | NonStreamingAction
|
|
BEDROCK_MODEL_ID: Final = "anthropic.claude-sonnet-5-v1:0"
|
|
BEDROCK_FALSE_POSITIVE_MODEL_ID: Final = "anthropic.claude-converse-stream-test-v1:0"
|
|
BEDROCK_EVENT_STREAM: Final = "application/vnd.amazon.eventstream"
|
|
GUARDRAIL_PATH: Final = "/beta/litellm_basic_guardrail_api"
|
|
SERVER_STREAMING_CLASSIFICATION_KEY: Final = "litellm_server_streaming_classification"
|
|
STREAMING_ACTIONS: Final[tuple[StreamingAction, ...]] = (
|
|
"converse-stream",
|
|
"invoke-with-response-stream",
|
|
)
|
|
NON_STREAMING_ACTIONS: Final[tuple[NonStreamingAction, ...]] = ("converse", "invoke")
|
|
SCOPES: Final[tuple[EndpointScope, ...]] = ("streaming", "non_streaming")
|
|
HOSTILE_CLASSIFICATION_CASES: Final = (
|
|
pytest.param("is_streaming_request", True, id="boolean-marker"),
|
|
pytest.param("is_streaming_request", "litellm-server-streaming", id="server-marker-string"),
|
|
pytest.param("litellm_server_streaming_classification", True, id="classification-field"),
|
|
)
|
|
WORKTREE: Final = Path(__file__).resolve().parents[3]
|
|
LITELLM_PATH: Final = Path(litellm.__file__).resolve()
|
|
assert LITELLM_PATH.is_relative_to(WORKTREE), (LITELLM_PATH, WORKTREE)
|
|
print(f"stream_scope repro litellm import: {LITELLM_PATH}") # noqa: T201 # required worktree evidence
|
|
|
|
|
|
def _json(value: object) -> bytes:
|
|
return json.dumps(value, separators=(",", ":")).encode()
|
|
|
|
|
|
def _strings(value: JsonValue) -> tuple[str, ...]:
|
|
if isinstance(value, str):
|
|
return (value,)
|
|
if isinstance(value, list):
|
|
return tuple(chain.from_iterable(_strings(item) for item in value))
|
|
if isinstance(value, dict):
|
|
return tuple(chain.from_iterable(_strings(item) for item in value.values()))
|
|
return ()
|
|
|
|
|
|
def _key_names(value: JsonValue) -> tuple[str, ...]:
|
|
if isinstance(value, dict):
|
|
return tuple(value) + tuple(chain.from_iterable(_key_names(item) for item in value.values()))
|
|
if isinstance(value, list):
|
|
return tuple(chain.from_iterable(_key_names(item) for item in value))
|
|
return ()
|
|
|
|
|
|
def _marker(body: JsonValue) -> str:
|
|
return next(value for value in _strings(body) if value.startswith("scope-"))
|
|
|
|
|
|
def _bedrock_converse_stream(marker: str) -> bytes:
|
|
return b"".join(
|
|
_aws_event_frame(event_type, payload, marker, marker)
|
|
for event_type, payload in (
|
|
("messageStart", {"role": "assistant"}),
|
|
(
|
|
"contentBlockDelta",
|
|
{"delta": {"text": f"scripted Bedrock reply {marker}"}, "contentBlockIndex": 0},
|
|
),
|
|
("messageStop", {"stopReason": "end_turn"}),
|
|
("metadata", {"usage": {"inputTokens": 11, "outputTokens": 4, "totalTokens": 15}}),
|
|
)
|
|
)
|
|
|
|
|
|
def _invoke_chunk(payload: Mapping[str, JsonValue], marker: str) -> bytes:
|
|
encoded: Final = base64.b64encode(_json(payload)).decode()
|
|
return _aws_event_frame("chunk", {"bytes": encoded}, marker, marker)
|
|
|
|
|
|
def _bedrock_invoke_stream(marker: str) -> bytes:
|
|
events: Final = (
|
|
{
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": f"msg-{marker}",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": BEDROCK_MODEL_ID,
|
|
"content": [],
|
|
"stop_reason": None,
|
|
"stop_sequence": None,
|
|
"usage": {"input_tokens": 11, "output_tokens": 0},
|
|
},
|
|
},
|
|
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
|
{
|
|
"type": "content_block_delta",
|
|
"index": 0,
|
|
"delta": {"type": "text_delta", "text": f"scripted Bedrock reply {marker}"},
|
|
},
|
|
{"type": "content_block_stop", "index": 0},
|
|
{
|
|
"type": "message_delta",
|
|
"delta": {"stop_reason": "end_turn"},
|
|
"usage": {"input_tokens": 11, "output_tokens": 4},
|
|
},
|
|
{"type": "message_stop"},
|
|
)
|
|
return b"".join(_invoke_chunk(event, marker) for event in events)
|
|
|
|
|
|
def _provider(request: Request) -> Reply:
|
|
if not request.body:
|
|
return Reply(status=400, body=_json({"error": "empty request body"}))
|
|
body: Final = JSON_OBJECT.validate_json(request.body)
|
|
marker: Final = _marker(body)
|
|
target: Final = request.target.split("?", 1)[0]
|
|
if target.startswith("/passthrough"):
|
|
if body.get("stream") is True:
|
|
streamed_response: Final = _json({"received": body})
|
|
return Reply(
|
|
content_type="text/event-stream",
|
|
chunks=(b"data: " + streamed_response + b"\n\n", b"data: [DONE]\n\n"),
|
|
)
|
|
return Reply(body=_json({"received": body}))
|
|
if target.endswith("/converse-stream"):
|
|
return Reply(body=_bedrock_converse_stream(marker), content_type=BEDROCK_EVENT_STREAM)
|
|
if target.endswith("/invoke-with-response-stream"):
|
|
return Reply(body=_bedrock_invoke_stream(marker), content_type=BEDROCK_EVENT_STREAM)
|
|
if target.endswith("/converse") or target.endswith("/invoke"):
|
|
return Reply(body=_json({"output": f"scripted Bedrock reply {marker}"}))
|
|
if target == "/v1/chat/completions":
|
|
if body.get("stream") is True:
|
|
chunk: Final = {
|
|
"id": f"chatcmpl-{marker}",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"delta": {"role": "assistant", "content": f"scripted chat reply {marker}"},
|
|
"finish_reason": None,
|
|
}
|
|
],
|
|
}
|
|
final_chunk: Final = {
|
|
**chunk,
|
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
}
|
|
return Reply(
|
|
content_type="text/event-stream",
|
|
chunks=(
|
|
b"data: " + _json(chunk) + b"\n\n",
|
|
b"data: " + _json(final_chunk) + b"\n\n",
|
|
b"data: [DONE]\n\n",
|
|
),
|
|
)
|
|
return Reply(
|
|
body=_json(
|
|
{
|
|
"id": f"chatcmpl-{marker}",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": f"scripted chat reply {marker}"},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
}
|
|
)
|
|
)
|
|
return Reply(status=404, body=_json({"error": f"unexpected upstream path: {target}"}))
|
|
|
|
|
|
def _sink(request: Request) -> Reply:
|
|
assert request.target.endswith(GUARDRAIL_PATH), request.target
|
|
assert b"scope-" in request.body, request.body.decode()
|
|
return Reply(body=_json({"action": "NONE"}))
|
|
|
|
|
|
def _rail(name: str, sink: Wire, scope: EndpointScope) -> dict[str, JsonValue]:
|
|
return {
|
|
"guardrail_name": name,
|
|
"litellm_params": {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "pre_call",
|
|
"default_on": False,
|
|
"stream_scope": scope,
|
|
"api_base": f"{sink.url}/{name}",
|
|
"api_key": "synthetic-guardrail-key",
|
|
},
|
|
}
|
|
|
|
|
|
def _chat_proxy_config(provider_url: str, guardrails: list[dict[str, JsonValue]]) -> dict[str, object]:
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
return {
|
|
**config,
|
|
"guardrails": guardrails,
|
|
"model_list": [
|
|
{
|
|
"model_name": "scope-invalid-config-chat",
|
|
"litellm_params": {
|
|
"model": "openai/gpt-4o-mini",
|
|
"api_base": f"{provider_url}/v1",
|
|
"api_key": "synthetic-provider-key",
|
|
},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ReproRig:
|
|
candidate: Gateway
|
|
direct_candidate: Gateway
|
|
scenario: Scenario
|
|
models: Mapping[str, str]
|
|
rails: Mapping[str, str]
|
|
provider: Wire
|
|
sink: Wire
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[ReproRig]:
|
|
with httpx.Client(
|
|
base_url=os.environ["INTEGRATION_PROXY_URL"],
|
|
timeout=30,
|
|
trust_env=False,
|
|
) as root_client:
|
|
root_gateway: Final = Gateway(
|
|
root_client,
|
|
os.environ.get("INTEGRATION_MASTER_KEY", "sk-integration-master"),
|
|
os.environ["INTEGRATION_UPSTREAM_URL"],
|
|
)
|
|
directory: Final = tmp_path_factory.mktemp("guardrail-stream-scope-repro")
|
|
with wire_server(_provider) as provider, wire_server(_sink) as sink:
|
|
rails: Final = MappingProxyType(
|
|
{
|
|
"bedrock_streaming": "bedrock_streaming",
|
|
"bedrock_non_streaming": "bedrock_non_streaming",
|
|
"chat_streaming": "chat_streaming",
|
|
"passthrough_streaming": "passthrough_streaming",
|
|
"passthrough_non_streaming": "passthrough_non_streaming",
|
|
"passthrough_spoof_streaming": "passthrough_spoof_streaming",
|
|
}
|
|
)
|
|
models: Final = MappingProxyType(
|
|
{
|
|
"chat": "scope-chat",
|
|
"bedrock_router": "scope-bedrock-router",
|
|
"bedrock_false_positive_router": "scope-bedrock-converse-stream-model",
|
|
}
|
|
)
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [
|
|
_rail(rails["bedrock_streaming"], sink, "streaming"),
|
|
_rail(rails["bedrock_non_streaming"], sink, "non_streaming"),
|
|
_rail(rails["chat_streaming"], sink, "streaming"),
|
|
_rail(rails["passthrough_streaming"], sink, "streaming"),
|
|
_rail(rails["passthrough_non_streaming"], sink, "non_streaming"),
|
|
_rail(rails["passthrough_spoof_streaming"], sink, "streaming"),
|
|
]
|
|
config["model_list"] = [
|
|
{
|
|
"model_name": models["chat"],
|
|
"litellm_params": {
|
|
"model": "openai/gpt-4o-mini",
|
|
"api_base": f"{provider.url}/v1",
|
|
"api_key": "synthetic-provider-key",
|
|
},
|
|
},
|
|
{
|
|
"model_name": models["bedrock_router"],
|
|
"litellm_params": {
|
|
"model": f"bedrock/{BEDROCK_MODEL_ID}",
|
|
"api_base": provider.url,
|
|
"aws_access_key_id": "AKIASYNTHETICSTREAMSCOPE",
|
|
"aws_secret_access_key": "synthetic-bedrock-secret",
|
|
"aws_region_name": "us-east-1",
|
|
},
|
|
},
|
|
{
|
|
"model_name": models["bedrock_false_positive_router"],
|
|
"litellm_params": {
|
|
"model": f"bedrock/{BEDROCK_FALSE_POSITIVE_MODEL_ID}",
|
|
"api_base": provider.url,
|
|
"aws_access_key_id": "AKIASYNTHETICSTREAMSCOPE",
|
|
"aws_secret_access_key": "synthetic-bedrock-secret",
|
|
"aws_region_name": "us-east-1",
|
|
},
|
|
},
|
|
]
|
|
config["environment_variables"] = {
|
|
"AWS_BEDROCK_RUNTIME_ENDPOINT": provider.url,
|
|
"AWS_ACCESS_KEY_ID": "AKIASYNTHETICSTREAMSCOPE",
|
|
"AWS_SECRET_ACCESS_KEY": "synthetic-bedrock-secret",
|
|
"AWS_REGION": "us-east-1",
|
|
"AWS_REGION_NAME": "us-east-1",
|
|
}
|
|
config["general_settings"]["pass_through_endpoints"] = [
|
|
{
|
|
"path": "/pt-forward",
|
|
"target": f"{provider.url}/passthrough",
|
|
"include_subpath": True,
|
|
},
|
|
{
|
|
"path": "/pt-spoof",
|
|
"target": f"{provider.url}/passthrough",
|
|
"include_subpath": True,
|
|
"guardrails": {rails["passthrough_spoof_streaming"]: None},
|
|
},
|
|
{
|
|
"path": "/pt-scope",
|
|
"target": f"{provider.url}/passthrough",
|
|
"include_subpath": True,
|
|
"guardrails": {
|
|
rails["passthrough_streaming"]: None,
|
|
rails["passthrough_non_streaming"]: None,
|
|
},
|
|
},
|
|
]
|
|
config_path: Final = directory / "stream-scope-repro.yaml"
|
|
config_path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy_process(root_gateway, directory, {}, config=config_path, workers=1) as owned:
|
|
with owned.gateway.scenario() as scenario:
|
|
direct_config: Final = {
|
|
**config,
|
|
"model_list": [
|
|
*config["model_list"],
|
|
{
|
|
"model_name": f"scope-unused-{uuid.uuid4().hex}*",
|
|
"litellm_params": {
|
|
"model": "openai/gpt-4o-mini",
|
|
"api_base": f"{provider.url}/v1",
|
|
"api_key": "synthetic-provider-key",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
direct_config_path: Final = directory / "stream-scope-direct.yaml"
|
|
direct_config_path.write_text(yaml.safe_dump(direct_config))
|
|
with owned_proxy_process(
|
|
root_gateway,
|
|
directory,
|
|
{},
|
|
config=direct_config_path,
|
|
workers=1,
|
|
) as direct:
|
|
yield ReproRig(
|
|
owned.gateway,
|
|
direct.gateway,
|
|
scenario,
|
|
models,
|
|
rails,
|
|
provider,
|
|
sink,
|
|
)
|
|
|
|
|
|
def _bedrock_request(
|
|
action: BedrockAction,
|
|
model_path: str,
|
|
marker: str,
|
|
) -> tuple[str, dict[str, JsonValue]]:
|
|
if action in ("converse", "converse-stream"):
|
|
body: Final = {
|
|
"messages": [{"role": "user", "content": [{"text": marker}]}],
|
|
"inferenceConfig": {"maxTokens": 16},
|
|
}
|
|
else:
|
|
body = {
|
|
"anthropic_version": "bedrock-2023-05-31",
|
|
"max_tokens": 16,
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": marker}]}],
|
|
}
|
|
return f"/bedrock/model/{model_path}/{action}", body
|
|
|
|
|
|
def _matching_requests(wire: Wire, marker: str) -> tuple[Request, ...]:
|
|
return tuple(request for request in wire.drain() if marker.encode() in request.body)
|
|
|
|
|
|
def _rail_scans(rows: Sequence[Request], rail_name: str, marker: str) -> tuple[Request, ...]:
|
|
return tuple(
|
|
request for request in rows if request.target.startswith(f"/{rail_name}/") and marker.encode() in request.body
|
|
)
|
|
|
|
|
|
def _chat_request_with_scans(
|
|
gateway: Gateway,
|
|
sink: Wire,
|
|
model: str,
|
|
marker: str,
|
|
streamed: bool,
|
|
rail_name: str,
|
|
) -> tuple[httpx.Response, tuple[Request, ...]]:
|
|
response: Final = gateway.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": marker}],
|
|
"stream": streamed,
|
|
"guardrails": [rail_name],
|
|
},
|
|
)
|
|
return response, _rail_scans(sink.drain(), rail_name, marker)
|
|
|
|
|
|
def _spend_row_for_call(call_id: str, content: bytes) -> dict[str, JsonValue]:
|
|
rows: Final = eventually(
|
|
lambda: read_rows(
|
|
"SELECT request_id, litellm_call_id, spend, prompt_tokens, completion_tokens, metadata "
|
|
'FROM "LiteLLM_SpendLogs" WHERE request_id=%s OR litellm_call_id=%s',
|
|
(call_id, call_id),
|
|
),
|
|
lambda values: len(values) >= 1,
|
|
seconds=70,
|
|
)
|
|
assert len(rows) == 1, (call_id, rows, content)
|
|
assert call_id in (rows[0]["request_id"], rows[0]["litellm_call_id"]), content
|
|
return rows[0]
|
|
|
|
|
|
@pytest.mark.parametrize("model_kind", ("router", "direct"))
|
|
@pytest.mark.parametrize("action", STREAMING_ACTIONS)
|
|
@pytest.mark.parametrize("scope", SCOPES)
|
|
def test_bedrock_streaming_actions_run_streaming_scoped_rails(
|
|
rig: ReproRig,
|
|
model_kind: ModelKind,
|
|
action: StreamingAction,
|
|
scope: EndpointScope,
|
|
) -> None:
|
|
marker: Final = f"scope-bedrock-stream-{uuid.uuid4().hex}"
|
|
model_path: Final = rig.models["bedrock_router"] if model_kind == "router" else BEDROCK_MODEL_ID
|
|
path, body = _bedrock_request(action, model_path, marker)
|
|
expected_body: Final = (
|
|
_bedrock_converse_stream(marker) if action == "converse-stream" else _bedrock_invoke_stream(marker)
|
|
)
|
|
call_id: Final = f"stream-scope-{uuid.uuid4().hex}"
|
|
key: Final = rig.scenario.key(guardrails=[rig.rails[f"bedrock_{scope}"]])
|
|
candidate: Final = rig.candidate if model_kind == "router" else rig.direct_candidate
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
path,
|
|
body,
|
|
key=key,
|
|
headers={"x-litellm-call-id": call_id},
|
|
)
|
|
assert response.status_code == 200, response.content
|
|
assert response.headers.get("content-type") == BEDROCK_EVENT_STREAM, dict(response.headers)
|
|
assert response.content == expected_body, response.content
|
|
provider_rows: Final = _matching_requests(rig.provider, marker)
|
|
assert len(provider_rows) == 1, (marker, provider_rows, response.content)
|
|
provider_body: Final = JSON_OBJECT.validate_json(provider_rows[0].body)
|
|
key_names: Final = _key_names(provider_body)
|
|
assert "is_streaming_request" not in key_names, provider_body
|
|
assert not tuple(name for name in key_names if name.startswith("litellm_")), provider_body
|
|
_spend_row_for_call(call_id, response.content)
|
|
sink_rows: Final = _rail_scans(rig.sink.drain(), rig.rails[f"bedrock_{scope}"], marker)
|
|
expected_scans: Final = int(scope == "streaming")
|
|
assert len(sink_rows) == expected_scans, (marker, model_kind, action, scope, sink_rows, response.content)
|
|
|
|
|
|
@pytest.mark.parametrize("model_kind", ("router", "direct"))
|
|
@pytest.mark.parametrize("action", NON_STREAMING_ACTIONS)
|
|
@pytest.mark.parametrize("scope", SCOPES)
|
|
def test_bedrock_non_streaming_actions_run_non_streaming_scoped_rails(
|
|
rig: ReproRig,
|
|
model_kind: ModelKind,
|
|
action: NonStreamingAction,
|
|
scope: EndpointScope,
|
|
) -> None:
|
|
marker: Final = f"scope-bedrock-nonstream-{uuid.uuid4().hex}"
|
|
model_path: Final = rig.models["bedrock_router"] if model_kind == "router" else BEDROCK_MODEL_ID
|
|
path, body = _bedrock_request(action, model_path, marker)
|
|
key: Final = rig.scenario.key(guardrails=[rig.rails[f"bedrock_{scope}"]])
|
|
candidate: Final = rig.candidate if model_kind == "router" else rig.direct_candidate
|
|
response: Final = candidate.request("POST", path, body, key=key)
|
|
assert response.status_code == 200, response.text
|
|
assert marker in response.text, response.text
|
|
provider_rows: Final = _matching_requests(rig.provider, marker)
|
|
assert len(provider_rows) == 1, (marker, provider_rows, response.text)
|
|
provider_body: Final = JSON_OBJECT.validate_json(provider_rows[0].body)
|
|
key_names: Final = _key_names(provider_body)
|
|
assert "is_streaming_request" not in key_names, provider_body
|
|
assert not tuple(name for name in key_names if name.startswith("litellm_")), provider_body
|
|
sink_rows: Final = _rail_scans(rig.sink.drain(), rig.rails[f"bedrock_{scope}"], marker)
|
|
expected_scans: Final = int(scope == "non_streaming")
|
|
assert len(sink_rows) == expected_scans, (marker, model_kind, action, scope, sink_rows, response.text)
|
|
|
|
|
|
@pytest.mark.parametrize("model_kind", ("router", "direct"))
|
|
def test_bedrock_model_id_streaming_action_text_on_converse_is_non_streaming(
|
|
rig: ReproRig,
|
|
model_kind: ModelKind,
|
|
) -> None:
|
|
marker: Final = f"scope-bedrock-converse-model-{uuid.uuid4().hex}"
|
|
model_path: Final = (
|
|
rig.models["bedrock_false_positive_router"] if model_kind == "router" else BEDROCK_FALSE_POSITIVE_MODEL_ID
|
|
)
|
|
path, body = _bedrock_request("converse", model_path, marker)
|
|
call_id: Final = f"stream-scope-bedrock-{uuid.uuid4().hex}"
|
|
key: Final = rig.scenario.key(
|
|
guardrails=[
|
|
rig.rails["bedrock_streaming"],
|
|
rig.rails["bedrock_non_streaming"],
|
|
]
|
|
)
|
|
candidate: Final = rig.candidate if model_kind == "router" else rig.direct_candidate
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
path,
|
|
body,
|
|
key=key,
|
|
headers={"x-litellm-call-id": call_id},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert marker in response.text, response.text
|
|
provider_rows: Final = _matching_requests(rig.provider, marker)
|
|
assert len(provider_rows) == 1, (marker, model_kind, provider_rows, response.text)
|
|
sink_rows: Final = rig.sink.drain()
|
|
streaming_rows: Final = _rail_scans(sink_rows, rig.rails["bedrock_streaming"], marker)
|
|
non_streaming_rows: Final = _rail_scans(sink_rows, rig.rails["bedrock_non_streaming"], marker)
|
|
assert streaming_rows == (), (marker, model_kind, streaming_rows, response.text)
|
|
assert len(non_streaming_rows) == 1, (marker, model_kind, non_streaming_rows, response.text)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", (False, True), ids=("stream-absent", "stream-true"))
|
|
def test_configured_passthrough_forwards_caller_is_streaming_request_field(
|
|
rig: ReproRig,
|
|
streamed: bool,
|
|
) -> None:
|
|
marker: Final = f"scope-passthrough-forward-{uuid.uuid4().hex}"
|
|
caller_value: Final = f"caller-{uuid.uuid4().hex}"
|
|
body: Final = {
|
|
"marker": marker,
|
|
"is_streaming_request": caller_value,
|
|
**({"stream": True} if streamed else {}),
|
|
}
|
|
response: Final = rig.candidate.request("POST", "/pt-forward", body)
|
|
assert response.status_code == 200, response.text
|
|
provider_rows: Final = _matching_requests(rig.provider, marker)
|
|
assert len(provider_rows) == 1, (marker, provider_rows, response.text)
|
|
upstream_body: Final = JSON_OBJECT.validate_json(provider_rows[0].body)
|
|
assert upstream_body.get("is_streaming_request") == caller_value, (
|
|
marker,
|
|
caller_value,
|
|
upstream_body,
|
|
response.text,
|
|
)
|
|
assert upstream_body == body, (marker, body, upstream_body, response.text)
|
|
if streamed:
|
|
assert response.headers.get("content-type", "").lower().startswith("text/event-stream"), dict(response.headers)
|
|
event_body: Final = response.text.removeprefix("data: ").split("\n", maxsplit=1)[0]
|
|
response_body: Final = JSON_OBJECT.validate_json(event_body)
|
|
else:
|
|
response_body = JSON_OBJECT.validate_json(response.content)
|
|
assert response_body == {"received": body}, response.text
|
|
|
|
|
|
@pytest.mark.parametrize(("hostile_field", "hostile_value"), HOSTILE_CLASSIFICATION_CASES)
|
|
def test_client_cannot_spoof_server_stream_classification(
|
|
rig: ReproRig,
|
|
hostile_field: str,
|
|
hostile_value: JsonValue,
|
|
) -> None:
|
|
chat_marker: Final = f"scope-chat-spoof-{uuid.uuid4().hex}"
|
|
chat_body: Final = {
|
|
"model": rig.models["chat"],
|
|
"messages": [{"role": "user", "content": chat_marker}],
|
|
"stream": False,
|
|
hostile_field: hostile_value,
|
|
}
|
|
chat_key: Final = rig.scenario.key(guardrails=[rig.rails["chat_streaming"]])
|
|
chat_response: Final = rig.candidate.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
chat_body,
|
|
key=chat_key,
|
|
)
|
|
chat_provider_rows: Final = _matching_requests(rig.provider, chat_marker)
|
|
chat_upstream_body: Final = chat_provider_rows[0].body.decode() if chat_provider_rows else "<no upstream request>"
|
|
print( # noqa: T201 # required chat hostile-body observation
|
|
f"chat hostile {hostile_field}={hostile_value!r}: "
|
|
f"status={chat_response.status_code}, response={chat_response.text!r}, upstream={chat_upstream_body}"
|
|
)
|
|
assert chat_response.status_code == 200, chat_response.text
|
|
assert len(chat_provider_rows) == 1, (chat_marker, chat_provider_rows, chat_response.text)
|
|
assert JSON_OBJECT.validate_json(chat_provider_rows[0].body) == {
|
|
"messages": [{"role": "user", "content": chat_marker}],
|
|
"model": "gpt-4o-mini",
|
|
hostile_field: hostile_value,
|
|
}, (hostile_field, hostile_value, chat_upstream_body)
|
|
assert _rail_scans(rig.sink.drain(), rig.rails["chat_streaming"], chat_marker) == (), (
|
|
chat_marker,
|
|
hostile_field,
|
|
hostile_value,
|
|
chat_response.text,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(("hostile_field", "hostile_value"), HOSTILE_CLASSIFICATION_CASES)
|
|
def test_configured_passthrough_cannot_spoof_server_stream_classification(
|
|
rig: ReproRig,
|
|
hostile_field: str,
|
|
hostile_value: JsonValue,
|
|
) -> None:
|
|
passthrough_marker: Final = f"scope-passthrough-spoof-{uuid.uuid4().hex}"
|
|
passthrough_body: Final = {
|
|
"marker": passthrough_marker,
|
|
"stream": False,
|
|
hostile_field: hostile_value,
|
|
}
|
|
passthrough_response: Final = rig.candidate.request("POST", "/pt-spoof", passthrough_body)
|
|
assert passthrough_response.status_code == 200, passthrough_response.text
|
|
passthrough_provider_rows: Final = _matching_requests(rig.provider, passthrough_marker)
|
|
assert len(passthrough_provider_rows) == 1, (
|
|
passthrough_marker,
|
|
passthrough_provider_rows,
|
|
passthrough_response.text,
|
|
)
|
|
assert _rail_scans(rig.sink.drain(), rig.rails["passthrough_spoof_streaming"], passthrough_marker) == (), (
|
|
passthrough_marker,
|
|
hostile_field,
|
|
hostile_value,
|
|
passthrough_response.text,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("streamed", "request_fields"),
|
|
((True, {"stream": True}), (False, {"stream": False}), (False, {})),
|
|
ids=("stream-true", "stream-false", "stream-absent"),
|
|
)
|
|
def test_passthrough_scope_follows_proxy_stream_decision(
|
|
rig: ReproRig,
|
|
streamed: bool,
|
|
request_fields: dict[str, JsonValue],
|
|
) -> None:
|
|
marker: Final = f"scope-passthrough-scope-{uuid.uuid4().hex}"
|
|
body: Final = {"marker": marker, **request_fields}
|
|
response: Final = rig.candidate.request("POST", "/pt-scope", body)
|
|
assert response.status_code == 200, response.text
|
|
if streamed:
|
|
expected_frame: Final = f"data: {_json({'received': body}).decode()}\n\ndata: [DONE]\n\n"
|
|
assert response.headers.get("content-type", "").lower().startswith("text/event-stream"), dict(response.headers)
|
|
assert response.text == expected_frame, response.text
|
|
assert response.headers.get("transfer-encoding", "").lower() == "chunked", dict(response.headers)
|
|
else:
|
|
response_body: Final = JSON_OBJECT.validate_json(response.content)
|
|
assert response_body == {"received": body}, response.text
|
|
assert "content-length" in response.headers, dict(response.headers)
|
|
provider_rows: Final = _matching_requests(rig.provider, marker)
|
|
assert len(provider_rows) == 1, (marker, provider_rows, response.text)
|
|
upstream_body: Final = JSON_OBJECT.validate_json(provider_rows[0].body)
|
|
assert upstream_body == body, (marker, body, upstream_body, response.text)
|
|
assert SERVER_STREAMING_CLASSIFICATION_KEY not in upstream_body, upstream_body
|
|
sink_rows: Final = rig.sink.drain()
|
|
streaming_rows: Final = _rail_scans(sink_rows, rig.rails["passthrough_streaming"], marker)
|
|
non_streaming_rows: Final = _rail_scans(sink_rows, rig.rails["passthrough_non_streaming"], marker)
|
|
assert len(streaming_rows) == int(streamed), (marker, streamed, streaming_rows, response.text)
|
|
assert len(non_streaming_rows) == int(not streamed), (marker, streamed, non_streaming_rows, response.text)
|
|
|
|
|
|
def test_invalid_yaml_stream_scope_keeps_rail_running_on_both_shapes(rig: ReproRig, tmp_path: Path) -> None:
|
|
name: Final = f"scope-invalid-yaml-{uuid.uuid4().hex}"
|
|
invalid_rail: Final = {
|
|
"guardrail_name": name,
|
|
"litellm_params": {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "pre_call",
|
|
"default_on": False,
|
|
"stream_scope": "sometimes",
|
|
"api_base": f"{rig.sink.url}/{name}",
|
|
"api_key": "synthetic-guardrail-key",
|
|
},
|
|
}
|
|
config: Final = _chat_proxy_config(rig.provider.url, [invalid_rail])
|
|
config_path: Final = tmp_path / "invalid-stream-scope.yaml"
|
|
config_path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy_process(rig.candidate, tmp_path, {}, config=config_path, workers=1) as owned:
|
|
markers: Final = tuple(f"scope-invalid-yaml-{int(streamed)}-{uuid.uuid4().hex}" for streamed in (False, True))
|
|
observations: Final = tuple(
|
|
_chat_request_with_scans(
|
|
owned.gateway,
|
|
rig.sink,
|
|
"scope-invalid-config-chat",
|
|
marker,
|
|
streamed,
|
|
name,
|
|
)
|
|
for streamed, marker in zip((False, True), markers)
|
|
)
|
|
assert tuple(response.status_code for response, _ in observations) == (200, 200), tuple(
|
|
response.text for response, _ in observations
|
|
)
|
|
assert tuple(len(sink_rows) for _, sink_rows in observations) == (1, 1), (markers, observations)
|
|
listed: Final = owned.gateway.request("GET", "/guardrails/list")
|
|
assert listed.status_code == 200, listed.text
|
|
list_payload: Final = JSON_OBJECT.validate_json(listed.content)
|
|
listed_guardrails: Final = list_payload.get("guardrails")
|
|
assert isinstance(listed_guardrails, list), list_payload
|
|
listed_rail: Final = next(
|
|
(row for row in listed_guardrails if isinstance(row, dict) and row.get("guardrail_name") == name),
|
|
None,
|
|
)
|
|
assert isinstance(listed_rail, dict), list_payload
|
|
listed_params: Final = listed_rail.get("litellm_params")
|
|
assert isinstance(listed_params, dict), listed_rail
|
|
assert listed_params.get("stream_scope") is None, listed_rail
|
|
|
|
|
|
def test_persisted_invalid_stream_scope_row_stays_readable_and_enforced(
|
|
rig: ReproRig,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
guardrail_id: Final = str(uuid.uuid4())
|
|
guardrail_name: Final = f"scope-invalid-persisted-{uuid.uuid4().hex}"
|
|
params: Final = {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "pre_call",
|
|
"default_on": False,
|
|
"api_base": f"{rig.sink.url}/{guardrail_name}",
|
|
"api_key": "synthetic-guardrail-key",
|
|
"stream_scope": "sometimes",
|
|
}
|
|
database_url: Final = os.environ.get("INTEGRATION_PROXY_DATABASE_URL") or os.environ["DATABASE_URL"]
|
|
write_rows(
|
|
'INSERT INTO "LiteLLM_GuardrailsTable" '
|
|
'("guardrail_id", "guardrail_name", "litellm_params", "created_at", "updated_at") '
|
|
"VALUES (%s, %s, %s::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
|
|
(guardrail_id, guardrail_name, json.dumps(params)),
|
|
database_url=database_url,
|
|
)
|
|
try:
|
|
config: Final = _chat_proxy_config(rig.provider.url, [])
|
|
config_path: Final = tmp_path / "persisted-invalid-stream-scope.yaml"
|
|
config_path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy_process(rig.candidate, tmp_path, {}, config=config_path, workers=1) as owned:
|
|
info: Final = eventually(
|
|
lambda: owned.gateway.request("GET", f"/guardrails/{guardrail_id}/info"),
|
|
lambda response: response.status_code != 404,
|
|
seconds=20,
|
|
)
|
|
listed: Final = owned.gateway.request("GET", "/v2/guardrails/list")
|
|
list_payload: Final = JSON_OBJECT.validate_json(listed.content) if listed.status_code == 200 else {}
|
|
listed_guardrails: Final = list_payload.get("guardrails")
|
|
includes_row: Final = isinstance(listed_guardrails, list) and any(
|
|
isinstance(row, dict) and row.get("guardrail_id") == guardrail_id for row in listed_guardrails
|
|
)
|
|
markers: Final = (
|
|
f"scope-invalid-persisted-0-{uuid.uuid4().hex}",
|
|
f"scope-invalid-persisted-1-{uuid.uuid4().hex}",
|
|
)
|
|
observations: Final = tuple(
|
|
_chat_request_with_scans(
|
|
owned.gateway,
|
|
rig.sink,
|
|
"scope-invalid-config-chat",
|
|
marker,
|
|
streamed,
|
|
guardrail_name,
|
|
)
|
|
for streamed, marker in zip((False, True), markers)
|
|
)
|
|
assert (
|
|
info.status_code == 200
|
|
and listed.status_code == 200
|
|
and includes_row
|
|
and tuple(response.status_code for response, _ in observations) == (200, 200)
|
|
and tuple(len(sink_rows) for _, sink_rows in observations) == (1, 1)
|
|
), {
|
|
"info": (info.status_code, info.text),
|
|
"list": (listed.status_code, listed.text),
|
|
"includes_row": includes_row,
|
|
"responses": tuple((response.status_code, response.text) for response, _ in observations),
|
|
"scan_counts": tuple(len(sink_rows) for _, sink_rows in observations),
|
|
}
|
|
finally:
|
|
write_rows(
|
|
'DELETE FROM "LiteLLM_GuardrailsTable" WHERE guardrail_id=%s',
|
|
(guardrail_id,),
|
|
database_url=database_url,
|
|
)
|
|
|
|
|
|
def test_management_rejects_invalid_stream_scope(rig: ReproRig) -> None:
|
|
name: Final = f"scope-invalid-management-{uuid.uuid4().hex}"
|
|
invalid_params: Final = {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "pre_call",
|
|
"default_on": False,
|
|
"api_base": f"{rig.sink.url}/{name}",
|
|
"api_key": "synthetic-guardrail-key",
|
|
"stream_scope": "sometimes",
|
|
}
|
|
created_invalid: Final = rig.candidate.request(
|
|
"POST",
|
|
"/guardrails",
|
|
{"guardrail": {"guardrail_name": name, "litellm_params": invalid_params}},
|
|
)
|
|
assert created_invalid.status_code == 422, created_invalid.text
|
|
|
|
valid_params: Final = {**invalid_params, "stream_scope": "both"}
|
|
created: Final = rig.candidate.request(
|
|
"POST",
|
|
"/guardrails",
|
|
{"guardrail": {"guardrail_name": name, "litellm_params": valid_params}},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
guardrail_id: Final = str(created.json()["guardrail_id"])
|
|
try:
|
|
put_response: Final = rig.candidate.request(
|
|
"PUT",
|
|
f"/guardrails/{guardrail_id}",
|
|
{"guardrail": {"guardrail_name": name, "litellm_params": invalid_params}},
|
|
)
|
|
patch_response: Final = rig.candidate.request(
|
|
"PATCH",
|
|
f"/guardrails/{guardrail_id}",
|
|
{"litellm_params": {"stream_scope": "sometimes"}},
|
|
)
|
|
assert put_response.status_code == 422, put_response.text
|
|
assert patch_response.status_code == 422, patch_response.text
|
|
finally:
|
|
deleted: Final = rig.candidate.request("DELETE", f"/guardrails/{guardrail_id}")
|
|
assert deleted.status_code == 200, deleted.text
|