Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions digital_mate/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,43 @@ def _parse_content(response_json: dict[str, Any]) -> str:
raise LLMError("LLM returned empty response.")
return content.strip()

@staticmethod
def _parse_response(response: httpx.Response) -> dict[str, Any]:
"""Normalize JSON or SSE responses into a chat completion object."""
content_type = response.headers.get("content-type", "").lower()
if "text/event-stream" not in content_type:
return response.json()

content_parts: list[str] = []
usage: dict[str, Any] | None = None
for line in response.text.splitlines():
if not line.startswith("data: "):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept valid SSE data fields without a space

When a stream-forcing gateway emits valid SSE as data:{...} or data:[DONE] without the optional space after the colon, this parser skips every event because it only accepts data: . In that scenario chat(), chat_json(), and chat_with_image() synthesize an empty completion and fail even though the gateway returned usable chunks; parse data: and trim the optional leading space instead.

Useful? React with 👍 / 👎.

continue
data = line[6:].strip()
if not data or data == "[DONE]":
continue
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
if isinstance(chunk.get("usage"), dict):
usage = chunk["usage"]
choices = chunk.get("choices", [])
if not choices:
continue
choice = choices[0]
message = choice.get("delta") or choice.get("message") or {}
content = message.get("content")
if content:
content_parts.append(content)

result: dict[str, Any] = {
"choices": [{"message": {"content": "".join(content_parts)}}]
}
if usage is not None:
result["usage"] = usage
return result

@staticmethod
def _strip_code_fences(text: str) -> str:
"""Strip markdown code fences from JSON text if present."""
Expand Down Expand Up @@ -174,7 +211,7 @@ async def chat(
try:
response = await self._client.post("/chat/completions", json=body)
response.raise_for_status()
response_json = response.json()
response_json = self._parse_response(response)
metrics.record_usage(response_json.get("usage"))
return self._parse_content(response_json)

Expand Down Expand Up @@ -477,7 +514,7 @@ async def chat_with_image(
try:
response = await self._client.post("/chat/completions", json=body)
response.raise_for_status()
response_json = response.json()
response_json = self._parse_response(response)
metrics.record_usage(response_json.get("usage"))
return self._parse_content(response_json)

Expand Down Expand Up @@ -679,7 +716,7 @@ async def chat_json(
try:
response = await self._client.post("/chat/completions", json=body)
response.raise_for_status()
response_json = response.json()
response_json = self._parse_response(response)
metrics.record_usage(response_json.get("usage"))
content = self._parse_content(response_json)

Expand Down
1 change: 1 addition & 0 deletions tests/test_image_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def _make_httpx_response(content: str) -> MagicMock:
"""Build a mock httpx.Response returning the given content."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.headers = {}
resp.json.return_value = {
"choices": [{"message": {"content": content}}]
}
Expand Down
54 changes: 54 additions & 0 deletions tests/test_llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def _make_httpx_response(
"""Build a mock httpx.Response returning the given content."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.headers = {}
resp.json.return_value = {
"choices": [{"message": {"content": content}}],
**({"usage": usage} if usage else {}),
Expand All @@ -54,13 +55,33 @@ def _make_httpx_response_none_content() -> MagicMock:
"""Build a mock httpx.Response with None content."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.headers = {}
resp.json.return_value = {
"choices": [{"message": {"content": None}}]
}
resp.raise_for_status = MagicMock()
return resp


def _make_buffered_sse_response(
contents: list[str], usage: dict[str, int] | None = None
) -> httpx.Response:
"""Build a buffered SSE response returned by stream-forcing gateways."""
lines = [
"data: " + json.dumps({"choices": [{"delta": {"content": content}}]})
for content in contents
]
if usage is not None:
lines.append("data: " + json.dumps({"choices": [], "usage": usage}))
lines.append("data: [DONE]")
return httpx.Response(
200,
headers={"content-type": "text/event-stream; charset=utf-8"},
text="\n\n".join(lines),
request=httpx.Request("POST", "http://test.api/v1/chat/completions"),
)


def _make_httpx_status_error(status_code: int = 400) -> httpx.HTTPStatusError:
"""Build an httpx.HTTPStatusError for the given status code."""
request = httpx.Request("POST", "http://test.api/v1/chat/completions")
Expand Down Expand Up @@ -107,6 +128,21 @@ async def test_chat_records_provider_token_usage(llm_client: LLMClient) -> None:
assert snapshot["tokens"]["total"] == 10


@pytest.mark.asyncio
async def test_chat_accepts_buffered_sse_response(llm_client: LLMClient) -> None:
"""chat() joins deltas when a gateway forces SSE for non-stream calls."""
llm_client._client.post = AsyncMock(
return_value=_make_buffered_sse_response(
["Hello", " world"],
{"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4},
)
)

result = await llm_client.chat([{"role": "user", "content": "hi"}])

assert result == "Hello world"


@pytest.mark.asyncio
async def test_chat_retries_on_timeout(llm_client: LLMClient) -> None:
"""chat() retries on httpx.TimeoutException with exponential backoff."""
Expand Down Expand Up @@ -244,6 +280,24 @@ async def test_chat_json_success(llm_client: LLMClient) -> None:
assert result == {"pillar": "content", "action": "caption"}


@pytest.mark.asyncio
async def test_chat_json_accepts_buffered_sse_response(
llm_client: LLMClient,
) -> None:
"""chat_json() parses fenced JSON assembled from SSE deltas."""
llm_client._client.post = AsyncMock(
return_value=_make_buffered_sse_response(
["```json\n", '{"pillar":"content",', '"action":"caption"}', "\n```"]
)
)

result = await llm_client.chat_json(
[{"role": "user", "content": "classify"}]
)

assert result == {"pillar": "content", "action": "caption"}


@pytest.mark.asyncio
async def test_chat_json_retries_on_invalid_json(llm_client: LLMClient) -> None:
"""chat_json() retries on JSONDecodeError, then succeeds."""
Expand Down