fix(policy_engine): merge guardrail metadata writes back on block and modify_response so failure spend records keep guardrail cost and status

This commit is contained in:
mateo-berri 2026-08-29 21:52:52 -07:00
parent badefa395c
commit bcee01a7a7
3 changed files with 46 additions and 1 deletions

View file

@ -236,6 +236,7 @@ class PipelineExecutor:
step_results=step_results,
error_message=error_detail,
original_exception=original_exception,
modified_data=working_data if working_data != data else None,
)
if action == "modify_response":
@ -243,6 +244,7 @@ class PipelineExecutor:
terminal_action="modify_response",
step_results=step_results,
modify_response_message=step.modify_response_message or error_detail,
modified_data=working_data if working_data != data else None,
)
# action == "next" → continue to next step

View file

@ -1838,7 +1838,9 @@ class ProxyLogging:
payload (already sent upstream) must stay untouched; a replacement
response carried in ``modified_data`` is adopted by the caller, and
metadata-bucket writes (applied guardrails, guardrail logging info)
are merged back so headers and spend logs still see them. On the
are merged back so headers and spend logs still see them, on block
and modify_response too, so failure spend records keep guardrail
cost and status. On the
streaming path it is the buffered chunk list, carried into
``ModifyResponseException.original_response`` for usage reporting.
"""
@ -1850,6 +1852,9 @@ class ProxyLogging:
_merge_pipeline_metadata_writes(data, result.modified_data)
return data
if result.modified_data is not None:
_merge_pipeline_metadata_writes(data, result.modified_data)
if result.terminal_action == "block":
original_exception: Final = result.original_exception
if original_exception is not None and not _exception_changes_request_flow(original_exception):

View file

@ -1197,6 +1197,44 @@ async def test_post_call_pipeline_guardrail_metadata_writes_reach_request_data(
assert slg_entries[0]["guardrail_name"] == "gr-post"
@pytest.mark.asyncio
async def test_post_call_pipeline_block_keeps_guardrail_metadata_writes(
proxy_logging, make_user_api_key_auth, monkeypatch
):
class BlockingWriterGuardrail(CustomGuardrail):
async def async_post_call_success_hook(self, data, user_api_key_dict, response):
add_guardrail_to_applied_guardrails_header(request_data=data, guardrail_name="gr-post")
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={"verdict": "fail"},
request_data=data,
guardrail_status="guardrail_intervened",
)
raise HTTPException(status_code=400, detail={"error": "output blocked"})
monkeypatch.setattr(
litellm,
"callbacks",
[
BlockingWriterGuardrail(
guardrail_name="gr-post", event_hook=GuardrailEventHooks.post_call, default_on=False
)
],
)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None, raising=False)
data = _post_call_pipeline_data()
with pytest.raises(HTTPException):
await proxy_logging.post_call_success_hook(
data=data, response=litellm.ModelResponse(), user_api_key_dict=make_user_api_key_auth()
)
assert data["metadata"]["applied_guardrails"] == ["gr-post"]
slg_entries = data["metadata"]["standard_logging_guardrail_information"]
assert len(slg_entries) == 1
assert slg_entries[0]["guardrail_name"] == "gr-post"
assert slg_entries[0]["guardrail_status"] == "guardrail_intervened"
@pytest.mark.asyncio
async def test_post_call_pipeline_managed_parallel_guardrail_runs_exactly_once(
proxy_logging, make_user_api_key_auth, monkeypatch