Skip to content

Commit 0237198

Browse files
committed
Wire run_shield_moderation_v2 into Responses API endpoint
Add run_shield_moderation_v2 and build_shield to utils/shields.py to run shield moderation through AbstractSafetyCapability instances instead of the Llama Stack client. Update the responses endpoint to call the new function with shield configs directly. Include unit tests covering pass, block, filtering, and error handling paths.
1 parent b7a4e5f commit 0237198

3 files changed

Lines changed: 249 additions & 15 deletions

File tree

src/app/endpoints/responses.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@
105105
select_model_for_responses,
106106
)
107107
from utils.rh_identity import get_rh_identity_context
108-
from utils.shields import run_shield_moderation
108+
from utils.shields import run_shield_moderation_v2
109109
from utils.suid import (
110110
normalize_conversation_id,
111111
)
@@ -424,11 +424,11 @@ async def responses_endpoint_handler(
424424
attachments_text = extract_attachments_text(original_request.input)
425425

426426
endpoint_path = ENDPOINT_PATH_RESPONSES
427-
moderation_result = await run_shield_moderation(
428-
client,
427+
428+
moderation_result = await run_shield_moderation_v2(
429429
input_text + "\n\n" + attachments_text,
430-
endpoint_path,
431-
original_request.shield_ids,
430+
configuration.configuration.shields,
431+
responses_request.shield_ids,
432432
)
433433

434434
filter_server_tools = (

src/utils/shields.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,31 @@
1010
from ogx_client import (
1111
APIStatusError as LLSApiStatusError,
1212
)
13+
from pydantic_ai import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior
1314

1415
from configuration import AppConfig
16+
from log import get_logger
1517
from models.api.requests import QueryRequest
1618
from models.api.responses.error import (
1719
InternalServerErrorResponse,
1820
NotFoundResponse,
1921
ServiceUnavailableResponse,
2022
UnprocessableEntityResponse,
2123
)
22-
from models.common import ShieldModerationPassed, ShieldModerationResult
23-
from models.config import ShieldConfiguration
24+
from models.common.moderation import (
25+
ShieldModerationPassed,
26+
ShieldModerationResult,
27+
)
28+
from models.config import QuestionValidityConfig, RedactionConfig, ShieldConfiguration
29+
from pydantic_ai_lightspeed.capabilities.base import AbstractSafetyCapability
30+
from pydantic_ai_lightspeed.capabilities.question_validity._capability import (
31+
QuestionValidity,
32+
)
33+
from pydantic_ai_lightspeed.capabilities.redaction._capability import (
34+
PiiRedactionCapability,
35+
)
36+
37+
logger = get_logger(__name__)
2438

2539

2640
def validate_shield_ids_override(
@@ -59,6 +73,74 @@ def validate_shield_ids_override(
5973
raise HTTPException(**response.model_dump())
6074

6175

76+
async def run_shield_moderation_v2(
77+
input_text: str,
78+
shield_configs: list[ShieldConfiguration],
79+
selected_shield_ids: Optional[list[str]] = None,
80+
) -> ShieldModerationResult:
81+
"""Run v2 shield moderation on input text.
82+
83+
Iterates through configured shields and runs moderation checks.
84+
85+
Parameters:
86+
input_text: The text to moderate.
87+
shield_configs: List of shield configurations to evaluate.
88+
selected_shield_ids: Optional list of shield names to filter by.
89+
90+
Returns:
91+
Result indicating if content was blocked or passed.
92+
93+
Raises:
94+
HTTPException: 503 if the shield model is unreachable or returns an
95+
HTTP error, 500 if the model returns an unexpected response.
96+
"""
97+
selected_shield_configs = (
98+
[c for c in shield_configs if c.name in selected_shield_ids]
99+
if selected_shield_ids is not None
100+
else shield_configs
101+
)
102+
103+
try:
104+
for shield_config in selected_shield_configs:
105+
shield = build_shield(shield_config)
106+
shield_result = await shield.run(input_text)
107+
108+
if shield_result.decision == "blocked":
109+
return shield_result
110+
except (ModelHTTPError, ModelAPIError) as e:
111+
logger.error("Shield moderation model request failed: %s", e)
112+
error_response = ServiceUnavailableResponse(
113+
backend_name="shield model",
114+
cause=str(e),
115+
)
116+
raise HTTPException(**error_response.model_dump()) from e
117+
except UnexpectedModelBehavior as e:
118+
logger.error("Shield moderation received unexpected model response: %s", e)
119+
error_response = InternalServerErrorResponse(
120+
response="Shield moderation failed",
121+
cause=str(e),
122+
)
123+
raise HTTPException(**error_response.model_dump()) from e
124+
125+
return ShieldModerationPassed()
126+
127+
128+
def build_shield(shield_config: ShieldConfiguration) -> AbstractSafetyCapability:
129+
"""Build a safety capability instance from a shield configuration.
130+
131+
Parameters:
132+
shield_config: The shield configuration to build from.
133+
134+
Returns:
135+
The constructed safety capability.
136+
"""
137+
match shield_config.config:
138+
case QuestionValidityConfig():
139+
return QuestionValidity(shield_config.config)
140+
case RedactionConfig():
141+
return PiiRedactionCapability(shield_config.config)
142+
143+
62144
async def run_shield_moderation(
63145
_client: AsyncOgxClient,
64146
_input_text: str,

tests/unit/utils/test_shields.py

Lines changed: 160 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@
22

33
import pytest
44
from fastapi import HTTPException, status
5+
from pydantic_ai import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior
56
from pytest_mock import MockerFixture
67

7-
from models.config import QuestionValidityConfig, QuestionValidityShieldConfiguration
8+
from models.common.moderation import ShieldModerationBlocked, ShieldModerationPassed
9+
from models.config import (
10+
QuestionValidityConfig,
11+
QuestionValidityShieldConfiguration,
12+
ShieldConfiguration,
13+
)
814
from utils.shields import (
915
append_turn_to_conversation,
1016
get_shields_for_request,
17+
run_shield_moderation_v2,
1118
validate_shield_ids_override,
1219
)
1320

1421

15-
def _shield(name: str) -> QuestionValidityShieldConfiguration:
22+
def _shield_config(name: str) -> QuestionValidityShieldConfiguration:
1623
"""Build a minimal question-validity shield configuration for tests."""
1724
return QuestionValidityShieldConfiguration(
1825
name=name,
@@ -133,30 +140,174 @@ def test_raises_422_when_empty_list_shield_ids_and_override_disabled(
133140
assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
134141

135142

143+
class TestRunShieldModerationV2:
144+
"""Tests for run_shield_moderation_v2 function."""
145+
146+
@pytest.mark.asyncio
147+
async def test_returns_passed_when_no_shields(self) -> None:
148+
"""Return ShieldModerationPassed when shield list is empty."""
149+
result = await run_shield_moderation_v2("test input", [])
150+
assert isinstance(result, ShieldModerationPassed)
151+
152+
@pytest.mark.asyncio
153+
async def test_returns_passed_when_all_shields_pass(
154+
self, mocker: MockerFixture
155+
) -> None:
156+
"""Return ShieldModerationPassed when every shield passes."""
157+
mock_shield = mocker.Mock()
158+
mock_shield.run = mocker.AsyncMock(return_value=ShieldModerationPassed())
159+
mocker.patch("utils.shields.build_shield", return_value=mock_shield)
160+
161+
shields: list[ShieldConfiguration] = [
162+
_shield_config("s1"),
163+
_shield_config("s2"),
164+
]
165+
result = await run_shield_moderation_v2("test input", shields)
166+
167+
assert isinstance(result, ShieldModerationPassed)
168+
assert mock_shield.run.call_count == 2
169+
170+
@pytest.mark.asyncio
171+
async def test_returns_blocked_on_first_block(self, mocker: MockerFixture) -> None:
172+
"""Return blocked result from first shield that blocks."""
173+
blocked = ShieldModerationBlocked(message="rejected", moderation_id="modr-123")
174+
mock_shield = mocker.Mock()
175+
mock_shield.run = mocker.AsyncMock(return_value=blocked)
176+
mocker.patch("utils.shields.build_shield", return_value=mock_shield)
177+
178+
shields: list[ShieldConfiguration] = [
179+
_shield_config("s1"),
180+
_shield_config("s2"),
181+
]
182+
result = await run_shield_moderation_v2("test input", shields)
183+
184+
assert isinstance(result, ShieldModerationBlocked)
185+
assert result.message == "rejected"
186+
mock_shield.run.assert_called_once()
187+
188+
@pytest.mark.asyncio
189+
async def test_filters_by_selected_shield_ids(self, mocker: MockerFixture) -> None:
190+
"""Only run shields matching the selected IDs."""
191+
mock_shield = mocker.Mock()
192+
mock_shield.run = mocker.AsyncMock(return_value=ShieldModerationPassed())
193+
mocker.patch("utils.shields.build_shield", return_value=mock_shield)
194+
195+
shields: list[ShieldConfiguration] = [
196+
_shield_config("s1"),
197+
_shield_config("s2"),
198+
_shield_config("s3"),
199+
]
200+
result = await run_shield_moderation_v2(
201+
"test input", shields, selected_shield_ids=["s2"]
202+
)
203+
204+
assert isinstance(result, ShieldModerationPassed)
205+
mock_shield.run.assert_called_once()
206+
207+
@pytest.mark.asyncio
208+
async def test_shields_stops_on_first_block(self, mocker: MockerFixture) -> None:
209+
"""Stop at the first blocking shield."""
210+
blocked = ShieldModerationBlocked(message="rejected", moderation_id="modr-789")
211+
mock_qv_shield = mocker.Mock()
212+
mock_qv_shield.run = mocker.AsyncMock(return_value=blocked)
213+
214+
mock_redact_shield = mocker.Mock()
215+
mock_redact_shield.run = mocker.AsyncMock(return_value=ShieldModerationPassed())
216+
217+
mocker.patch(
218+
"utils.shields.build_shield",
219+
side_effect=[mock_qv_shield, mock_redact_shield],
220+
)
221+
222+
shields: list[ShieldConfiguration] = [
223+
_shield_config("s-1"),
224+
_shield_config("s-2"),
225+
]
226+
result = await run_shield_moderation_v2("test input", shields)
227+
228+
assert isinstance(result, ShieldModerationBlocked)
229+
mock_qv_shield.run.assert_called_once()
230+
mock_redact_shield.run.assert_not_called()
231+
232+
@pytest.mark.asyncio
233+
async def test_raises_503_on_model_http_error(self, mocker: MockerFixture) -> None:
234+
"""Raise HTTP 503 when the shield model returns an HTTP error."""
235+
mock_shield = mocker.Mock()
236+
mock_shield.run = mocker.AsyncMock(
237+
side_effect=ModelHTTPError(
238+
status_code=500, model_name="test-model", body=None
239+
)
240+
)
241+
mocker.patch("utils.shields.build_shield", return_value=mock_shield)
242+
243+
with pytest.raises(HTTPException) as exc_info:
244+
await run_shield_moderation_v2("test input", [_shield_config("s1")])
245+
246+
assert exc_info.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
247+
248+
@pytest.mark.asyncio
249+
async def test_raises_503_on_model_api_error(self, mocker: MockerFixture) -> None:
250+
"""Raise HTTP 503 when the shield model is unreachable."""
251+
mock_shield = mocker.Mock()
252+
mock_shield.run = mocker.AsyncMock(
253+
side_effect=ModelAPIError(
254+
model_name="test-model", message="Connection refused"
255+
)
256+
)
257+
mocker.patch("utils.shields.build_shield", return_value=mock_shield)
258+
259+
with pytest.raises(HTTPException) as exc_info:
260+
await run_shield_moderation_v2("test input", [_shield_config("s1")])
261+
262+
assert exc_info.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
263+
264+
@pytest.mark.asyncio
265+
async def test_raises_500_on_unexpected_model_behavior(
266+
self, mocker: MockerFixture
267+
) -> None:
268+
"""Raise HTTP 500 when the shield model returns an unexpected response."""
269+
mock_shield = mocker.Mock()
270+
mock_shield.run = mocker.AsyncMock(
271+
side_effect=UnexpectedModelBehavior("bad response format")
272+
)
273+
mocker.patch("utils.shields.build_shield", return_value=mock_shield)
274+
275+
with pytest.raises(HTTPException) as exc_info:
276+
await run_shield_moderation_v2("test input", [_shield_config("s1")])
277+
278+
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
279+
280+
136281
class TestGetShieldsForRequest:
137282
"""Tests for get_shields_for_request function."""
138283

139284
def test_returns_all_shields_when_shield_ids_none(self) -> None:
140285
"""Return all configured shields when shield_ids is None."""
141-
shields = [_shield("shield-1"), _shield("shield-2")]
286+
shields = [
287+
_shield_config("shield-1"),
288+
_shield_config("shield-2"),
289+
]
142290

143291
result = get_shields_for_request(shields, shield_ids=None)
144292

145293
assert result == shields
146294

147295
def test_returns_empty_list_when_shield_ids_empty(self) -> None:
148296
"""Return no shields when an empty shield_ids list is provided."""
149-
shields = [_shield("shield-1"), _shield("shield-2")]
297+
shields = [
298+
_shield_config("shield-1"),
299+
_shield_config("shield-2"),
300+
]
150301

151302
result = get_shields_for_request(shields, shield_ids=[])
152303

153304
assert result == []
154305

155306
def test_filters_to_requested_shields_when_all_exist(self) -> None:
156307
"""Return only shields whose names appear in shield_ids."""
157-
shield1 = _shield("shield-1")
158-
shield2 = _shield("shield-2")
159-
shield3 = _shield("shield-3")
308+
shield1 = _shield_config("shield-1")
309+
shield2 = _shield_config("shield-2")
310+
shield3 = _shield_config("shield-3")
160311

161312
result = get_shields_for_request(
162313
[shield1, shield2, shield3], shield_ids=["shield-1", "shield-3"]
@@ -168,7 +319,8 @@ def test_raises_404_when_requested_shield_not_configured(self) -> None:
168319
"""Raise 404 when a requested shield name is not configured."""
169320
with pytest.raises(HTTPException) as exc_info:
170321
get_shields_for_request(
171-
[_shield("shield-1")], shield_ids=["shield-1", "missing-shield"]
322+
[_shield_config("shield-1")],
323+
shield_ids=["shield-1", "missing-shield"],
172324
)
173325

174326
assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND

0 commit comments

Comments
 (0)