fix(gateway): assemble streaming response into ChatCompletion shape

_parse_sse_response previously returned a list of raw SSE delta chunks.
This was:
  - inconsistent with non-streaming (which returns a single ChatCompletion dict)
  - verbose: 294 chunks per call vs one assembled response
  - inconvenient to inspect: consumers had to manually concat delta.content

Now assembles chunks into a unified dict::

    {
      "id": "chatcmpl-...", "object": "chat.completion",
      "created": <int>, "model": "<model>",
      "choices": [{"message": {"role": "assistant", "content": "<full text>"},
                   "finish_reason": "stop"}],
      "usage": <last chunk usage or None>
    }

Streaming and non-streaming model_request events now have the same shape.
Empty stream (no data chunks) returns {} consistently.

Tests updated to assert on assembled shape.
This commit is contained in:
yuqyang
2026-04-06 20:38:02 -07:00
parent db7e7151d0
commit c25eb23cdc
2 changed files with 69 additions and 11 deletions
+60 -4
View File
@@ -147,7 +147,7 @@ def _capture_event(
rollout_id: str,
attempt_id: str,
original_body: dict[str, Any],
response_body: dict[str, Any] | list[dict[str, Any]],
response_body: dict[str, Any],
server_meta: dict[str, Any],
) -> None:
"""Write a model_request event to the store."""
@@ -163,11 +163,26 @@ def _capture_event(
)
def _parse_sse_response(raw: bytes) -> dict[str, Any] | list[dict[str, Any]]:
"""Parse SSE stream bytes into a list of data chunks.
def _parse_sse_response(raw: bytes) -> dict[str, Any]:
"""Parse SSE stream bytes into an assembled ChatCompletion-shaped dict.
Streaming and non-streaming responses are stored with the same shape so
that consumers (hooks, VERL, archive inspection) don't need to branch.
SSE format: lines starting with "data: " followed by JSON.
The final line is "data: [DONE]".
Assembled shape mirrors the non-streaming OpenAI response::
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": <int>,
"model": "<model>",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "<full text>"},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": ..., "completion_tokens": ..., "total_tokens": ...}
}
"""
import contextlib
import json
@@ -178,4 +193,45 @@ def _parse_sse_response(raw: bytes) -> dict[str, Any] | list[dict[str, Any]]:
if line.startswith("data: ") and line != "data: [DONE]":
with contextlib.suppress(json.JSONDecodeError):
chunks.append(json.loads(line[6:]))
return chunks
if not chunks:
return {}
first = chunks[0]
last = chunks[-1]
# Assemble full content from delta.content across all choices (index-keyed).
contents: dict[int, list[str]] = {}
finish_reasons: dict[int, str | None] = {}
role: str = "assistant"
for chunk in chunks:
for choice in chunk.get("choices", []):
idx = choice.get("index", 0)
delta = choice.get("delta", {})
if "role" in delta:
role = delta["role"]
contents.setdefault(idx, []).append(delta.get("content") or "")
if choice.get("finish_reason"):
finish_reasons[idx] = choice["finish_reason"]
choices = [
{
"index": idx,
"message": {"role": role, "content": "".join(parts)},
"finish_reason": finish_reasons.get(idx),
}
for idx, parts in sorted(contents.items())
]
if not choices:
choices = [{"index": 0, "message": {"role": role, "content": ""}, "finish_reason": None}]
return {
"id": first.get("id", ""),
"object": "chat.completion",
"created": first.get("created"),
"model": first.get("model", ""),
"choices": choices,
# vLLM sends usage in the last chunk when stream_options.include_usage=True;
# fall back to None so callers can detect absence.
"usage": last.get("usage"),
}
+9 -7
View File
@@ -217,13 +217,15 @@ class TestProxyStreaming:
assert event["data"]["request"]["model"] == "qwen-7b" # prepared (rewritten) model
assert event["data"]["server"]["model"] == "qwen-7b"
# Response should be the list of parsed SSE data chunks (3 chunks, not [DONE]).
# Response is now assembled into a ChatCompletion-shaped dict (same shape as non-streaming).
response_data = event["data"]["response"]
assert isinstance(response_data, list)
assert len(response_data) == 3
assert response_data[0]["id"] == "chatcmpl-1"
assert response_data[1]["choices"][0]["delta"]["content"] == "Hello"
assert response_data[2]["choices"][0]["delta"]["content"] == "!"
assert isinstance(response_data, dict)
assert response_data["id"] == "chatcmpl-1"
assert response_data["object"] == "chat.completion"
assert len(response_data["choices"]) == 1
choice = response_data["choices"][0]
assert choice["message"]["role"] == "assistant"
assert choice["message"]["content"] == "Hello!" # delta chunks concatenated
def test_streaming_route_rewrite(self, client: TestClient, auth: dict, httpx_mock):
"""Verify model rewrite works for streaming requests too."""
@@ -268,4 +270,4 @@ class TestProxyStreaming:
events = client.get(f"/api/events?rollout_id={rid}&attempt_id=pod-1", headers=auth).json()
assert len(events) == 1
assert events[0]["data"]["response"] == [] # no chunks parsed
assert events[0]["data"]["response"] == {} # no chunks parsed