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:
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user