Skip to content

Commit de30f8a

Browse files
committed
ci: install fastapi with litellm; cover guardrail edge paths
Base litellm does not include fastapi (it's in their proxy extra), so test collection failed with ModuleNotFoundError in CI. Proxy deployments always ship fastapi; CI now installs it alongside litellm and the test module skips gracefully without it. Adds edge-path tests (no-messages data, non-text content parts, opaque responses, observability failure) bringing the adapter to 100% line coverage.
1 parent ee1b712 commit de30f8a

3 files changed

Lines changed: 90 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
if: matrix.install-profile == 'nlp-advanced'
5959
run: |
6060
pip install -e ".[test,cli,nlp,nlp-advanced]" -r requirements-test.txt
61-
pip install "litellm>=1.90,<2" # exercises the LiteLLM guardrail adapter tests
61+
pip install "litellm>=1.90,<2" fastapi # exercises the LiteLLM guardrail adapter tests (proxy deployments always have fastapi)
6262
python -m spacy download en_core_web_lg
6363
datafog download-model urchade/gliner_multi_pii-v1 --engine gliner
6464

datafog/integrations/litellm_guardrail.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
Errors and block messages report entity type counts only — matched PII is
2727
never echoed into logs, exceptions, or proxy responses.
2828
29-
Requires ``litellm`` (this module is not imported by ``datafog`` core).
29+
Requires ``litellm`` and ``fastapi`` (this module is not imported by
30+
``datafog`` core; the LiteLLM proxy, where this runs, always ships fastapi).
3031
"""
3132

3233
import logging

tests/test_litellm_guardrail.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010

1111
litellm = pytest.importorskip("litellm")
12+
pytest.importorskip("fastapi") # adapter raises fastapi.HTTPException on block
1213

1314
from datafog.integrations.litellm_guardrail import DataFogGuardrail # noqa: E402
1415

@@ -119,6 +120,92 @@ async def test_redacts_model_response(self):
119120
)
120121
assert EMAIL not in response.choices[0].message.content
121122

123+
async def test_response_without_choices_is_returned_untouched(self):
124+
guardrail = DataFogGuardrail(guardrail_name="datafog-pii")
125+
opaque = object()
126+
result = await guardrail.async_post_call_success_hook(
127+
data={}, user_api_key_dict=None, response=opaque
128+
)
129+
assert result is opaque
130+
131+
async def test_non_text_response_content_is_skipped_not_crashed(self):
132+
guardrail = DataFogGuardrail(guardrail_name="datafog-pii")
133+
response = _model_response("placeholder")
134+
response.choices[0].message.content = [{"type": "tool_use"}]
135+
result = await guardrail.async_post_call_success_hook(
136+
data={}, user_api_key_dict=None, response=response
137+
)
138+
assert result.choices[0].message.content == [{"type": "tool_use"}]
139+
140+
async def test_post_call_fail_open_returns_unredacted_response(self, monkeypatch):
141+
guardrail = DataFogGuardrail(guardrail_name="datafog-pii", fail_policy="open")
142+
monkeypatch.setattr(
143+
"datafog.integrations.litellm_guardrail._redact_text",
144+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
145+
)
146+
response = _model_response(f"reach me at {EMAIL}")
147+
result = await guardrail.async_post_call_success_hook(
148+
data={}, user_api_key_dict=None, response=response
149+
)
150+
assert result.choices[0].message.content == f"reach me at {EMAIL}"
151+
152+
153+
@pytest.mark.asyncio
154+
class TestEdgeShapes:
155+
async def test_data_without_messages_passes_through(self):
156+
guardrail = DataFogGuardrail(guardrail_name="datafog-pii")
157+
data = {"input": f"embed {EMAIL}"}
158+
result = await guardrail.async_pre_call_hook(
159+
user_api_key_dict=None, cache=None, data=data, call_type="aembedding"
160+
)
161+
assert result == data
162+
163+
async def test_message_without_content_key_passes_through(self):
164+
guardrail = DataFogGuardrail(guardrail_name="datafog-pii")
165+
data = {"messages": [{"role": "assistant", "tool_calls": []}]}
166+
result = await guardrail.async_pre_call_hook(
167+
user_api_key_dict=None, cache=None, data=data, call_type="completion"
168+
)
169+
assert result == data
170+
171+
async def test_mixed_content_parts_skips_non_text_and_redacts_text(self):
172+
guardrail = DataFogGuardrail(guardrail_name="datafog-pii")
173+
data = _chat_data(
174+
[
175+
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xx"}},
176+
{"type": "text", "text": f"card {CARD}"},
177+
]
178+
)
179+
result = await guardrail.async_pre_call_hook(
180+
user_api_key_dict=None, cache=None, data=data, call_type="completion"
181+
)
182+
parts = result["messages"][0]["content"]
183+
assert parts[0]["type"] == "image_url" # untouched
184+
assert CARD not in parts[1]["text"]
185+
186+
async def test_non_string_non_list_content_passes_through(self):
187+
guardrail = DataFogGuardrail(guardrail_name="datafog-pii")
188+
data = _chat_data(None)
189+
result = await guardrail.async_pre_call_hook(
190+
user_api_key_dict=None, cache=None, data=data, call_type="completion"
191+
)
192+
assert result["messages"][0]["content"] is None
193+
194+
async def test_logging_helper_failure_never_breaks_traffic(self, monkeypatch):
195+
guardrail = DataFogGuardrail(guardrail_name="datafog-pii")
196+
monkeypatch.setattr(
197+
DataFogGuardrail,
198+
"add_standard_logging_guardrail_information_to_request_data",
199+
lambda self, **kw: (_ for _ in ()).throw(RuntimeError("obs down")),
200+
)
201+
data = await guardrail.async_pre_call_hook(
202+
user_api_key_dict=None,
203+
cache=None,
204+
data=_chat_data(f"reach me at {EMAIL}"),
205+
call_type="completion",
206+
)
207+
assert EMAIL not in data["messages"][0]["content"] # redaction still happened
208+
122209

123210
@pytest.mark.asyncio
124211
class TestConfig:

0 commit comments

Comments
 (0)