mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
* 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>
308 lines
13 KiB
Python
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
|