Files
2026-07-13 13:22:34 +08:00

337 lines
12 KiB
Python

import json
from unittest import mock
import pytest
from mlflow.entities.gateway_guardrail import (
GatewayGuardrail,
GatewayGuardrailConfig,
GuardrailAction,
GuardrailStage,
)
from mlflow.entities.scorer import ScorerVersion
from mlflow.gateway.guardrail_utils import (
load_guardrails,
run_post_llm_guardrails,
run_post_llm_guardrails_passthrough,
run_pre_llm_guardrails,
)
from mlflow.gateway.guardrails import GuardrailViolation, JudgeGuardrail
from mlflow.gateway.schemas.chat import ResponsePayload
# ─── Helpers ─────────────────────────────────────────────────────────────────
class _SimpleScorer:
"""Minimal scorer that returns 'yes' or 'no' and tracks call count."""
def __init__(self, *, passing: bool = True) -> None:
self.call_count = 0
self._passing = passing
def __call__(self, **kwargs) -> str:
self.call_count += 1
return "yes" if self._passing else "no"
def _make_judge(stage, action=GuardrailAction.VALIDATION, *, passing=True):
scorer = _SimpleScorer(passing=passing)
return JudgeGuardrail(
scorer=scorer,
stage=GuardrailStage(stage),
action=GuardrailAction(action),
name=f"test-{stage.lower()}",
)
def _make_request_payload():
return {"messages": [{"role": "user", "content": "hello"}]}
def _make_response_payload():
return ResponsePayload(**{
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "hi there"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10},
})
def _make_scorer_version():
return ScorerVersion(
experiment_id="0",
scorer_name="safety",
scorer_version=1,
serialized_scorer=json.dumps({
"name": "safety",
"builtin_scorer_class": "Safety",
"instructions": "check safety",
}),
creation_time=0,
scorer_id="s-1",
)
def _make_guardrail_entity(stage="BEFORE", action="VALIDATION"):
return GatewayGuardrail(
guardrail_id="gr-1",
name="safety",
scorer=_make_scorer_version(),
stage=GuardrailStage(stage),
action=GuardrailAction(action),
created_at=0,
last_updated_at=0,
)
def _make_guardrail_config(stage="BEFORE", action="VALIDATION"):
return GatewayGuardrailConfig(
endpoint_id="ep-1",
guardrail_id="gr-1",
execution_order=0,
created_at=0,
guardrail=_make_guardrail_entity(stage, action),
)
# ─── run_pre_llm_guardrails ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_run_pre_llm_guardrails_passes():
g = _make_judge("BEFORE")
payload = _make_request_payload()
result = await run_pre_llm_guardrails([g], payload)
assert result == payload
assert g.scorer.call_count == 1
@pytest.mark.asyncio
async def test_run_pre_llm_guardrails_skips_after_stage():
g = _make_judge("AFTER")
payload = _make_request_payload()
result = await run_pre_llm_guardrails([g], payload)
assert result == payload
assert g.scorer.call_count == 0
@pytest.mark.asyncio
async def test_run_pre_llm_guardrails_blocks():
g = _make_judge("BEFORE", passing=False)
with pytest.raises(GuardrailViolation, match="blocked"):
await run_pre_llm_guardrails([g], _make_request_payload())
@pytest.mark.asyncio
async def test_run_pre_llm_guardrails_chains_multiple():
g1 = _make_judge("BEFORE")
g2 = _make_judge("BEFORE")
payload = _make_request_payload()
result = await run_pre_llm_guardrails([g1, g2], payload)
assert result == payload
assert g1.scorer.call_count == 1
assert g2.scorer.call_count == 1
@pytest.mark.asyncio
async def test_run_pre_llm_guardrails_stops_at_first_failure():
g1 = _make_judge("BEFORE", passing=False)
g2 = _make_judge("BEFORE")
with pytest.raises(GuardrailViolation, match="blocked"):
await run_pre_llm_guardrails([g1, g2], _make_request_payload())
assert g2.scorer.call_count == 0
# ─── run_post_llm_guardrails ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_run_post_llm_guardrails_passes():
g = _make_judge("AFTER")
req = _make_request_payload()
response = _make_response_payload()
result = await run_post_llm_guardrails([g], req, response)
assert result.choices[0].message.content == "hi there"
assert g.scorer.call_count == 1
@pytest.mark.asyncio
async def test_run_post_llm_guardrails_skips_before_stage():
g = _make_judge("BEFORE")
response = _make_response_payload()
result = await run_post_llm_guardrails([g], _make_request_payload(), response)
assert result is response
assert g.scorer.call_count == 0
@pytest.mark.asyncio
async def test_run_post_llm_guardrails_blocks():
g = _make_judge("AFTER", passing=False)
with pytest.raises(GuardrailViolation, match="blocked"):
await run_post_llm_guardrails([g], _make_request_payload(), _make_response_payload())
@pytest.mark.asyncio
async def test_run_post_llm_guardrails_no_guardrails_returns_response():
response = _make_response_payload()
result = await run_post_llm_guardrails([], _make_request_payload(), response)
assert result is response
# ─── run_post_llm_guardrails_passthrough ─────────────────────────────────────
def _make_passthrough_response():
return {"candidates": [{"content": {"parts": [{"text": "hi"}]}}]}
@pytest.mark.asyncio
async def test_run_post_llm_guardrails_passthrough_passes():
g = _make_judge("AFTER")
req = _make_request_payload()
response = _make_passthrough_response()
result = await run_post_llm_guardrails_passthrough([g], req, response)
assert result == response
assert g.scorer.call_count == 1
@pytest.mark.asyncio
async def test_run_post_llm_guardrails_passthrough_skips_before_stage():
g = _make_judge("BEFORE")
response = _make_passthrough_response()
result = await run_post_llm_guardrails_passthrough([g], _make_request_payload(), response)
assert result is response
assert g.scorer.call_count == 0
@pytest.mark.asyncio
async def test_run_post_llm_guardrails_passthrough_blocks():
g = _make_judge("AFTER", passing=False)
with pytest.raises(GuardrailViolation, match="blocked"):
await run_post_llm_guardrails_passthrough(
[g], _make_request_payload(), _make_passthrough_response()
)
@pytest.mark.asyncio
async def test_run_post_llm_guardrails_passthrough_no_guardrails_returns_response():
response = _make_passthrough_response()
result = await run_post_llm_guardrails_passthrough([], _make_request_payload(), response)
assert result is response
# ─── load_guardrails ──────────────────────────────────────────────────────────
def test_load_guardrails_empty():
store = mock.MagicMock()
store.list_endpoint_guardrail_configs.return_value = []
endpoint_config = mock.MagicMock()
endpoint_config.endpoint_id = "ep-1"
request = mock.MagicMock()
request.base_url = "http://localhost:5000/"
result = load_guardrails(store, endpoint_config, request)
assert result == []
store.list_endpoint_guardrail_configs.assert_called_once_with("ep-1")
def test_load_guardrails_converts_real_entity():
config = _make_guardrail_config(stage="BEFORE", action="VALIDATION")
resolved_scorer = mock.MagicMock()
store = mock.MagicMock()
store.list_endpoint_guardrail_configs.return_value = [config]
store.resolve_endpoint_in_scorer.return_value = resolved_scorer
endpoint_config = mock.MagicMock()
endpoint_config.endpoint_id = "ep-1"
request = mock.MagicMock()
request.base_url = "http://localhost:5000/"
with mock.patch("mlflow.gateway.guardrails.JudgeGuardrail.from_entity") as mock_from_entity:
mock_from_entity.return_value = _make_judge("BEFORE")
result = load_guardrails(store, endpoint_config, request)
assert len(result) == 1
assert isinstance(result[0], JudgeGuardrail)
store.resolve_endpoint_in_scorer.assert_called_once_with(config.guardrail.scorer)
called_guardrail, called_url = mock_from_entity.call_args[0]
assert called_url == "http://localhost:5000"
assert called_guardrail.scorer is resolved_scorer
assert called_guardrail.guardrail_id == config.guardrail.guardrail_id
def test_load_guardrails_resolves_endpoint_id_to_name():
"""Scorer's gateway:/ model URI contains an endpoint ID; load_guardrails must
resolve it to the endpoint name via resolve_endpoint_in_scorer before invoking
from_entity, so the judge self-call uses the correct model name.
"""
config = _make_guardrail_config(stage="BEFORE", action="VALIDATION")
original_scorer = config.guardrail.scorer
resolved_scorer = ScorerVersion(
experiment_id=original_scorer.experiment_id,
scorer_name=original_scorer.scorer_name,
scorer_version=original_scorer.scorer_version,
serialized_scorer=json.dumps({
"name": "safety",
"instructions_judge_pydantic_data": {
"instructions": "check safety",
"model": "gateway:/my-endpoint-name",
},
}),
creation_time=original_scorer.creation_time,
scorer_id=original_scorer.scorer_id,
)
store = mock.MagicMock()
store.list_endpoint_guardrail_configs.return_value = [config]
store.resolve_endpoint_in_scorer.return_value = resolved_scorer
endpoint_config = mock.MagicMock()
endpoint_config.endpoint_id = "ep-1"
request = mock.MagicMock()
request.base_url = "http://localhost:5000/"
with mock.patch("mlflow.gateway.guardrails.JudgeGuardrail.from_entity") as mock_from_entity:
mock_from_entity.return_value = _make_judge("BEFORE")
load_guardrails(store, endpoint_config, request)
store.resolve_endpoint_in_scorer.assert_called_once_with(original_scorer)
called_guardrail, _ = mock_from_entity.call_args[0]
assert called_guardrail.scorer is resolved_scorer
def test_load_guardrails_skips_failed_conversion():
good_config = _make_guardrail_config(stage="BEFORE")
bad_config = _make_guardrail_config(stage="AFTER")
bad_config.guardrail_id = "gr-bad"
store = mock.MagicMock()
store.list_endpoint_guardrail_configs.return_value = [bad_config, good_config]
endpoint_config = mock.MagicMock()
endpoint_config.endpoint_id = "ep-1"
request = mock.MagicMock()
request.base_url = "http://localhost:5000/"
good_judge = _make_judge("BEFORE")
def from_entity_side_effect(entity, server_url):
if entity.stage == GuardrailStage.AFTER:
raise ValueError("bad scorer")
return good_judge
with mock.patch(
"mlflow.gateway.guardrails.JudgeGuardrail.from_entity",
side_effect=from_entity_side_effect,
):
result = load_guardrails(store, endpoint_config, request)
assert result == [good_judge]