litellm/tests/integration/observability/test_generic_guardrail_api_wire.py
ryan-crabbe-berri 0dac61a555
fix(guardrails): accept Gemini tools in generic_guardrail_api, add opt-in forward_messages_as_sent, name the error type on fail-open (#46338)
* fix(guardrails): accept Gemini tools and Anthropic content blocks in generic_guardrail_api, name the error type on fail-open

GuardrailToolParam required a type key, so Gemini native tools
(function_declarations, googleSearch) failed validation inside the hook
and either returned a 500 or skipped the scan when fail_on_error was
false. The type key is now optional and left out of the forwarded tool
when absent, so an echoing guardrail server cannot write type: null into
the provider request.

structured_messages was re-validated against the chat completion message
types, which silently emptied tool_result, tool_use, image and document
content blocks to content: [] and dropped undeclared keys before the
guardrail saw them. The request model now forwards rows as opaque
mappings, which also retires the write-back shim that restored the
original rows for echoed-back entries.

The fail-open critical log line now carries error_type so a timeout, a
refused connection and an upstream HTTP status can be told apart without
the traceback.

* fix(httpx): retry aiohttp server disconnects the way httpx does

aiohttp raises ServerDisconnectedError when a kept-alive connection is
closed by the peer before a response arrives, which is what a load
balancer idle timeout shorter than AIOHTTP_KEEPALIVE_TIMEOUT produces.
The transport mapped it to httpx.ReadError, which the handler's
connection-error retry does not cover, so the request failed instead of
being resent on a fresh connection. It now maps to
httpx.RemoteProtocolError, the error httpx itself raises for the same
condition, so the existing retry fires.

* test(httpx): build the stub server reply in one bytes literal

* fix(httpx): resend a dropped connection's request as sent and keep a streamed resend readable

The connection-error retry closed its fresh client before a streamed body was read,
so an aiohttp disconnect now billed a second upstream request and still failed. It
also skipped masking on a status error from the resend, resent PATCH and DELETE as
POST, dropped multipart files and ignored the handler's ssl_verify.

The retry now resends the request the first attempt built, on a client with the
handler's TLS settings, closes that client when a streamed body is closed, and masks
a status error the same way the first attempt does.

* fix(guardrails): log HTTPStatusError, not the masking wrapper, as the fail-open error_type

* fix(guardrails): resend a generic guardrail check cut before any response

A load balancer that closes an idle keepalive connection made the guardrail POST
fail with "Server disconnected", because #46336 only resends POST on ConnectError.
AsyncHTTPHandler.post now takes a keyword-only idempotent flag, defaulting to
False, that gives a RemoteProtocolError the same single fresh-client resend PUT
and DELETE get. Only GenericGuardrailAPI.apply_guardrail opts in, since a
guardrail check is a pure verdict over the request. Every other POST keeps the
#46336 rule

The fail-open log line now puts error_type at the end so the pre-PR text stays
contiguous for existing alert matchers

test_rows_reach_the_guardrail_verbatim now reads the body off a real local
guardrail server instead of mock call args

* fix(http): keep a mid-body server disconnect a ReadError and move socket tests to integration

A ServerDisconnectedError raised while reading a response body now stays
httpx.ReadError, as it was before this PR. Only a disconnect before any
response maps to RemoteProtocolError, which is the case the resend rule is
about. Stream readers that end on ReadError, like the SAP stream iterator,
keep their old behavior

The loopback-server disconnect, TLS and guardrail resend tests move to
tests/integration/sdk/test_disconnect_resend_wire.py, since tests/unit is
in-process only. Unit keeps in-process MockTransport checks for the idempotent
POST resend, masking on the resend, streamed and buffered client closing, the
guardrail resend, and the guardrail receiving rows verbatim

* fix(guardrails): resend a reset guardrail check and keep typed echoes on texts

An idempotent POST now resends once on any network error after connect
(ReadError, WriteError, RemoteProtocolError), so a guardrail behind a load
balancer that answers a stale keepalive with a TCP reset rides it out too.
That makes the aiohttp ServerDisconnectedError remap unnecessary, so it goes
back to ReadError. This stops PUT and DELETE from stacking litellm's resend on
top of aiohttp's own idempotent retry (4 sends instead of 2) and stops Vigil
Guard from replaying its analyze POST on a disconnect

Every resend now runs inside the method's own error mapping, so a timeout on
the resend is a litellm.Timeout like the first attempt and a status error is
masked once by the caller

A guardrail server that parses rows with its own message types and echoes them
back without the keys those types lack (annotations, tool_calls[].index) is
read as an echo again, so its texts redaction reaches the LLM. A row only counts
as an echo when no value changed and every key the chat completion types
declare is kept

The wire tests for the PUT/DELETE resend, its masking and its TLS settings now
run on the httpx transport, where litellm's resend is the only retry, and the
POST and guardrail tests cover both transports and both cut styles

* test(guardrails): black box wire tests for generic guardrail shapes, fail-open error type and idle cut resend

Integration tests against an owned proxy and scripted peers. Gemini native tools reach the guardrail and Gemini as sent, the guardrail sees chat rows and an Anthropic image block as sent, an echoing guardrail leaves a /v1/messages request unchanged, the fail-open log line names ConnectError, Timeout or HTTPStatusError, and a guardrail check cut on an idle pooled connection is resent once on each worker. Each fails at the merge base and passes at the head

* fix(guardrails): check echoed rows without recursion and keep pytest.raises to one statement

* refactor(guardrails): move the dropped-connection guardrail resend out of this PR

The resend on a dropped or hung generic guardrail call, and the shared AsyncHTTPHandler retry rework behind it, move to a follow-up PR that adds a per-guardrail num_retries setting. This PR keeps the request and response shape fixes and the error_type on the fail-open log line

* test(guardrails): pick the closed guardrail port from the integration port allocator

Reading a port back from a port-0 bind trips TQ012, so the fail-open connect error case now takes its unused port from allocate_port

* feat(guardrails): forward structured_messages as sent only behind forward_messages_as_sent

generic_guardrail_api keeps main's OpenAI-format rows and echo comparison by default. Setting forward_messages_as_sent: true on the guardrail sends rows as the client sent them and treats an echo that only drops undeclared keys as unchanged

---------

Co-authored-by: yuneng-jiang <yuneng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 14:52:35 -07:00

308 lines
13 KiB
Python

import json
import threading
from collections.abc import Iterator, Mapping
from contextlib import ExitStack
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Final
import pytest
import yaml
from integration._support.anthropic_sse import message_json
from integration._support.client import Gateway, eventually
from integration._support.openai_wire import chat_reply
from integration._support.ports import allocate_port
from integration._support.process import OwnedProxy, owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
GEMINI_MODEL: Final = "gemini/gemini-3.8-flash"
ANTHROPIC_MODEL: Final = "anthropic/claude-sonnet-5-5"
OPENAI_MODEL: Final = "openai/gpt-5.6"
ECHO_GEMINI_TOOLS: Final = "echo-gemini-tools"
NONE_CHAT_ROWS: Final = "none-chat-rows"
NONE_CHAT_ROWS_DEFAULT: Final = "none-chat-rows-default"
NONE_ANTHROPIC_IMAGE: Final = "none-anthropic-image"
ECHO_ANTHROPIC_MESSAGES: Final = "echo-anthropic-messages"
FORWARDS_MESSAGES_AS_SENT: Final = frozenset({NONE_CHAT_ROWS, NONE_ANTHROPIC_IMAGE, ECHO_ANTHROPIC_MESSAGES})
FAIL_OPEN_CONNECT_ERROR: Final = "fail-open-connect-error"
FAIL_OPEN_TIMEOUT: Final = "fail-open-timeout"
FAIL_OPEN_HTTP_STATUS: Final = "fail-open-http-status"
FAIL_OPEN_LINE: Final = "Generic Guardrail API error (fail-open)"
PNG: Final = (
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR4nGO4IydHEmIY1TCqYfhqAACaMxgQdGu1YAAAAABJRU5ErkJggg=="
)
_CONFIG: Final = Path(__file__).resolve().parents[1] / "proxy_config.yaml"
_JSON: Final = TypeAdapter(dict[str, JsonValue])
def _verdict_none(_request: Request) -> Reply:
return Reply(body=json.dumps({"action": "NONE"}).encode())
def _verdict_echo(request: Request) -> Reply:
body: Final = _JSON.validate_json(request.body)
echoed: Final = {key: body[key] for key in ("structured_messages", "tools") if body.get(key)}
return Reply(body=json.dumps({"action": "NONE", **echoed}).encode())
def _unavailable(_request: Request) -> Reply:
return Reply(status=503, body=b'{"error": "guardrail unavailable"}')
def _gemini_reply(_request: Request) -> Reply:
return Reply(
body=json.dumps(
{
"candidates": [{"content": {"role": "model", "parts": [{"text": "ok"}]}, "finishReason": "STOP"}],
"usageMetadata": {"promptTokenCount": 3, "candidatesTokenCount": 1, "totalTokenCount": 4},
}
).encode()
)
def _anthropic_reply(_request: Request) -> Reply:
return Reply(body=message_json("msg_guardrail_wire", "claude-sonnet-5-5", "ok"))
def _openai_reply(_request: Request) -> Reply:
return chat_reply("chatcmpl-guardrail-wire", "gpt-5.6", "ok", stream=False)
def _guardrail(name: str, api_base: str, **params: JsonValue) -> dict[str, JsonValue]:
return {
"guardrail_name": name,
"litellm_params": {
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"default_on": False,
"api_base": api_base,
"api_key": "synthetic-guardrail-key",
**params,
},
}
@dataclass(frozen=True, slots=True)
class GuardrailRig:
owned: OwnedProxy
policies: Mapping[str, Wire]
@pytest.fixture(scope="module")
def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[GuardrailRig]:
directory: Final = tmp_path_factory.mktemp("generic-guardrail-wire")
release: Final = threading.Event()
def held_until_teardown(request: Request) -> Reply:
release.wait()
return _verdict_none(request)
with ExitStack() as stack:
policies: Final = MappingProxyType(
{
ECHO_GEMINI_TOOLS: stack.enter_context(wire_server(_verdict_echo)),
NONE_CHAT_ROWS: stack.enter_context(wire_server(_verdict_none)),
NONE_CHAT_ROWS_DEFAULT: stack.enter_context(wire_server(_verdict_none)),
NONE_ANTHROPIC_IMAGE: stack.enter_context(wire_server(_verdict_none)),
ECHO_ANTHROPIC_MESSAGES: stack.enter_context(wire_server(_verdict_echo)),
}
)
stalled: Final = stack.enter_context(wire_server(held_until_teardown))
stack.callback(release.set)
unavailable: Final = stack.enter_context(wire_server(_unavailable))
config: Final = _JSON.validate_python(yaml.safe_load(_CONFIG.read_text()))
path: Final = directory / "generic-guardrail-wire.yaml"
path.write_text(
yaml.safe_dump(
{
**config,
"guardrails": [
*(
_guardrail(name, wire.url, forward_messages_as_sent=name in FORWARDS_MESSAGES_AS_SENT)
for name, wire in policies.items()
),
_guardrail(FAIL_OPEN_CONNECT_ERROR, f"http://127.0.0.1:{allocate_port()}", fail_on_error=False),
_guardrail(FAIL_OPEN_TIMEOUT, stalled.url, fail_on_error=False, timeout=1),
_guardrail(FAIL_OPEN_HTTP_STATUS, unavailable.url, fail_on_error=False),
],
}
)
)
owned: Final = stack.enter_context(owned_proxy_process(session_gateway, directory, {}, config=path))
yield GuardrailRig(owned, policies)
def _bodies(wire: Wire) -> list[dict[str, JsonValue]]:
return [_JSON.validate_json(request.body) for request in wire.drain()]
def _fail_open_lines(log: Path, guardrail: str) -> list[str]:
return [
line
for line in log.read_text(errors="replace").splitlines()
if FAIL_OPEN_LINE in line and f"guardrail_name={guardrail} " in line
]
def test_gemini_native_tools_reach_the_guardrail_and_gemini_as_sent(rig: GuardrailRig) -> None:
tools: Final = [
{"function_declarations": [{"name": "get_weather", "parameters": {"type": "object", "properties": {}}}]},
{"googleSearch": {}},
]
with wire_server(_gemini_reply) as gemini, rig.owned.gateway.scenario() as scenario:
model: Final = scenario.model(model=GEMINI_MODEL, api_base=gemini.url, api_key="synthetic-gemini-key")
response: Final = rig.owned.gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"messages": [{"role": "user", "content": "weather in Paris?"}],
"tools": tools,
"guardrails": [ECHO_GEMINI_TOOLS],
},
)
assert response.status_code == 200, response.text
assert [body["tools"] for body in _bodies(rig.policies[ECHO_GEMINI_TOOLS])] == [tools]
sent: Final = _bodies(gemini)
assert len(sent) == 1, sent
assert {"googleSearch": {}} in sent[0]["tools"], sent
assert [tool for tool in sent[0]["tools"] if "type" in tool] == [], sent
CHAT_ROWS: Final = (
{"role": "user", "name": "pat", "content": "weather in Paris?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{"index": 0, "id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
)
OPENAI_CHAT_ROWS: Final = (
{"role": "user", "content": "weather in Paris?"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
)
@pytest.mark.parametrize(
("guardrail", "seen"),
[(NONE_CHAT_ROWS, CHAT_ROWS), (NONE_CHAT_ROWS_DEFAULT, OPENAI_CHAT_ROWS)],
ids=["forward_messages_as_sent", "default"],
)
def test_guardrail_sees_chat_rows_as_sent_only_when_configured(
rig: GuardrailRig, guardrail: str, seen: tuple[dict[str, JsonValue], ...]
) -> None:
messages: Final = list(CHAT_ROWS)
with wire_server(_openai_reply) as openai_peer, rig.owned.gateway.scenario() as scenario:
model: Final = scenario.model(
model=OPENAI_MODEL, api_base=f"{openai_peer.url}/v1", api_key="synthetic-openai-key"
)
response: Final = rig.owned.gateway.request(
"POST", "/v1/chat/completions", {"model": model, "messages": messages, "guardrails": [guardrail]}
)
assert response.status_code == 200, response.text
assert [body["structured_messages"] for body in _bodies(rig.policies[guardrail])] == [list(seen)]
assert len(openai_peer.drain()) == 1
def test_guardrail_sees_an_anthropic_image_block_on_chat_completions_as_sent(rig: GuardrailRig) -> None:
image: Final = [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG}}]
with wire_server(_anthropic_reply) as anthropic_peer, rig.owned.gateway.scenario() as scenario:
model: Final = scenario.model(
model=ANTHROPIC_MODEL, api_base=anthropic_peer.url, api_key="synthetic-anthropic-key"
)
rig.owned.gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"max_tokens": 16,
"messages": [{"role": "user", "content": image}, {"role": "user", "content": "what color?"}],
"guardrails": [NONE_ANTHROPIC_IMAGE],
},
)
seen: Final = _bodies(rig.policies[NONE_ANTHROPIC_IMAGE])
assert [body["structured_messages"][0]["content"] for body in seen] == [image], seen
def test_echoing_guardrail_leaves_the_anthropic_messages_request_unchanged(rig: GuardrailRig) -> None:
messages: Final = [
{
"role": "user",
"content": [
{"type": "text", "text": "What color is this square? Also check the weather in Paris."},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG}},
{"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": "Trip notes."}},
],
},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Paris"}}],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "sunny, 21C"},
{"type": "text", "text": "Answer both in one sentence."},
],
},
]
tools: Final = [
{
"name": "get_weather",
"description": "Weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}},
}
]
with wire_server(_anthropic_reply) as anthropic_peer, rig.owned.gateway.scenario() as scenario:
model: Final = scenario.model(
model=ANTHROPIC_MODEL, api_base=anthropic_peer.url, api_key="synthetic-anthropic-key"
)
response: Final = rig.owned.gateway.request(
"POST",
"/v1/messages",
{
"model": model,
"max_tokens": 64,
"messages": messages,
"tools": tools,
"guardrails": [ECHO_ANTHROPIC_MESSAGES],
},
)
assert response.status_code == 200, response.text
assert len(rig.policies[ECHO_ANTHROPIC_MESSAGES].drain()) == 1
assert [body["messages"] for body in _bodies(anthropic_peer)] == [messages]
@pytest.mark.parametrize(
("guardrail", "tokens"),
[
(FAIL_OPEN_CONNECT_ERROR, ("error_type=ConnectError",)),
(FAIL_OPEN_TIMEOUT, ("error_type=Timeout",)),
(FAIL_OPEN_HTTP_STATUS, ("error_type=HTTPStatusError", "http_status_code=503")),
],
ids=["connect_error", "timeout", "http_status"],
)
def test_fail_open_log_line_names_the_error_type(rig: GuardrailRig, guardrail: str, tokens: tuple[str, ...]) -> None:
with wire_server(_openai_reply) as openai_peer, rig.owned.gateway.scenario() as scenario:
model: Final = scenario.model(
model=OPENAI_MODEL, api_base=f"{openai_peer.url}/v1", api_key="synthetic-openai-key"
)
response: Final = rig.owned.gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "hi"}], "guardrails": [guardrail]},
)
assert response.status_code == 200, response.text
assert len(openai_peer.drain()) == 1
lines: Final = eventually(lambda: _fail_open_lines(rig.owned.log, guardrail), bool, seconds=30)
assert [line for line in lines if not all(token in line for token in tokens)] == [], lines