mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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:
parent
e8b8bf73c4
commit
d62dd7ac9a
2 changed files with 61 additions and 17 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue