fix(responses): name the field a malformed frame guardrail selection got wrong

A frame whose guardrails or guardrail_config has the wrong shape now gets
an error event naming the field, instead of pydantic's raw validation dump
This commit is contained in:
tavren27 2026-10-28 00:27:38 -07:00
parent e8b8bf73c4
commit d62dd7ac9a
2 changed files with 61 additions and 17 deletions

View file

@ -2022,7 +2022,6 @@ _CLIENT_METADATA_KEYS: Final = ("metadata", "litellm_metadata")
_CLIENT_GUARDRAIL_SELECTORS: Final = ("guardrails", "guardrail_config", "policies")
_PROXY_ONLY_FRAME_FIELDS: Final = (*_CLIENT_GUARDRAIL_SELECTORS, "include_guardrail_response")
_NESTED_BODY_ONLY_FIELDS: Final = (*_GUARDED_FRAME_FIELDS, *_PROXY_ONLY_FRAME_FIELDS)
_GUARDRAIL_NAMES: Final = TypeAdapter(list[str])
_OBJECTS: Final = TypeAdapter(list[object])
_TURN_COPIED_CONTAINERS: Final = (list, dict)
@ -2116,6 +2115,14 @@ def _responses_ws_frame_guard(
return guard
class _FrameGuardrailSelection(TypedDict, total=False):
guardrails: ReadOnly[Sequence[str]]
guardrail_config: ReadOnly[Mapping[str, object]]
_FRAME_GUARDRAIL_SELECTION: Final = TypeAdapter(_FrameGuardrailSelection)
async def _metadata_with_frame_guardrails(
bucket: Mapping[str, object],
body: Mapping[str, object],
@ -2125,6 +2132,7 @@ async def _metadata_with_frame_guardrails(
) -> Mapping[str, object]:
"""A frame selects guardrails the way an HTTP ``/v1/responses`` body does, adding to the key, team and policy
guardrails the socket was admitted with, so a frame can never shrink that list"""
selection: Final = _frame_guardrail_selection(body)
metadata: Final = (
await _metadata_with_frame_policies(bucket, body["policies"], model, metadata_key, user_api_key_dict)
if "policies" in body
@ -2138,25 +2146,34 @@ async def _metadata_with_frame_guardrails(
{
"guardrails": [
*(_OBJECTS.validate_python(existing_guardrails) if isinstance(existing_guardrails, list) else ()),
*_GUARDRAIL_NAMES.validate_python(body["guardrails"]),
*selection["guardrails"],
]
}
if "guardrails" in body
if "guardrails" in selection
else {}
),
**(
{
"guardrail_config": {
**(_WS_FRAME.validate_python(existing_config) if isinstance(existing_config, dict) else {}),
**_WS_FRAME.validate_python(body["guardrail_config"]),
**selection["guardrail_config"],
}
}
if "guardrail_config" in body
if "guardrail_config" in selection
else {}
),
}
def _frame_guardrail_selection(body: Mapping[str, object]) -> _FrameGuardrailSelection:
try:
return _FRAME_GUARDRAIL_SELECTION.validate_python(
{key: body[key] for key in ("guardrails", "guardrail_config") if key in body}
)
except ValidationError as e:
raise ValueError(f"response.create {first_validation_error(e)}") from e
async def _metadata_with_frame_policies(
bucket: Mapping[str, object],
policies: object,

View file

@ -6,6 +6,7 @@ import asyncio
import contextlib
import dataclasses
import json
import re
import unittest
from collections.abc import Mapping
from typing import Any, Final, Literal, cast
@ -1801,11 +1802,19 @@ class TestResponsesWSStreamingFirstMessage:
@pytest.mark.parametrize(
("failure", "expected_status", "expected_type"),
[
(ValueError("No deployments available - crossed budget: Exceeded budget for deployment"), 429, "rate_limit_exceeded"),
(
ValueError("No deployments available - crossed budget: Exceeded budget for deployment"),
429,
"rate_limit_exceeded",
),
(litellm.BadRequestError(message="bad input", model="m", llm_provider="openai"), 400, "invalid_request_error"),
(RuntimeError("boom"), 500, "server_error"),
(ValueError(f"{RouterErrors.no_deployments_with_tag_routing.value}=['free']"), 401, "invalid_request_error"),
(ProxyException(message="Authentication Error", type="auth_error", param=None, code=401), 401, "invalid_request_error"),
(
ProxyException(message="Authentication Error", type="auth_error", param=None, code=401),
401,
"invalid_request_error",
),
(HTTPException(status_code=403, detail="key not allowed"), 403, "invalid_request_error"),
],
ids=["crossed-budget", "bad-request", "unexpected", "tag-routing", "proxy-auth", "http-exception"],
@ -2278,11 +2287,17 @@ class TestResponsesWSFirstFrameModelAuth:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"selection", [{"guardrails": "frame-pick"}, {"guardrails": [{"name": "frame-pick"}]}, {"guardrail_config": []}]
("selection", "message"),
[
(
{"guardrails": "frame-pick"},
"response.create guardrails: 'str' instances are not allowed as a Sequence value",
),
({"guardrails": [{"name": "frame-pick"}]}, "response.create guardrails.0: Input should be a valid string"),
({"guardrail_config": []}, "response.create guardrail_config: Input should be a valid dictionary"),
],
)
async def test_frame_guard_refuses_a_frame_whose_guardrail_selection_is_malformed(self, selection):
from pydantic import ValidationError
async def test_frame_guard_refuses_a_frame_whose_guardrail_selection_is_malformed(self, selection, message):
from litellm.proxy.response_api_endpoints.endpoints import _responses_ws_frame_guard
proxy_logging_obj = MagicMock()
@ -2294,7 +2309,7 @@ class TestResponsesWSFirstFrameModelAuth:
MagicMock(),
)
with pytest.raises(ValidationError):
with pytest.raises(ValueError, match=f"^{re.escape(message)}$"):
await guard(json.dumps({"type": "response.create", "input": "hi", **selection}))
proxy_logging_obj.pre_call_hook.assert_not_awaited()
@ -2685,6 +2700,7 @@ class TestResponsesWSFirstFrameModelAuth:
ws.receive_text = AsyncMock(
return_value=json.dumps({"type": "response.create", "model": "gpt-4o-mini", "input": []})
)
async def close_over_the_network(**kwargs):
await asyncio.sleep(0)
@ -2704,7 +2720,9 @@ class TestResponsesWSFirstFrameModelAuth:
def route_request(data, **kwargs):
async def relay():
guarded = await data["responses_ws_frame_guard"](json.dumps({"type": "response.create", "input": "two"}))
guarded = await data["responses_ws_frame_guard"](
json.dumps({"type": "response.create", "input": "two"})
)
guarded.bill_refusal(refusal)
if relay_outcome == "still_relaying":
await asyncio.Event().wait()
@ -2722,6 +2740,7 @@ class TestResponsesWSFirstFrameModelAuth:
side_effect=lambda user_api_key_dict, data, call_type, guardrails_only: data
)
proxy_logging_obj.post_call_failure_hook = AsyncMock(side_effect=slow_failure_hook)
async def release_over_the_network(user_api_key_dict):
await asyncio.sleep(0.01)
@ -2764,7 +2783,9 @@ class TestResponsesWSFirstFrameModelAuth:
row_may_finish.set()
await asyncio.wait_for(row_written.wait(), timeout=1)
assert proxy_logging_obj.post_call_failure_hook.await_args.kwargs["original_exception"] is (crash if crashed else refusal)
assert proxy_logging_obj.post_call_failure_hook.await_args.kwargs["original_exception"] is (
crash if crashed else refusal
)
@pytest.mark.asyncio
@pytest.mark.parametrize("ending", ["client_closed", "crash"])
@ -2799,7 +2820,9 @@ class TestResponsesWSFirstFrameModelAuth:
def route_request(data, **kwargs):
async def relay():
guarded = await data["responses_ws_frame_guard"](json.dumps({"type": "response.create", "input": "two"}))
guarded = await data["responses_ws_frame_guard"](
json.dumps({"type": "response.create", "input": "two"})
)
guarded.bill_refusal(refusal)
if ending == "crash":
raise crash
@ -2874,7 +2897,9 @@ class TestResponsesWSFirstFrameModelAuth:
def route_request(data, **kwargs):
async def relay():
guarded = await data["responses_ws_frame_guard"](json.dumps({"type": "response.create", "input": "two"}))
guarded = await data["responses_ws_frame_guard"](
json.dumps({"type": "response.create", "input": "two"})
)
guarded.bill_refusal(refusal)
data["litellm_metadata"]["standard_logging_guardrail_information"] = "written by a broken callback"
raise RuntimeError("relay crashed")
@ -4576,7 +4601,9 @@ class TestResponsesInputTokens:
assert response.status_code == 200, response.text
sent = counter.await_args.kwargs["responses_request"]
assert {name: sent[name] for name in ("tools", "tool_choice", "parallel_tool_calls") if name in sent} == expected
assert {
name: sent[name] for name in ("tools", "tool_choice", "parallel_tool_calls") if name in sent
} == expected
def test_previous_response_id_reaches_the_counter_authorized_next_to_the_id_the_client_held(self) -> None:
from litellm.types.utils import ADDRESSED_RESPONSE_ID_FIELD