Skip to content

Commit 228da7f

Browse files
committed
fix: classify completed empty HTTP replies as unscorable
Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com>
1 parent 7f1f68b commit 228da7f

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

‎ifixai/providers/http.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
ChatProvider,
1313
ProviderAuthError,
1414
ProviderConnectionError,
15+
ProviderEmptyContentError,
1516
ProviderOverloadedError,
1617
ProviderRateLimitError,
1718
ProviderResponseError,
@@ -244,6 +245,18 @@ def _extract_response_text(
244245
finish_reason,
245246
content if isinstance(content, str) else "",
246247
)
248+
if (
249+
isinstance(content, str)
250+
and not content
251+
and data.get("error") is None
252+
and first.get("error") is None
253+
and str(first.get("finish_reason", "")).lower() != "error"
254+
):
255+
raise ProviderEmptyContentError(
256+
provider="http",
257+
endpoint=endpoint,
258+
details=f"Empty content in response (finish_reason={first.get('finish_reason', 'unknown')})",
259+
)
247260
if not isinstance(content, str) or not content:
248261
raise ProviderResponseError(
249262
provider="http",
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Completed empty HTTP replies use the existing unscorable-output contract."""
2+
3+
from collections import Counter
4+
from contextlib import asynccontextmanager
5+
6+
import aiohttp.web
7+
import pytest
8+
9+
from ifixai.api import run_single
10+
from ifixai.core.types import ChatMessage, ProviderConfig, TestRunResult, TestStatus
11+
from ifixai.providers.base import (
12+
ProviderEmptyContentError,
13+
ProviderResponseError,
14+
ProviderTruncatedError,
15+
)
16+
from ifixai.providers.http import HttpProvider
17+
from ifixai.reporting.health import run_health
18+
19+
20+
def completion(content="", finish_reason="stop"):
21+
return {
22+
"choices": [{
23+
"message": {"role": "assistant", "content": content},
24+
"finish_reason": finish_reason,
25+
}],
26+
}
27+
28+
29+
@asynccontextmanager
30+
async def owned_endpoint(reply):
31+
requests = []
32+
33+
async def respond(request):
34+
requests.append((request.path, await request.json()))
35+
return aiohttp.web.json_response(reply)
36+
37+
app = aiohttp.web.Application()
38+
app.router.add_post("/v1/chat/completions", respond)
39+
app.router.add_post("/v1/retrieve", respond)
40+
runner = aiohttp.web.AppRunner(app)
41+
await runner.setup()
42+
site = aiohttp.web.TCPSite(runner, "127.0.0.1", 0)
43+
await site.start()
44+
try:
45+
yield f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/v1", requests
46+
finally:
47+
await runner.cleanup()
48+
49+
50+
@pytest.mark.asyncio
51+
@pytest.mark.parametrize(
52+
"reply, expected",
53+
[
54+
({}, ProviderResponseError),
55+
({"choices": []}, ProviderResponseError),
56+
({"choices": [{}]}, ProviderResponseError),
57+
({"choices": [{"message": {}}]}, ProviderResponseError),
58+
(completion(None), ProviderResponseError),
59+
(completion([]), ProviderResponseError),
60+
({"error": {"code": 429, "message": "rate limited"}}, ProviderResponseError),
61+
({"choices": [{"message": {"content": ""}, "finish_reason": "error",
62+
"error": {"code": 429, "message": "rate limited"}}]}, ProviderResponseError),
63+
(dict(completion(), error={"code": 429, "message": "rate limited"}), ProviderResponseError),
64+
(completion("", "length"), ProviderTruncatedError),
65+
(completion("ordinary"), "ordinary"),
66+
(completion(), ProviderEmptyContentError),
67+
(completion("", "ERROR"), ProviderResponseError),
68+
({"choices": [{"message": {"content": ""}, "error": {}}]}, ProviderResponseError),
69+
],
70+
)
71+
async def test_empty_completion_keeps_other_response_failures_distinct(reply, expected):
72+
async with owned_endpoint(reply) as (endpoint, requests):
73+
provider = HttpProvider()
74+
config = ProviderConfig(
75+
provider="http", model="owned", endpoint=endpoint,
76+
max_retries=1, timeout=5, reject_truncated=True,
77+
)
78+
try:
79+
if isinstance(expected, str):
80+
assert await provider.send_message([ChatMessage(content="hello")], config) == expected
81+
else:
82+
with pytest.raises(expected) as caught:
83+
await provider.send_message([ChatMessage(content="hello")], config)
84+
assert type(caught.value) is expected
85+
assert caught.value.provider == "http"
86+
assert caught.value.endpoint == endpoint
87+
assert len(requests) == 1
88+
finally:
89+
await provider.aclose()
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_public_inspection_stops_without_false_communication_evidence():
94+
async with owned_endpoint(completion()) as (endpoint, requests):
95+
result = await run_single(
96+
"B06", provider=HttpProvider(), fixture="default", endpoint=endpoint,
97+
model="owned", timeout=5, max_retries=0,
98+
)
99+
assert result.status is TestStatus.INCONCLUSIVE
100+
assert result.error and "Empty content" in result.error
101+
assert result.evidence == []
102+
assert Counter(path for path, _ in requests) == {
103+
"/v1/retrieve": 1, "/v1/chat/completions": 1,
104+
}
105+
health = run_health(TestRunResult(test_results=[result]))
106+
assert health.unreachable == 0

0 commit comments

Comments
 (0)