Skip to content
Open
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
13 changes: 13 additions & 0 deletions mstar/api_server/openai/serving_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,18 @@ def chunk(delta, finish=None) -> str:
yield chunk({"audio": {"id": rid("audio"), "data": base64.b64encode(c.data).decode("ascii")}})
elif c.modality == "image":
yield chunk({"content": media_io.png_to_data_url(c.data)})
elif c.modality == "error":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note: this doesn't cover the timeout error, which directly raises an HTTPException in iter_result_chunks

# The request failed after the stream opened (an engine error mid
# generation, a delivery timeout); the HTTP status is committed, so
# the failure travels in-band the way the non-streaming path's
# error body does — not as a normal ``stop``, which a client would
# take for a complete answer.
yield sse({"error": {
"message": c.data.decode("utf-8", "replace"),
"type": "server_error",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The type is hardcoded to "server_error", but the data worker's _fail_request will return a 400 if it sees a ValueError or TypeError

"code": c.metadata.get("status", 500),
}})
yield SSE_DONE
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This early return trips the finally in iter_result_chunks, which calls self.abort_request(request_id) and triggers an abort, even though the drain/removal of the request is already being processed.

(reproduced the behavior with a dummy example:

import asyncio

async def x():
    finished = False
    try:
        for i in range(5):
            yield i
        finished = True
    finally:
        if not finished:
            print("finally reached: not finished")


async def y():
    async for c in x():
        print(c)
        if c == 3:
            return

asyncio.run(y())

prints the "finally reached: not finished")

yield chunk({}, finish="stop")
yield SSE_DONE
22 changes: 22 additions & 0 deletions test/modular/test_openai_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,28 @@ def test_chat_stream(client_and_stub):
assert lines[-1]["choices"][0]["finish_reason"] == "stop"


def test_chat_stream_reports_a_failed_request_in_band(client_and_stub):
"""A request that fails after the stream opened ends with an error event,
not a ``finish_reason: stop`` that reads as a complete answer."""
client, stub = client_and_stub
stub.model_name = "bagel"
stub.next_chunks = [
_Chunk("text", b"Paris"),
_Chunk("error", b"Error in worker: ValueError: q implies q_len_per_req=5", {"status": 500}),
]
text = client.post(
"/v1/chat/completions",
json={"model": "bagel", "messages": [{"role": "user", "content": "go"}], "stream": True},
).text
events = [json.loads(l[6:]) for l in text.splitlines() if l.startswith("data: ") and "[DONE]" not in l]
assert events[1]["choices"][0]["delta"]["content"] == "Paris"
assert events[-1]["error"] == {
"message": "Error in worker: ValueError: q implies q_len_per_req=5", "type": "server_error", "code": 500,
}
assert not any(e.get("choices", [{}])[0].get("finish_reason") for e in events)
assert text.rstrip().endswith("data: [DONE]")


def test_unsupported_model_404(client_and_stub):
client, stub = client_and_stub
stub.model_name = "pi05"
Expand Down
Loading