mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
refactor(grayswan): consume response-scan conversation and drop the cache
The translation layer now supplies structured_messages and tools on response scans, so the request-conversation cache stashed in request metadata (and its fragile tuple round-trip check) is no longer needed. Response scans without a structured view fall back to context-free assistant turns, which is correct for the SDK/direct-call path. The rebuilt conversation reflects the request after pre-call guardrail mutations (e.g. PII masking) instead of the pre-scan snapshot, so Cygnal now sees what the model actually received.
This commit is contained in:
parent
7966666e6e
commit
10da979ae7
2 changed files with 76 additions and 92 deletions
|
|
@ -27,7 +27,6 @@ if TYPE_CHECKING:
|
|||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
GRAYSWAN_BLOCK_ERROR_MSG: Final = "Blocked by Gray Swan Guardrail"
|
||||
GRAYSWAN_CONVERSATION_CACHE_KEY: Final = "_grayswan_request_conversation"
|
||||
|
||||
|
||||
class MonitorTurn(TypedDict):
|
||||
|
|
@ -159,8 +158,9 @@ class GraySwanGuardrail(CustomGuardrail):
|
|||
Args:
|
||||
inputs: Dictionary containing:
|
||||
- texts: List of extracted texts (fallback scan content)
|
||||
- structured_messages: Normalized, scoped conversation (request scans)
|
||||
- tools: Scoped tool definitions (request scans)
|
||||
- structured_messages: Normalized, scoped conversation (response
|
||||
scans carry the request conversation plus the response turns)
|
||||
- tools: Scoped tool definitions
|
||||
- tool_calls: Tool calls emitted by the model (response scans)
|
||||
- images: Optional list of images (not currently used by GraySwan)
|
||||
request_data: The original request data
|
||||
|
|
@ -176,7 +176,7 @@ class GraySwanGuardrail(CustomGuardrail):
|
|||
"""
|
||||
start_time: Final = time.time()
|
||||
try:
|
||||
messages, tools = self._build_monitor_input(inputs, request_data, input_type)
|
||||
messages, tools = self._build_monitor_input(inputs, input_type)
|
||||
if not messages:
|
||||
verbose_proxy_logger.debug("Gray Swan Guardrail: No content to scan")
|
||||
return inputs
|
||||
|
|
@ -505,53 +505,24 @@ class GraySwanGuardrail(CustomGuardrail):
|
|||
def _build_monitor_input(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict, # mutable-ok: the shared per-request state dict every hook receives; the request scan caches on it
|
||||
input_type: Literal["request", "response"],
|
||||
) -> tuple[tuple[Mapping[str, Any], ...], tuple[Mapping[str, Any], ...] | None]:
|
||||
"""Build the monitor conversation from the translation layer's scoped view.
|
||||
|
||||
Request scans send `structured_messages` and `tools` exactly as the unified
|
||||
guardrail system produced them (normalized per API surface, operator scoping
|
||||
flags applied) and cache them on the request. Response scans replay the
|
||||
cached conversation and append the response turns. Without a structured
|
||||
view, the pre-existing texts-only wrapping is kept.
|
||||
Both scan types send `structured_messages` and `tools` exactly as the
|
||||
unified guardrail system produced them (normalized per API surface,
|
||||
operator scoping flags applied; response scans carry the request
|
||||
conversation with the response turns appended). Without a structured
|
||||
view, request scans keep the pre-existing texts-only wrapping and
|
||||
response scans send context-free assistant turns (the SDK/direct-call
|
||||
path, where no request conversation exists).
|
||||
"""
|
||||
conversation: Final = self._sanitize_json_list(inputs.get("structured_messages"))
|
||||
if conversation:
|
||||
return conversation, self._sanitize_json_list(inputs.get("tools"))
|
||||
if input_type == "request":
|
||||
conversation: Final = self._sanitize_json_list(inputs.get("structured_messages"))
|
||||
if not conversation:
|
||||
return self._texts_fallback(inputs, "user"), None
|
||||
tools: Final = self._sanitize_json_list(inputs.get("tools"))
|
||||
self._cache_request_conversation(request_data, conversation, tools)
|
||||
return conversation, tools
|
||||
response_turns: Final = self._build_response_turns(inputs)
|
||||
if not response_turns:
|
||||
return (), None
|
||||
cached: Final = self._cached_request_conversation(request_data)
|
||||
if cached is None:
|
||||
return self._texts_fallback(inputs, "assistant"), None
|
||||
return (*cached[0], *response_turns), cached[1]
|
||||
|
||||
def _cache_request_conversation(
|
||||
self,
|
||||
request_data: dict, # mutable-ok: the shared per-request state dict every hook receives; caching on it is the point
|
||||
conversation: tuple[Mapping[str, Any], ...],
|
||||
tools: tuple[Mapping[str, Any], ...] | None,
|
||||
) -> None:
|
||||
metadata: Final = request_data.setdefault(
|
||||
"metadata",
|
||||
{}, # mutable-ok: request metadata is shared mutable state other hooks also write to
|
||||
) # rebind-ok: response scans replay the request-time scoped conversation and request_data is the only object shared across hooks
|
||||
if isinstance(metadata, dict):
|
||||
metadata[GRAYSWAN_CONVERSATION_CACHE_KEY] = (conversation, tools)
|
||||
|
||||
def _cached_request_conversation(
|
||||
self, request_data: Mapping[str, Any]
|
||||
) -> tuple[tuple[Mapping[str, Any], ...], tuple[Mapping[str, Any], ...] | None] | None:
|
||||
metadata: Final = request_data.get("metadata")
|
||||
cached: Final = metadata.get(GRAYSWAN_CONVERSATION_CACHE_KEY) if isinstance(metadata, dict) else None
|
||||
if isinstance(cached, tuple) and len(cached) == 2:
|
||||
return cached
|
||||
return None
|
||||
return self._texts_fallback(inputs, "user"), None
|
||||
return self._build_response_turns(inputs), None
|
||||
|
||||
def _build_response_turns(self, inputs: GenericGuardrailAPIInputs) -> tuple[Mapping[str, Any], ...]:
|
||||
tool_calls: Final = self._sanitize_json_list(inputs.get("tool_calls"))
|
||||
|
|
|
|||
|
|
@ -666,9 +666,11 @@ async def test_apply_guardrail_scans_texts_not_raw_messages(monkeypatch, grayswa
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_response_appends_assistant_turn_with_tool_calls(
|
||||
async def test_apply_guardrail_response_uses_structured_conversation_with_tools(
|
||||
monkeypatch, grayswan_guardrail: GraySwanGuardrail
|
||||
) -> None:
|
||||
"""The translation layer hands response scans the request conversation with
|
||||
the response turns already appended; the payload sends it verbatim."""
|
||||
captured: dict = {}
|
||||
|
||||
async def _fake_call(payload: dict):
|
||||
|
|
@ -677,34 +679,65 @@ async def test_apply_guardrail_response_appends_assistant_turn_with_tool_calls(
|
|||
|
||||
monkeypatch.setattr(grayswan_guardrail, "_call_grayswan_api", _fake_call)
|
||||
|
||||
structured_messages = [{"role": "user", "content": "write a script"}]
|
||||
response_tool_calls = [
|
||||
structured_messages = [
|
||||
{"role": "user", "content": "write a script"},
|
||||
{
|
||||
"id": "call_9",
|
||||
"type": "function",
|
||||
"function": {"name": "write_file", "arguments": '{"path": "x.sh", "content": "xmrig"}'},
|
||||
}
|
||||
"role": "assistant",
|
||||
"content": "sure, writing it now",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_9",
|
||||
"type": "function",
|
||||
"function": {"name": "write_file", "arguments": '{"path": "x.sh", "content": "xmrig"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
request_data: dict = {"model": "gpt-4"}
|
||||
tools = [{"type": "function", "function": {"name": "write_file", "parameters": {}}}]
|
||||
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": ["write a script"], "structured_messages": structured_messages},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": ["sure, writing it now"], "tool_calls": response_tool_calls},
|
||||
request_data=request_data,
|
||||
inputs={
|
||||
"texts": ["sure, writing it now"],
|
||||
"structured_messages": structured_messages,
|
||||
"tools": tools,
|
||||
},
|
||||
request_data={"model": "gpt-4"},
|
||||
input_type="response",
|
||||
)
|
||||
|
||||
wire = _wire(captured["payload"])
|
||||
assert wire["messages"][:-1] == structured_messages
|
||||
assert wire["messages"][-1] == {
|
||||
"role": "assistant",
|
||||
"content": "sure, writing it now",
|
||||
"tool_calls": response_tool_calls,
|
||||
}
|
||||
assert wire["messages"] == structured_messages
|
||||
assert wire["tools"] == tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_response_does_not_read_request_metadata(
|
||||
monkeypatch, grayswan_guardrail: GraySwanGuardrail
|
||||
) -> None:
|
||||
"""Request scans no longer stash the conversation on request_data, and
|
||||
response scans build the payload from inputs alone."""
|
||||
captured: dict = {}
|
||||
|
||||
async def _fake_call(payload: dict):
|
||||
captured["payload"] = payload
|
||||
return {"violation": 0.0}
|
||||
|
||||
monkeypatch.setattr(grayswan_guardrail, "_call_grayswan_api", _fake_call)
|
||||
|
||||
request_data: dict = {"model": "gpt-4"}
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": ["go"], "structured_messages": [{"role": "user", "content": "go"}]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
assert "_grayswan_request_conversation" not in (request_data.get("metadata") or {})
|
||||
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": ["done"]},
|
||||
request_data=request_data,
|
||||
input_type="response",
|
||||
)
|
||||
assert _wire(captured["payload"])["messages"] == [{"role": "assistant", "content": "done"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -720,23 +753,15 @@ async def test_apply_guardrail_scans_tool_call_only_response(
|
|||
monkeypatch.setattr(grayswan_guardrail, "_call_grayswan_api", _fake_call)
|
||||
|
||||
response_tool_calls = [{"id": "call_2", "type": "function", "function": {"name": "run", "arguments": "{}"}}]
|
||||
request_data: dict = {"model": "gpt-4"}
|
||||
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": ["go"], "structured_messages": [{"role": "user", "content": "go"}]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": [], "tool_calls": response_tool_calls},
|
||||
request_data=request_data,
|
||||
request_data={"model": "gpt-4"},
|
||||
input_type="response",
|
||||
)
|
||||
|
||||
wire = _wire(captured["payload"])
|
||||
assert wire["messages"][0] == {"role": "user", "content": "go"}
|
||||
assert wire["messages"][-1]["tool_calls"] == response_tool_calls
|
||||
assert wire["messages"][-1]["content"] == ""
|
||||
assert wire["messages"] == [{"role": "assistant", "content": "", "tool_calls": response_tool_calls}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -795,20 +820,14 @@ async def test_apply_guardrail_empty_response_is_not_scanned(
|
|||
|
||||
monkeypatch.setattr(grayswan_guardrail, "_call_grayswan_api", _fake_call)
|
||||
|
||||
request_data: dict = {"model": "gpt-4"}
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": ["go"], "structured_messages": [{"role": "user", "content": "go"}]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
inputs: dict = {"texts": []}
|
||||
result = await grayswan_guardrail.apply_guardrail(
|
||||
inputs=inputs,
|
||||
request_data=request_data,
|
||||
request_data={"model": "gpt-4"},
|
||||
input_type="response",
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert len(calls) == 0
|
||||
assert result is inputs
|
||||
|
||||
|
||||
|
|
@ -824,19 +843,13 @@ async def test_apply_guardrail_multiple_response_texts_get_separate_turns(
|
|||
|
||||
monkeypatch.setattr(grayswan_guardrail, "_call_grayswan_api", _fake_call)
|
||||
|
||||
request_data: dict = {"model": "gpt-4"}
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": ["pick one"], "structured_messages": [{"role": "user", "content": "pick one"}]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
await grayswan_guardrail.apply_guardrail(
|
||||
inputs={"texts": ["candidate one", "candidate two"]},
|
||||
request_data=request_data,
|
||||
request_data={"model": "gpt-4"},
|
||||
input_type="response",
|
||||
)
|
||||
|
||||
assert _wire(captured["payload"])["messages"][-2:] == [
|
||||
assert _wire(captured["payload"])["messages"] == [
|
||||
{"role": "assistant", "content": "candidate one"},
|
||||
{"role": "assistant", "content": "candidate two"},
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue