Grayswan guardrail passthrough on flagged (#16891)

* attempt to implement the passthrough feature

* Formatting and small change

* Fix formatting

* Format test file

---------

Co-authored-by: Xiaohan Fu <xiaohan@grayswan.ai>
This commit is contained in:
Derek Duenas
2025-11-21 20:01:35 -08:00
committed by GitHub
co-authored by Xiaohan Fu
parent f56c7e1ef9
commit bbaf0af907
4 changed files with 135 additions and 18 deletions
@@ -1,3 +1,5 @@
from typing import Optional
import pytest
from fastapi import HTTPException
@@ -22,7 +24,9 @@ def grayswan_guardrail() -> GraySwanGuardrail:
)
def test_prepare_payload_uses_dynamic_overrides(grayswan_guardrail: GraySwanGuardrail) -> None:
def test_prepare_payload_uses_dynamic_overrides(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
messages = [{"role": "user", "content": "hello"}]
dynamic_body = {
"categories": {"custom": "override"},
@@ -38,7 +42,9 @@ def test_prepare_payload_uses_dynamic_overrides(grayswan_guardrail: GraySwanGuar
assert payload["reasoning_mode"] == "thinking"
def test_prepare_payload_falls_back_to_guardrail_defaults(grayswan_guardrail: GraySwanGuardrail) -> None:
def test_prepare_payload_falls_back_to_guardrail_defaults(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
messages = [{"role": "user", "content": "hello"}]
payload = grayswan_guardrail._prepare_payload(messages, {})
@@ -48,8 +54,12 @@ def test_prepare_payload_falls_back_to_guardrail_defaults(grayswan_guardrail: Gr
assert payload["reasoning_mode"] == "hybrid"
def test_process_response_does_not_block_under_threshold(grayswan_guardrail: GraySwanGuardrail) -> None:
grayswan_guardrail._process_grayswan_response({"violation": 0.3, "violated_rules": []})
def test_process_response_does_not_block_under_threshold(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
grayswan_guardrail._process_grayswan_response(
{"violation": 0.3, "violated_rules": []}
)
def test_process_response_blocks_when_threshold_exceeded() -> None:
@@ -85,18 +95,22 @@ class _DummyClient:
self.calls: list[dict] = []
async def post(self, *, url: str, headers: dict, json: dict, timeout: float):
self.calls.append({"url": url, "headers": headers, "json": json, "timeout": timeout})
self.calls.append(
{"url": url, "headers": headers, "json": json, "timeout": timeout}
)
return _DummyResponse(self.payload)
@pytest.mark.asyncio
async def test_run_guardrail_posts_payload(monkeypatch, grayswan_guardrail: GraySwanGuardrail) -> None:
async def test_run_guardrail_posts_payload(
monkeypatch, grayswan_guardrail: GraySwanGuardrail
) -> None:
dummy_client = _DummyClient({"violation": 0.1})
grayswan_guardrail.async_handler = dummy_client
captured = {}
def fake_process(response_json: dict) -> None:
def fake_process(response_json: dict, data: Optional[dict] = None) -> None:
captured["response"] = response_json
monkeypatch.setattr(grayswan_guardrail, "_process_grayswan_response", fake_process)
@@ -110,7 +124,9 @@ async def test_run_guardrail_posts_payload(monkeypatch, grayswan_guardrail: Gray
@pytest.mark.asyncio
async def test_run_guardrail_raises_api_error(grayswan_guardrail: GraySwanGuardrail) -> None:
async def test_run_guardrail_raises_api_error(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
class _FailingClient:
async def post(self, **_kwargs):
raise RuntimeError("boom")
@@ -121,3 +137,61 @@ async def test_run_guardrail_raises_api_error(grayswan_guardrail: GraySwanGuardr
with pytest.raises(GraySwanGuardrailAPIError):
await grayswan_guardrail.run_grayswan_guardrail(payload)
def test_process_response_passthrough_stores_detection_info() -> None:
"""Test that passthrough mode stores detection info in metadata without blocking."""
guardrail = GraySwanGuardrail(
guardrail_name="grayswan-passthrough",
api_key="test-key",
on_flagged_action="passthrough",
violation_threshold=0.2,
event_hook=GuardrailEventHooks.pre_call,
)
data = {"messages": [{"role": "user", "content": "test"}]}
response_json = {
"violation": 0.8,
"violated_rules": [1, 2],
"mutation": True,
"ipi": False,
}
# Should not raise an exception
guardrail._process_grayswan_response(response_json, data)
# Verify detection info was stored in metadata
assert "metadata" in data
assert "guardrail_detections" in data["metadata"]
assert len(data["metadata"]["guardrail_detections"]) == 1
detection = data["metadata"]["guardrail_detections"][0]
assert detection["guardrail"] == "grayswan"
assert detection["flagged"] is True
assert detection["violation_score"] == 0.8
assert detection["violated_rules"] == [1, 2]
assert detection["mutation"] is True
assert detection["ipi"] is False
def test_process_response_passthrough_does_not_store_if_under_threshold() -> None:
"""Test that passthrough mode doesn't store anything if violation is under threshold."""
guardrail = GraySwanGuardrail(
guardrail_name="grayswan-passthrough",
api_key="test-key",
on_flagged_action="passthrough",
violation_threshold=0.5,
event_hook=GuardrailEventHooks.pre_call,
)
data = {"messages": [{"role": "user", "content": "test"}]}
response_json = {
"violation": 0.3,
"violated_rules": [],
}
# Should not raise an exception
guardrail._process_grayswan_response(response_json, data)
# Should not have any detection info since it didn't exceed threshold
assert "guardrail_detections" not in data.get("metadata", {})