mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
* fix(responses): stream guardrail pre-call block as SSE with a typed output item Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(responses): import blocked usage helper from the guardrail utils module Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): drop narrating docstrings and poll without rebinding Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): cover pre-call guardrail block on /v1/responses stream and json Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): audit cells for responses guardrail block contract Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): observe upstream on the recorded chat route for responses denial cells Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): tidy responses denial audit cells Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): wait for worker count to recover after SIGKILL Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): require a replacement worker after SIGKILL Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(responses): type the blocked response test helpers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yucheng <yucheng@berri.ai>
1191 lines
59 KiB
Python
1191 lines
59 KiB
Python
import json
|
|
import os
|
|
import signal
|
|
import socket
|
|
import uuid
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import psutil
|
|
import pytest
|
|
import yaml
|
|
from integration._support.client import Gateway, eventually, object_value
|
|
from integration._support.database import read_rows
|
|
from integration._support.mcp import mcp_peer, register_mcp, tool_names
|
|
from integration._support.process import group_members, owned_proxy, owned_proxy_process
|
|
from integration._support.wire import Reply, Request, wire_server
|
|
from openai import AsyncOpenAI, OpenAI
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.rewrite_reaches_correct_anthropic_positions")
|
|
def test_guardrail_rewrites_system_and_user_in_actual_anthropic_request(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
originals: Final = ["synthetic private system", "synthetic private user", "unchanged sibling"]
|
|
replacements: Final = ["permitted system", "permitted user", "unchanged sibling"]
|
|
|
|
def guardrail(request: Request) -> Reply:
|
|
assert request.target == "/beta/litellm_basic_guardrail_api"
|
|
body: Final = json.loads(request.body)
|
|
assert body["texts"] == originals
|
|
return Reply(body=json.dumps({"action": "GUARDRAIL_INTERVENED", "texts": replacements}).encode())
|
|
|
|
def provider(request: Request) -> Reply:
|
|
assert request.target == "/v1/messages"
|
|
body: Final = json.loads(request.body)
|
|
assert body["system"] == [{"type": "text", "text": replacements[0]}]
|
|
assert body["messages"] == [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": replacements[1]}, {"type": "text", "text": replacements[2]}],
|
|
}
|
|
]
|
|
assert all(text.encode() not in request.body for text in originals[:2])
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": identity,
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-sonnet-4-5-20250929",
|
|
"content": [{"type": "text", "text": "permitted response"}],
|
|
"stop_reason": "end_turn",
|
|
"stop_sequence": None,
|
|
"usage": {"input_tokens": 11, "output_tokens": 4},
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
with wire_server(guardrail) as policy, wire_server(provider) as upstream:
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [
|
|
{
|
|
"guardrail_name": identity,
|
|
"litellm_params": {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "pre_call",
|
|
"default_on": True,
|
|
"api_base": policy.url,
|
|
"api_key": "synthetic-guardrail-key",
|
|
},
|
|
}
|
|
]
|
|
path: Final = tmp_path / "rewrite.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(
|
|
model="anthropic/claude-sonnet-4-5-20250929", api_base=upstream.url, api_key="synthetic-anthropic-key"
|
|
)
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{
|
|
"model": model,
|
|
"max_tokens": 16,
|
|
"messages": [
|
|
{"role": "system", "content": originals[0]},
|
|
{"role": "user", "content": [{"type": "text", "text": text} for text in originals[1:]]},
|
|
],
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["choices"][0]["message"]["content"] == "permitted response"
|
|
assert response.json()["choices"][0]["finish_reason"] == "stop"
|
|
assert response.json()["usage"]["total_tokens"] == 15
|
|
assert len(policy.drain()) == len(upstream.drain()) == 1
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.anthropic_messages_caller_metadata_keeps_guardrail_spend_log")
|
|
def test_anthropic_messages_with_caller_metadata_keeps_guardrail_information_in_spend_log(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
prompt: Final = "synthetic allowed prompt " + identity
|
|
caller_metadata: Final = {"user_id": "device-account-session"}
|
|
|
|
def guardrail(request: Request) -> Reply:
|
|
assert request.target == "/beta/litellm_basic_guardrail_api"
|
|
assert json.loads(request.body)["texts"] == [prompt]
|
|
return Reply(body=json.dumps({"action": "NONE"}).encode())
|
|
|
|
def provider(request: Request) -> Reply:
|
|
assert request.target == "/v1/messages"
|
|
body: Final = json.loads(request.body)
|
|
assert body["messages"] == [{"role": "user", "content": prompt}]
|
|
assert body["metadata"] == caller_metadata
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": identity,
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-sonnet-4-5-20250929",
|
|
"content": [{"type": "text", "text": "permitted response"}],
|
|
"stop_reason": "end_turn",
|
|
"stop_sequence": None,
|
|
"usage": {"input_tokens": 11, "output_tokens": 4},
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
with wire_server(guardrail) as policy, wire_server(provider) as upstream:
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [
|
|
{
|
|
"guardrail_name": identity,
|
|
"litellm_params": {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "pre_call",
|
|
"default_on": True,
|
|
"api_base": policy.url,
|
|
"api_key": "synthetic-guardrail-key",
|
|
},
|
|
}
|
|
]
|
|
path: Final = tmp_path / "caller-metadata.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(
|
|
model="anthropic/claude-sonnet-4-5-20250929", api_base=upstream.url, api_key="synthetic-anthropic-key"
|
|
)
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/messages",
|
|
{
|
|
"model": model,
|
|
"max_tokens": 16,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"metadata": caller_metadata,
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["content"] == [{"type": "text", "text": "permitted response"}], response.text
|
|
assert response.headers["x-litellm-applied-guardrails"] == identity, dict(response.headers)
|
|
assert len(policy.drain()) == len(upstream.drain()) == 1
|
|
rows: Final = eventually(
|
|
lambda: read_rows(
|
|
'SELECT call_type, metadata FROM "LiteLLM_SpendLogs" WHERE model_group=%s',
|
|
(model,),
|
|
),
|
|
lambda values: len(values) == 1,
|
|
seconds=70,
|
|
)
|
|
assert rows[0]["call_type"] == "anthropic_messages", rows[0]
|
|
saved: Final = object_value(rows[0]["metadata"])
|
|
entries: Final = saved["guardrail_information"]
|
|
assert isinstance(entries, list) and len(entries) == 1, saved
|
|
entry: Final = object_value(entries[0])
|
|
assert entry["guardrail_name"] == identity, saved
|
|
assert entry["guardrail_mode"] == "pre_call", saved
|
|
assert entry["guardrail_status"] == "success", saved
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.denial_prevents_provider_with_allowed_control")
|
|
def test_guardrail_denial_prevents_provider_and_preserves_allowed_control(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
|
|
def guardrail(request: Request) -> Reply:
|
|
assert request.target == "/beta/litellm_basic_guardrail_api"
|
|
body: Final = json.loads(request.body)
|
|
assert body["texts"] in (["synthetic denied marker"], ["synthetic allowed marker"])
|
|
result: Final = (
|
|
{"action": "BLOCKED", "blocked_reason": "synthetic policy denial"}
|
|
if body["texts"] == ["synthetic denied marker"]
|
|
else {"action": "NONE"}
|
|
)
|
|
return Reply(body=json.dumps(result).encode())
|
|
|
|
with wire_server(guardrail) as policy:
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [
|
|
{
|
|
"guardrail_name": identity,
|
|
"litellm_params": {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "pre_call",
|
|
"default_on": True,
|
|
"api_base": policy.url,
|
|
"api_key": "synthetic-guardrail-key",
|
|
},
|
|
}
|
|
]
|
|
path: Final = tmp_path / "deny.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model()
|
|
key: Final = scenario.key(models=[model])
|
|
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as observed:
|
|
observed.get("/__observations")
|
|
denied: Final = candidate.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{"model": model, "messages": [{"role": "user", "content": "synthetic denied marker"}]},
|
|
key=key,
|
|
)
|
|
assert denied.status_code == 400 and "synthetic policy denial" in denied.text, denied.text
|
|
assert observed.get("/__observations").json()["requests"] == []
|
|
allowed: Final = candidate.chat(model, text="synthetic allowed marker", key=key)
|
|
assert allowed["usage"]["total_tokens"] == 40
|
|
assert (
|
|
allowed["choices"][0]["message"]["content"]
|
|
== "Hello! This is a mock response from the fake OpenAI endpoint."
|
|
)
|
|
assert len(observed.get("/__observations").json()["requests"]) == 1
|
|
assert len(policy.drain()) == 2
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.bedrock_passthrough_converse_scans_only_caller_content")
|
|
def test_bedrock_passthrough_converse_guardrail_ignores_denied_term_in_tool_definition(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
denied: Final = "synthetic denied marker"
|
|
allowed: Final = "synthetic allowed weather question"
|
|
access_key: Final = "AKIASYNTHETICPASSTHROUGH"
|
|
tool_config: Final = {
|
|
"tools": [
|
|
{
|
|
"toolSpec": {
|
|
"name": "lookup_weather",
|
|
"description": f"Look up the forecast, never answer a {denied}",
|
|
"inputSchema": {
|
|
"json": {
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string", "enum": [denied]}},
|
|
"required": ["city"],
|
|
}
|
|
},
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
def guardrail(request: Request) -> Reply:
|
|
assert request.target == "/beta/litellm_basic_guardrail_api"
|
|
texts: Final = json.loads(request.body)["texts"]
|
|
result: Final = (
|
|
{"action": "BLOCKED", "blocked_reason": "synthetic policy denial"}
|
|
if any(denied in text for text in texts)
|
|
else {"action": "NONE"}
|
|
)
|
|
return Reply(body=json.dumps(result).encode())
|
|
|
|
def runtime(request: Request) -> Reply:
|
|
assert request.target == "/model/anthropic.claude-3-haiku-20240307-v1:0/converse"
|
|
assert request.headers["authorization"].startswith(f"AWS4-HMAC-SHA256 Credential={access_key}/"), (
|
|
request.headers
|
|
)
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"output": {"message": {"role": "assistant", "content": [{"text": "sunny passthrough control"}]}},
|
|
"stopReason": "end_turn",
|
|
"usage": {"inputTokens": 11, "outputTokens": 4, "totalTokens": 15},
|
|
"metrics": {"latencyMs": 1},
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
with wire_server(guardrail) as policy, wire_server(runtime) as bedrock, gateway.scenario() as scenario:
|
|
model: Final = scenario.model(
|
|
model="bedrock/anthropic.claude-3-haiku-20240307-v1:0",
|
|
api_key=None,
|
|
api_base=bedrock.url,
|
|
aws_access_key_id=access_key,
|
|
aws_secret_access_key="synthetic-secret",
|
|
aws_region_name="us-east-1",
|
|
)
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [
|
|
{
|
|
"guardrail_name": identity,
|
|
"litellm_params": {
|
|
"guardrail": "generic_guardrail_api",
|
|
"mode": "pre_call",
|
|
"default_on": True,
|
|
"api_base": policy.url,
|
|
"api_key": "synthetic-guardrail-key",
|
|
},
|
|
}
|
|
]
|
|
path: Final = tmp_path / "bedrock-passthrough.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy(gateway, tmp_path, {}, config=path) as candidate:
|
|
route: Final = f"/bedrock/model/{model}/converse"
|
|
passed: Final = candidate.request(
|
|
"POST",
|
|
route,
|
|
{"messages": [{"role": "user", "content": [{"text": allowed}]}], "toolConfig": tool_config},
|
|
)
|
|
assert passed.status_code == 200, passed.text
|
|
assert passed.json()["output"]["message"]["content"] == [{"text": "sunny passthrough control"}]
|
|
forwarded: Final = bedrock.drain()
|
|
assert len(forwarded) == 1, "the runtime peer must see exactly the allowed request"
|
|
assert json.loads(forwarded[0].body)["toolConfig"] == tool_config
|
|
blocked: Final = candidate.request(
|
|
"POST",
|
|
route,
|
|
{"messages": [{"role": "user", "content": [{"text": denied}]}], "toolConfig": tool_config},
|
|
)
|
|
assert blocked.status_code == 400 and "synthetic policy denial" in blocked.text, blocked.text
|
|
assert bedrock.drain() == ()
|
|
assert [json.loads(request.body)["texts"] for request in policy.drain()] == [[allowed], [denied]]
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.bedrock_post_call_scans_streamed_anthropic_messages_tool_use")
|
|
def test_bedrock_guardrail_streams_anthropic_messages_tool_use_instead_of_chunk_builder_500(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
guardrail_id: Final = "synthetic" + uuid.uuid4().hex[:8]
|
|
spoken: Final = "Checking the forecast"
|
|
frames: Final = (
|
|
'event: message_start\ndata: {"type": "message_start", "message": {"id": "msg_synthetic", "type": "message", '
|
|
'"role": "assistant", "model": "claude-sonnet-4-5-20250929", "content": [], "stop_reason": null, '
|
|
'"stop_sequence": null, "usage": {"input_tokens": 11, "output_tokens": 1}}}\n\n',
|
|
'event: content_block_start\ndata: {"type": "content_block_start", "index": 0, '
|
|
'"content_block": {"type": "text", "text": ""}}\n\n',
|
|
'event: content_block_delta\ndata: {"type": "content_block_delta", "index": 0, '
|
|
f'"delta": {{"type": "text_delta", "text": "{spoken}"}}}}\n\n',
|
|
'event: content_block_stop\ndata: {"type": "content_block_stop", "index": 0}\n\n',
|
|
'event: content_block_start\ndata: {"type": "content_block_start", "index": 1, '
|
|
'"content_block": {"type": "tool_use", "id": "toolu_synthetic", "name": "lookup_weather", "input": {}}}\n\n',
|
|
'event: content_block_delta\ndata: {"type": "content_block_delta", "index": 1, '
|
|
'"delta": {"type": "input_json_delta", "partial_json": "{\\"city\\": \\"Paris\\"}"}}\n\n',
|
|
'event: content_block_stop\ndata: {"type": "content_block_stop", "index": 1}\n\n',
|
|
'event: message_delta\ndata: {"type": "message_delta", "delta": {"stop_reason": "tool_use", '
|
|
'"stop_sequence": null}, "usage": {"output_tokens": 9}}\n\n',
|
|
'event: message_stop\ndata: {"type": "message_stop"}\n\n',
|
|
)
|
|
tools: Final = [
|
|
{
|
|
"name": "lookup_weather",
|
|
"description": "Look up the forecast for a city",
|
|
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
|
|
}
|
|
]
|
|
|
|
def guardrail(request: Request) -> Reply:
|
|
assert request.target == f"/guardrail/{guardrail_id}/version/DRAFT/apply", request.target
|
|
body: Final = json.loads(request.body)
|
|
assert body["source"] == "OUTPUT", body
|
|
assert body["content"] == [{"text": {"text": spoken}}], body
|
|
return Reply(body=json.dumps({"action": "NONE", "outputs": [], "assessments": []}).encode())
|
|
|
|
def provider(request: Request) -> Reply:
|
|
assert request.target == "/v1/messages"
|
|
body: Final = json.loads(request.body)
|
|
assert body["stream"] is True, body
|
|
assert body["tools"] == tools, body
|
|
return Reply(content_type="text/event-stream", chunks=tuple(frame.encode() for frame in frames))
|
|
|
|
with wire_server(guardrail) as policy, wire_server(provider) as upstream:
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [
|
|
{
|
|
"guardrail_name": identity,
|
|
"litellm_params": {
|
|
"guardrail": "bedrock",
|
|
"mode": "post_call",
|
|
"default_on": True,
|
|
"mask_response_content": True,
|
|
"guardrailIdentifier": guardrail_id,
|
|
"guardrailVersion": "DRAFT",
|
|
"aws_region_name": "us-east-1",
|
|
"aws_access_key_id": "AKIASYNTHETICGUARDRAIL",
|
|
"aws_secret_access_key": "synthetic-secret",
|
|
"aws_bedrock_runtime_endpoint": policy.url,
|
|
},
|
|
}
|
|
]
|
|
path: Final = tmp_path / "bedrock-stream.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(
|
|
model="anthropic/claude-sonnet-4-5-20250929", api_base=upstream.url, api_key="synthetic-anthropic-key"
|
|
)
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/messages",
|
|
{
|
|
"model": model,
|
|
"max_tokens": 64,
|
|
"stream": True,
|
|
"tools": tools,
|
|
"messages": [{"role": "user", "content": f"What is the weather in Paris? {identity}"}],
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
head, separator, tail = response.text.partition("\n\n")
|
|
assert separator == "\n\n", response.text
|
|
assert head.startswith("event: message_start\ndata: "), response.text
|
|
assert json.loads(head.removeprefix("event: message_start\ndata: ")) == {
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": "msg_synthetic",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": model,
|
|
"content": [],
|
|
"stop_reason": None,
|
|
"stop_sequence": None,
|
|
"usage": {"input_tokens": 11, "output_tokens": 1},
|
|
},
|
|
}, response.text
|
|
assert tail == "".join(frames[1:]), response.text
|
|
assert len(policy.drain()) == len(upstream.drain()) == 1
|
|
|
|
|
|
@pytest.mark.covers("other.mcp.guardrails.request_selection_blocks_resolved_tool_without_execution")
|
|
def test_request_selected_mcp_guardrail_blocks_direct_and_virtual_calls(gateway: Gateway, tmp_path: Path) -> None:
|
|
guardrail = "mcp-policy-" + uuid.uuid4().hex
|
|
config = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [
|
|
{
|
|
"guardrail_name": guardrail,
|
|
"litellm_params": {
|
|
"guardrail": "custom_code",
|
|
"mode": "pre_mcp_call",
|
|
"default_on": False,
|
|
"custom_code": (
|
|
"def apply_guardrail(inputs, request_data, input_type):\n"
|
|
' if inputs.get("tools", [{}])[0].get("function", {}).get("name") == "add":\n'
|
|
' return block("integration resolved add denied")\n'
|
|
" return allow()\n"
|
|
),
|
|
},
|
|
}
|
|
]
|
|
path = tmp_path / "mcp-guardrail.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
with (
|
|
owned_proxy(gateway, tmp_path, {}, config=path) as candidate,
|
|
mcp_peer() as peer,
|
|
candidate.scenario() as scenario,
|
|
):
|
|
identity = register_mcp(scenario, peer, "guardrail" + uuid.uuid4().hex)
|
|
permission = {"mcp_servers": [identity], "mcp_tool_search_enabled": True}
|
|
key = scenario.key(object_permission=permission)
|
|
key_selected = scenario.key(object_permission=permission, guardrails=[guardrail])
|
|
team = scenario.team(guardrails=[guardrail], object_permission={"mcp_servers": [identity]})
|
|
team_selected = scenario.key(team_id=team, object_permission=permission)
|
|
catalog_key = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
names = tool_names(candidate, catalog_key, identity)
|
|
assert set(names) == {"add", "multiply", "fail"}
|
|
for virtual in (False, True):
|
|
for caller, selected, tool, expected in (
|
|
(key, [], "add", 8),
|
|
(key, [guardrail], "add", None),
|
|
(key_selected, [], "add", None),
|
|
(team_selected, [], "add", None),
|
|
(key, [guardrail], "multiply", 15),
|
|
):
|
|
arguments = {"a": 3, "b": 5}
|
|
peer.drain()
|
|
response = candidate.client.post(
|
|
"/mcp-rest/tools/call",
|
|
headers={"x-litellm-api-key": caller},
|
|
json={
|
|
"server_id": identity,
|
|
"name": "mcp_tool_call" if virtual else names[tool],
|
|
"arguments": {"tool_name": names[tool], "arguments": arguments} if virtual else arguments,
|
|
"guardrails": selected,
|
|
},
|
|
)
|
|
calls = tuple(item for item in peer.drain() if item["body"].get("method") == "tools/call")
|
|
if expected is None:
|
|
assert response.status_code == 400, response.text
|
|
assert "integration resolved add denied" in response.text, response.text
|
|
assert calls == (), "pre-call denial must prevent upstream execution"
|
|
else:
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["isError"] is False
|
|
assert response.json()["content"][0]["text"] == str(expected), response.text
|
|
assert len(calls) == 1
|
|
assert calls[0]["body"]["params"]["name"] == tool
|
|
assert calls[0]["body"]["params"]["arguments"] == arguments
|
|
|
|
|
|
_RESPONSES_DENIAL: Final = "This model is not currently available."
|
|
|
|
|
|
def _deny_guardrail(name: str, denial: str = _RESPONSES_DENIAL) -> dict[str, object]:
|
|
return {
|
|
"guardrail_name": name,
|
|
"litellm_params": {
|
|
"guardrail": "custom_code",
|
|
"mode": "pre_call",
|
|
"default_on": False,
|
|
"custom_code": (f"def apply_guardrail(inputs, request_data, input_type):\n return block({denial!r})\n"),
|
|
},
|
|
}
|
|
|
|
|
|
def _responses_denial_config(tmp_path: Path, identity: str, denial: str = _RESPONSES_DENIAL) -> Path:
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [_deny_guardrail(identity, denial)]
|
|
path: Final = tmp_path / "responses-deny.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
return path
|
|
|
|
|
|
def _assert_blocked_message_item(item: dict[str, object], response: dict[str, object]) -> None:
|
|
assert item["type"] == "message", item
|
|
assert item["role"] == "assistant", item
|
|
assert item["status"] == "completed", item
|
|
assert str(item["id"]).startswith("msg_"), item
|
|
assert item["content"] == [{"type": "output_text", "text": _RESPONSES_DENIAL, "annotations": []}], item
|
|
assert response["status"] == "completed", response
|
|
usage: Final = response["usage"]
|
|
assert isinstance(usage, dict), response
|
|
assert (usage["input_tokens"], usage["output_tokens"], usage["total_tokens"]) == (0, 0, 0), usage
|
|
|
|
|
|
def _response_id(index: int, response: httpx.Response) -> str:
|
|
assert response.status_code == 200, (index, response.text)
|
|
if index % 3 == 0:
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
return str(_blocked_stream_events(response.text)[-1]["response"]["id"])
|
|
if index % 3 == 1:
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
blocked: Final = _blocked_stream_events(response.text)[-1]["response"]
|
|
_assert_blocked_message_item(blocked["output"][0], blocked)
|
|
return str(blocked["id"])
|
|
assert response.headers["content-type"].startswith("application/json"), response.text
|
|
body: Final = response.json()
|
|
_assert_blocked_message_item(body["output"][0], body)
|
|
return str(body["id"])
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_streams_typed_message")
|
|
def test_responses_pre_call_denial_streams_sse_with_typed_message_item(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as observed:
|
|
observed.get("/__observations")
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": model, "input": "say hi", "stream": True, "guardrails": [identity]},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), (
|
|
response.headers["content-type"],
|
|
response.text,
|
|
)
|
|
lines: Final = tuple(line for line in response.text.split("\n") if line.startswith("data: "))
|
|
assert lines[-1] == "data: [DONE]", response.text
|
|
events: Final = tuple(json.loads(line.removeprefix("data: ")) for line in lines[:-1])
|
|
kinds: Final = tuple(event["type"] for event in events)
|
|
assert tuple(kind for kind in kinds if kind != "response.output_text.delta") == (
|
|
"response.created",
|
|
"response.in_progress",
|
|
"response.output_item.added",
|
|
"response.content_part.added",
|
|
"response.output_text.done",
|
|
"response.content_part.done",
|
|
"response.output_item.done",
|
|
"response.completed",
|
|
), kinds
|
|
assert kinds.index("response.output_text.delta") == kinds.index("response.content_part.added") + 1, kinds
|
|
assert "".join(event["delta"] for event in events if event["type"] == "response.output_text.delta") == (
|
|
_RESPONSES_DENIAL
|
|
)
|
|
completed: Final = events[-1]["response"]
|
|
assert completed["output"] == [events[-2]["item"]], (completed, events[-2])
|
|
_assert_blocked_message_item(completed["output"][0], completed)
|
|
assert observed.get("/__observations").json()["requests"] == []
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_returns_typed_message")
|
|
def test_responses_pre_call_denial_returns_json_with_typed_message_item(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as observed:
|
|
observed.get("/__observations")
|
|
response: Final = candidate.request(
|
|
"POST", "/v1/responses", {"model": model, "input": "say hi", "guardrails": [identity]}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("application/json"), response.headers["content-type"]
|
|
body: Final = response.json()
|
|
assert body["object"] == "response", body
|
|
assert len(body["output"]) == 1, body
|
|
_assert_blocked_message_item(body["output"][0], body)
|
|
assert observed.get("/__observations").json()["requests"] == []
|
|
|
|
|
|
_RESPONSES_OUTPUT_DENIAL: Final = "Output withheld by policy."
|
|
_UPSTREAM_INPUT_TOKENS: Final = 20
|
|
_UPSTREAM_OUTPUT_TOKENS: Final = 20
|
|
_UPSTREAM_TOTAL_TOKENS: Final = 40
|
|
|
|
|
|
def _responses_output_denial_config(tmp_path: Path, identity: str, model: str) -> Path:
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["guardrails"] = [
|
|
{
|
|
"guardrail_name": identity,
|
|
"litellm_params": {
|
|
"guardrail": "custom_code",
|
|
"mode": "post_call",
|
|
"default_on": False,
|
|
"custom_code": (
|
|
"def apply_guardrail(inputs, request_data, input_type):\n"
|
|
f" return block({_RESPONSES_OUTPUT_DENIAL!r})\n"
|
|
),
|
|
},
|
|
}
|
|
]
|
|
config["policies"] = {
|
|
f"{identity}-pipeline": {
|
|
"guardrails": {"add": [identity]},
|
|
"pipeline": {
|
|
"mode": "post_call",
|
|
"steps": [
|
|
{
|
|
"guardrail": identity,
|
|
"on_pass": "allow",
|
|
"on_fail": "modify_response",
|
|
"modify_response_message": _RESPONSES_OUTPUT_DENIAL,
|
|
}
|
|
],
|
|
},
|
|
}
|
|
}
|
|
config["policy_attachments"] = [{"policy": f"{identity}-pipeline", "models": [model]}]
|
|
path: Final = tmp_path / "responses-output-deny.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
return path
|
|
|
|
|
|
def _blocked_stream_events(text: str) -> tuple[dict[str, object], ...]:
|
|
lines: Final = tuple(line for line in text.split("\n") if line.startswith("data: "))
|
|
assert lines[-1] == "data: [DONE]", text
|
|
return tuple(json.loads(line.removeprefix("data: ")) for line in lines[:-1])
|
|
|
|
|
|
def _dead_api_base() -> str:
|
|
with socket.socket() as reserve:
|
|
reserve.bind(("127.0.0.1", 0))
|
|
port: Final = reserve.getsockname()[1]
|
|
return f"http://127.0.0.1:{port}/v1"
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_openai_sdk_streams_typed_message")
|
|
def test_responses_pre_call_denial_openai_sdk_streams_typed_message(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
client: Final = OpenAI(
|
|
base_url=f"{candidate.client.base_url}/v1", api_key=candidate.key, max_retries=0, timeout=15
|
|
)
|
|
events: Final = tuple(
|
|
client.responses.create(model=model, input="say hi", stream=True, extra_body={"guardrails": [identity]})
|
|
)
|
|
assert events[-1].type == "response.completed", [event.type for event in events]
|
|
completed: Final = events[-1].response
|
|
assert completed is not None and len(completed.output) == 1, completed
|
|
item: Final = completed.output[0]
|
|
assert item.type == "message", item
|
|
assert item.role == "assistant" and item.status == "completed", item
|
|
assert item.content[0].type == "output_text" and item.content[0].text == _RESPONSES_DENIAL, item.content
|
|
assert completed.usage is not None and completed.usage.total_tokens == 0, completed.usage
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_openai_async_sdk_streams_typed_message")
|
|
async def test_responses_pre_call_denial_openai_async_sdk_streams_typed_message(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
client: Final = AsyncOpenAI(
|
|
base_url=f"{candidate.client.base_url}/v1", api_key=candidate.key, max_retries=0, timeout=15
|
|
)
|
|
stream: Final = await client.responses.create(
|
|
model=model, input="say hi", stream=True, extra_body={"guardrails": [identity]}
|
|
)
|
|
kinds: Final = [event.type async for event in stream]
|
|
assert kinds[-1] == "response.completed", kinds
|
|
assert "response.output_text.delta" in kinds, kinds
|
|
assert "response.in_progress" in kinds, kinds
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_openai_sdk_returns_typed_message")
|
|
def test_responses_pre_call_denial_openai_sdk_returns_typed_message(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
client: Final = OpenAI(
|
|
base_url=f"{candidate.client.base_url}/v1", api_key=candidate.key, max_retries=0, timeout=15
|
|
)
|
|
body: Final = client.responses.create(model=model, input="say hi", extra_body={"guardrails": [identity]})
|
|
assert body.object == "response" and body.status == "completed", body
|
|
assert len(body.output) == 1, body.output
|
|
item: Final = body.output[0]
|
|
assert item.type == "message" and item.role == "assistant", item
|
|
assert item.content[0].type == "output_text" and item.content[0].text == _RESPONSES_DENIAL, item.content
|
|
assert body.output_text == _RESPONSES_DENIAL, body
|
|
assert body.usage is not None and body.usage.total_tokens == 0, body.usage
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_stream_false_returns_json")
|
|
def test_responses_pre_call_denial_stream_false_returns_json(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
response: Final = candidate.request(
|
|
"POST", "/v1/responses", {"model": model, "input": "say hi", "stream": False, "guardrails": [identity]}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("application/json"), response.text
|
|
body: Final = response.json()
|
|
_assert_blocked_message_item(body["output"][0], body)
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_stream_string_true_returns_json")
|
|
def test_responses_pre_call_denial_stream_string_true_returns_json(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
response: Final = candidate.request(
|
|
"POST", "/v1/responses", {"model": model, "input": "say hi", "stream": "true", "guardrails": [identity]}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("application/json"), (
|
|
response.headers["content-type"],
|
|
response.text,
|
|
)
|
|
body: Final = response.json()
|
|
_assert_blocked_message_item(body["output"][0], body)
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_stream_event_vocabulary")
|
|
def test_responses_pre_call_denial_stream_event_vocabulary(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
second: Final = "guardrail-2-" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
loaded: Final = yaml.safe_load(config.read_text())
|
|
loaded["guardrails"].append(_deny_guardrail(second))
|
|
config.write_text(yaml.safe_dump(loaded))
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as observed:
|
|
observed.get("/__observations")
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": model, "input": "say hi", "stream": True, "guardrails": [identity, second]},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
events: Final = _blocked_stream_events(response.text)
|
|
kinds: Final = {event["type"] for event in events}
|
|
assert kinds == {
|
|
"response.created",
|
|
"response.in_progress",
|
|
"response.output_item.added",
|
|
"response.content_part.added",
|
|
"response.output_text.delta",
|
|
"response.output_text.done",
|
|
"response.content_part.done",
|
|
"response.output_item.done",
|
|
"response.completed",
|
|
}, kinds
|
|
item_done: Final = tuple(event for event in events if event["type"] == "response.output_item.done")
|
|
assert len(item_done) == 1, events
|
|
assert len(events[-1]["response"]["output"]) == 1, events[-1]
|
|
assert observed.get("/__observations").json()["requests"] == []
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_stream_large_denial_text")
|
|
def test_responses_pre_call_denial_stream_large_denial_text(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
denial: Final = ("Denied: " + "mixed ascii and unicode text " * 200 + "fin")[:5000]
|
|
config: Final = _responses_denial_config(tmp_path, identity, denial)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": model, "input": "say hi", "stream": True, "guardrails": [identity]},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
events: Final = _blocked_stream_events(response.text)
|
|
assert "".join(event["delta"] for event in events if event["type"] == "response.output_text.delta") == denial
|
|
done: Final = next(event for event in events if event["type"] == "response.output_text.done")
|
|
assert done["text"] == denial, done
|
|
completed: Final = events[-1]["response"]
|
|
assert completed["output"][0]["content"][0]["text"] == denial, completed
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_stream_requests_have_distinct_ids")
|
|
def test_responses_pre_call_denial_stream_requests_have_distinct_ids(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as observed:
|
|
observed.get("/__observations")
|
|
responses: Final = tuple(
|
|
candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": model, "input": "say hi", "stream": True, "guardrails": [identity]},
|
|
)
|
|
for _ in range(2)
|
|
)
|
|
completed: Final = tuple(_blocked_stream_events(response.text)[-1]["response"] for response in responses)
|
|
for response in responses:
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
assert completed[0]["id"] != completed[1]["id"], completed
|
|
assert completed[0]["output"][0]["id"] != completed[1]["output"][0]["id"], completed
|
|
assert observed.get("/__observations").json()["requests"] == []
|
|
|
|
|
|
def _register_named_model(candidate: Gateway, name: str, api_base: str | None = None, **parameters: object) -> str:
|
|
created: Final = candidate.post(
|
|
"/model/new",
|
|
{
|
|
"model_name": name,
|
|
"litellm_params": {
|
|
"model": "openai/gpt-4o-mini",
|
|
"api_key": "integration-provider-key",
|
|
"api_base": api_base or f"{candidate.upstream_url}/v1",
|
|
**parameters,
|
|
},
|
|
},
|
|
)
|
|
return str(created["model_info"]["id"])
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_post_call_pipeline_denial_streams_real_usage")
|
|
def test_responses_post_call_pipeline_denial_streams_real_usage(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
model: Final = f"integration-{uuid.uuid4().hex}"
|
|
config: Final = _responses_output_denial_config(tmp_path, identity, model)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate:
|
|
model_id: Final = _register_named_model(candidate, model, use_chat_completions_api=True)
|
|
try:
|
|
response: Final = candidate.request(
|
|
"POST", "/v1/responses", {"model": model, "input": f"say hi {uuid.uuid4().hex}", "stream": True}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
events: Final = _blocked_stream_events(response.text)
|
|
assert events[-1]["type"] == "response.completed", events
|
|
completed: Final = events[-1]["response"]
|
|
item: Final = completed["output"][0]
|
|
assert item["type"] == "message" and item["role"] == "assistant", item
|
|
assert item["content"][0]["type"] == "output_text", item
|
|
assert item["content"][0]["text"] == _RESPONSES_OUTPUT_DENIAL, item
|
|
usage: Final = completed["usage"]
|
|
assert (
|
|
usage["input_tokens"],
|
|
usage["output_tokens"],
|
|
usage["total_tokens"],
|
|
) == (_UPSTREAM_INPUT_TOKENS, _UPSTREAM_OUTPUT_TOKENS, _UPSTREAM_TOTAL_TOKENS), usage
|
|
finally:
|
|
candidate.post("/model/delete", {"id": model_id})
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_post_call_pipeline_denial_returns_real_usage")
|
|
def test_responses_post_call_pipeline_denial_returns_real_usage(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
model: Final = f"integration-{uuid.uuid4().hex}"
|
|
config: Final = _responses_output_denial_config(tmp_path, identity, model)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate:
|
|
model_id: Final = _register_named_model(candidate, model, use_chat_completions_api=True)
|
|
try:
|
|
response: Final = candidate.request(
|
|
"POST", "/v1/responses", {"model": model, "input": f"say hi {uuid.uuid4().hex}"}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
body: Final = response.json()
|
|
item: Final = body["output"][0]
|
|
assert item["type"] == "message" and item["role"] == "assistant", item
|
|
assert item["content"][0]["type"] == "output_text", item
|
|
assert item["content"][0]["text"] == _RESPONSES_OUTPUT_DENIAL, item
|
|
usage: Final = body["usage"]
|
|
assert (
|
|
usage["input_tokens"],
|
|
usage["output_tokens"],
|
|
usage["total_tokens"],
|
|
) == (_UPSTREAM_INPUT_TOKENS, _UPSTREAM_OUTPUT_TOKENS, _UPSTREAM_TOTAL_TOKENS), usage
|
|
finally:
|
|
candidate.post("/model/delete", {"id": model_id})
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_denial_requires_authentication")
|
|
def test_responses_denial_requires_authentication(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model()
|
|
response: Final = candidate.request(
|
|
"POST", "/v1/responses", {"model": model, "input": "say hi", "guardrails": [identity]}, key="sk-invalid"
|
|
)
|
|
assert response.status_code == 401, (response.status_code, response.text)
|
|
assert response.json()["error"]["type"] == "token_not_found_in_db", response.text
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_stream_does_not_reach_upstream")
|
|
def test_responses_pre_call_denial_stream_does_not_reach_upstream(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(api_base=_dead_api_base())
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": model, "input": "say hi", "stream": True, "guardrails": [identity]},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
events: Final = _blocked_stream_events(response.text)
|
|
completed: Final = events[-1]["response"]
|
|
_assert_blocked_message_item(completed["output"][0], completed)
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_unguarded_stream_reaches_upstream")
|
|
def test_responses_unguarded_stream_reaches_upstream(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
dead: Final = scenario.model(api_base=_dead_api_base())
|
|
denied: Final = candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": dead, "input": "say hi", "stream": True, "guardrails": [identity]},
|
|
)
|
|
assert denied.status_code == 200, denied.text
|
|
model: Final = scenario.model(use_chat_completions_api=True)
|
|
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as observed:
|
|
observed.get("/__observations")
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": model, "input": f"say hi {uuid.uuid4().hex}", "stream": True, "guardrails": []},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
assert "response.completed" in response.text, response.text
|
|
requests: Final = eventually(
|
|
lambda: observed.get("/__observations").json()["requests"],
|
|
lambda values: len(values) >= 1,
|
|
seconds=30,
|
|
)
|
|
assert len(requests) == 1, requests
|
|
assert requests[0]["path"] == "/v1/chat/completions", requests
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.chat_pre_call_denial_streams_content_filter")
|
|
def test_chat_pre_call_denial_streams_content_filter(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model()
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": "say hi"}],
|
|
"stream": True,
|
|
"guardrails": [identity],
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
lines: Final = tuple(line for line in response.text.split("\n") if line.startswith("data: "))
|
|
assert lines[-1] == "data: [DONE]", response.text
|
|
chunks: Final = tuple(json.loads(line.removeprefix("data: ")) for line in lines[:-1])
|
|
assert chunks[0]["choices"][0]["delta"]["content"] == _RESPONSES_DENIAL, chunks
|
|
assert chunks[-1]["choices"][0]["finish_reason"] == "stop", chunks
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.chat_pre_call_denial_returns_content_filter")
|
|
def test_chat_pre_call_denial_returns_content_filter(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model()
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": "say hi"}],
|
|
"guardrails": [identity],
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
body: Final = response.json()
|
|
choice: Final = body["choices"][0]
|
|
assert choice["finish_reason"] == "content_filter", body
|
|
assert choice["message"]["content"] == _RESPONSES_DENIAL, body
|
|
assert (
|
|
body["usage"]["prompt_tokens"],
|
|
body["usage"]["completion_tokens"],
|
|
body["usage"]["total_tokens"],
|
|
) == (0, 0, 0), body["usage"]
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.messages_pre_call_denial_returns_message")
|
|
def test_messages_pre_call_denial_returns_message(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model()
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/messages",
|
|
{
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": "say hi"}],
|
|
"max_tokens": 16,
|
|
"guardrails": [identity],
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
body: Final = response.json()
|
|
assert body["type"] == "message" and body["role"] == "assistant", body
|
|
assert body["content"] == [{"type": "text", "text": _RESPONSES_DENIAL}], body
|
|
assert body["stop_reason"] == "end_turn", body
|
|
assert (body["usage"]["input_tokens"], body["usage"]["output_tokens"]) == (0, 0), body
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.messages_pre_call_denial_streams_message")
|
|
def test_messages_pre_call_denial_streams_message(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model()
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/messages",
|
|
{
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": "say hi"}],
|
|
"max_tokens": 16,
|
|
"stream": True,
|
|
"guardrails": [identity],
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|
|
lines: Final = tuple(line for line in response.text.split("\n") if line.startswith("data: "))
|
|
assert len(lines) == 1, response.text
|
|
body: Final = json.loads(lines[0].removeprefix("data: "))
|
|
assert body["type"] == "message" and body["role"] == "assistant", body
|
|
assert body["content"] == [{"type": "text", "text": _RESPONSES_DENIAL}], body
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_writes_zero_spend_row")
|
|
def test_responses_pre_call_denial_writes_zero_spend_row(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model()
|
|
response: Final = candidate.request(
|
|
"POST", "/v1/responses", {"model": model, "input": "say hi", "guardrails": [identity]}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
rows: Final = eventually(
|
|
lambda: read_rows(
|
|
'SELECT spend, total_tokens FROM "LiteLLM_SpendLogs" WHERE model=%s AND call_type=%s',
|
|
(model, "aresponses"),
|
|
),
|
|
lambda values: len(values) == 1,
|
|
seconds=70,
|
|
)
|
|
assert float(rows[0]["spend"]) == 0, rows
|
|
assert rows[0]["total_tokens"] == 0, rows
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_stream_survives_worker_burst")
|
|
def test_responses_pre_call_denial_stream_survives_worker_burst(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy(gateway, tmp_path, {}, config=config, workers=2) as candidate, candidate.scenario() as scenario:
|
|
model: Final = scenario.model(api_base=_dead_api_base())
|
|
healthy: Final = scenario.model(use_chat_completions_api=True)
|
|
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as observed:
|
|
observed.get("/__observations")
|
|
|
|
def burst(index: int) -> httpx.Response:
|
|
if index % 3 == 0:
|
|
return candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": healthy, "input": f"say hi {uuid.uuid4().hex} {index}", "stream": True},
|
|
)
|
|
stream: Final = index % 3 == 1
|
|
return candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": model, "input": f"say hi {index}", "stream": stream, "guardrails": [identity]},
|
|
)
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
responses: Final = tuple(pool.map(burst, range(30)))
|
|
response_ids: Final = frozenset(_response_id(index, response) for index, response in enumerate(responses))
|
|
assert len(response_ids) == 30, response_ids
|
|
assert len(observed.get("/__observations").json()["requests"]) == 10
|
|
|
|
|
|
@pytest.mark.covers("other.observability.guardrails.responses_pre_call_denial_stream_survives_worker_kill")
|
|
def test_responses_pre_call_denial_stream_survives_worker_kill(gateway: Gateway, tmp_path: Path) -> None:
|
|
identity: Final = "guardrail" + uuid.uuid4().hex
|
|
config: Final = _responses_denial_config(tmp_path, identity)
|
|
with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned:
|
|
candidate: Final = owned.gateway
|
|
with candidate.scenario() as scenario:
|
|
model: Final = scenario.model(api_base=_dead_api_base())
|
|
members: Final = tuple(
|
|
member for member in group_members(owned.process.pid) if member.pid != owned.process.pid
|
|
)
|
|
children: Final = tuple(member.pid for member in members)
|
|
workers: Final = tuple(
|
|
member.pid for member in members if any("spawn_main" in part for part in member.cmdline())
|
|
)
|
|
assert len(workers) >= 2, workers
|
|
os.kill(workers[0], signal.SIGKILL)
|
|
expected: Final = len(children)
|
|
eventually(
|
|
lambda: tuple(
|
|
member.pid
|
|
for member in group_members(owned.process.pid)
|
|
if member.pid != owned.process.pid
|
|
and member.is_running()
|
|
and member.status() != psutil.STATUS_ZOMBIE
|
|
),
|
|
lambda pids: len(pids) >= expected and any(pid not in children for pid in pids),
|
|
seconds=30,
|
|
)
|
|
|
|
def burst(index: int) -> httpx.Response:
|
|
return candidate.request(
|
|
"POST",
|
|
"/v1/responses",
|
|
{"model": model, "input": f"say hi {index}", "stream": True, "guardrails": [identity]},
|
|
)
|
|
|
|
with ThreadPoolExecutor(max_workers=5) as pool:
|
|
responses: Final = tuple(pool.map(burst, range(10)))
|
|
for response in responses:
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream"), response.text
|