Harden OpenAI chat completion streams

This commit is contained in:
wasimysaid 2026-06-19 19:03:09 +02:00
commit bd1e5eabe5
3 changed files with 164 additions and 22 deletions

View file

@ -4748,7 +4748,7 @@ async def openai_chat_completions(
finally:
_tracker.__exit__(None, None, None)
return StreamingResponse(
return _SameTaskStreamingResponse(
audio_input_stream(),
media_type = "text/event-stream",
headers = {
@ -5239,7 +5239,7 @@ async def openai_chat_completions(
pass
_tracker.__exit__(None, None, None)
return StreamingResponse(
return _SameTaskStreamingResponse(
gguf_tool_stream(),
media_type = "text/event-stream",
headers = {
@ -5393,7 +5393,7 @@ async def openai_chat_completions(
finally:
_tracker.__exit__(None, None, None)
return StreamingResponse(
return _SameTaskStreamingResponse(
gguf_stream_chunks(),
media_type = "text/event-stream",
headers = {
@ -5842,7 +5842,7 @@ async def openai_chat_completions(
_sf_tracker.__exit__(None, None, None)
if payload.stream:
return StreamingResponse(
return _SameTaskStreamingResponse(
sf_tool_stream(),
media_type = "text/event-stream",
headers = {
@ -6062,7 +6062,7 @@ async def openai_chat_completions(
finally:
_tracker.__exit__(None, None, None)
return StreamingResponse(
return _SameTaskStreamingResponse(
stream_chunks(),
media_type = "text/event-stream",
headers = {
@ -9547,7 +9547,7 @@ async def _openai_passthrough_stream(
except Exception:
pass
_tracker.__exit__(None, None, None)
return StreamingResponse(
return _SameTaskStreamingResponse(
iter(()),
media_type = "text/event-stream",
headers = {
@ -9598,6 +9598,26 @@ async def _openai_passthrough_stream(
_await_disconnect_then_close(request, resp, cancel_event)
)
monitor_done = False
saw_finish_reason = False
saw_done = False
last_chunk_id = completion_id
last_chunk_model = model_name
last_chunk_created = int(time.time())
def _synthetic_finish_line() -> str:
chunk = ChatCompletionChunk(
id = last_chunk_id,
created = last_chunk_created,
model = last_chunk_model,
choices = [
ChunkChoice(
delta = ChoiceDelta(),
finish_reason = "stop",
)
],
)
return f"data: {chunk.model_dump_json(exclude_none = True)}"
try:
lines_iter = resp.aiter_lines()
async for raw_line in _aiter_llama_stream_items(
@ -9611,6 +9631,37 @@ async def _openai_passthrough_stream(
continue
if not raw_line.startswith("data: "):
continue
data_text = raw_line[6:].strip()
if data_text == "[DONE]":
saw_done = True
if not saw_finish_reason and not cancel_event.is_set():
finish_line = _synthetic_finish_line()
_monitor_openai_sse_line(
monitor_id,
finish_line,
llama_backend.context_length,
)
yield finish_line + "\n\n"
saw_finish_reason = True
yield raw_line + "\n\n"
monitor_done = True
break
try:
chunk_data = json.loads(data_text)
except json.JSONDecodeError:
chunk_data = None
if isinstance(chunk_data, dict):
if isinstance(chunk_data.get("id"), str):
last_chunk_id = chunk_data["id"]
if isinstance(chunk_data.get("model"), str):
last_chunk_model = chunk_data["model"]
if isinstance(chunk_data.get("created"), int):
last_chunk_created = chunk_data["created"]
choices = chunk_data.get("choices")
if isinstance(choices, list) and choices:
choice = choices[0]
if isinstance(choice, dict) and choice.get("finish_reason"):
saw_finish_reason = True
# Honor parallel_tool_calls=false (best-effort): drop tool_call
# deltas with index>=1 so only the first call streams. Only
# lines carrying tool_calls are reparsed; everything else is
@ -9625,9 +9676,19 @@ async def _openai_passthrough_stream(
# Relay verbatim to preserve llama-server's native id,
# finish_reason, delta.tool_calls, and usage chunks.
yield raw_line + "\n\n"
if monitor_event == "done" or raw_line[6:].strip() == "[DONE]":
if monitor_event == "done":
monitor_done = True
break
if not saw_done and not saw_finish_reason and not cancel_event.is_set():
finish_line = _synthetic_finish_line()
_monitor_openai_sse_line(
monitor_id,
finish_line,
llama_backend.context_length,
)
yield finish_line + "\n\n"
yield "data: [DONE]\n\n"
monitor_done = True
if not monitor_done:
api_monitor.finish(
monitor_id,
@ -9680,7 +9741,7 @@ async def _openai_passthrough_stream(
pass
_tracker.__exit__(None, None, None)
return StreamingResponse(
return _SameTaskStreamingResponse(
_stream(),
media_type = "text/event-stream",
headers = {

View file

@ -48,6 +48,7 @@ from routes.inference import (
_openai_passthrough_stream,
_openai_stream_usage_chunk,
_proxy_to_external_provider,
_SameTaskStreamingResponse,
_set_or_prepend_system_message,
openai_completions,
openai_embeddings,
@ -2239,6 +2240,7 @@ class TestApiMonitorProviderAndCompletionStreams:
"chatcmpl-test",
monitor_id = monitor_id,
)
assert isinstance(response, _SameTaskStreamingResponse)
iterator = response.body_iterator
first = await anext(iterator)
assert "hello" in first
@ -2256,6 +2258,67 @@ class TestApiMonitorProviderAndCompletionStreams:
asyncio.run(_run())
def test_passthrough_stream_synthesizes_missing_finish_reason(self, monkeypatch):
async def _run():
import routes.inference as inf_mod
class Request:
async def is_disconnected(self):
return False
async def fake_send(*_args, **_kwargs):
return httpx.Response(200, content = b"")
async def fake_items(*_args, **_kwargs):
yield 'data: {"id":"upstream","created":123,"model":"gguf","choices":[{"index":0,"delta":{"content":"hello"}}]}'
yield "data: [DONE]"
monitor = ApiMonitor(max_entries = 3)
monkeypatch.setattr(inf_mod, "api_monitor", monitor)
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
monitor_id = monitor.start(
endpoint = "/v1/chat/completions",
method = "POST",
model = "gguf",
prompt = "hi",
)
payload = ChatCompletionRequest(
model = "default",
messages = [ChatMessage(role = "user", content = "hi")],
stream = True,
tools = [
{
"type": "function",
"function": {
"name": "lookup",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
response = await _openai_passthrough_stream(
Request(),
threading.Event(),
SimpleNamespace(
base_url = "http://llama.test",
context_length = 4096,
_request_reasoning_kwargs = lambda *_args, **_kwargs: None,
),
payload,
"gguf",
"chatcmpl-test",
monitor_id = monitor_id,
)
chunks = [chunk async for chunk in response.body_iterator]
body = "".join(chunks)
assert '"finish_reason":"stop"' in body.replace(" ", "")
assert "data: [DONE]" in body
asyncio.run(_run())
def test_passthrough_non_streaming_cancel_finalizes_monitor(self, monkeypatch):
async def _run():
import routes.inference as inf_mod

View file

@ -146,24 +146,42 @@ def test_async_generators_cleanup_tracker_in_finally():
)
def test_streaming_responses_have_no_background_task():
top = None
for n in ast.walk(_TREE):
if isinstance(n, ast.AsyncFunctionDef) and n.name == "openai_chat_completions":
top = n
break
assert top is not None
def test_chat_completions_streams_avoid_starlette_task_group():
top = _async_function("openai_chat_completions")
legacy_calls = []
same_task_calls = 0
for sub in ast.walk(top):
if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name)):
continue
if sub.func.id != "StreamingResponse":
if sub.func.id == "StreamingResponse":
legacy_calls.append(sub.lineno)
if sub.func.id == "_SameTaskStreamingResponse":
same_task_calls += 1
assert not legacy_calls, (
"Streaming /v1/chat/completions must use _SameTaskStreamingResponse, "
"not Starlette's legacy task-group StreamingResponse. Lines: "
f"{legacy_calls}"
)
assert same_task_calls >= 5
def test_openai_passthrough_stream_avoids_starlette_task_group():
top = _async_function("_openai_passthrough_stream")
legacy_calls = []
same_task_calls = 0
for sub in ast.walk(top):
if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name)):
continue
kwargs = {kw.arg for kw in sub.keywords if kw.arg}
assert "background" not in kwargs, (
"StreamingResponse in openai_chat_completions must not pass "
"`background=` -- cleanup now lives in the generator's finally "
"block; a BackgroundTask would be skipped on abrupt disconnect"
)
if sub.func.id == "StreamingResponse":
legacy_calls.append(sub.lineno)
if sub.func.id == "_SameTaskStreamingResponse":
same_task_calls += 1
assert not legacy_calls, (
"OpenAI passthrough streams must use _SameTaskStreamingResponse, "
"not Starlette's legacy task-group StreamingResponse. Lines: "
f"{legacy_calls}"
)
assert same_task_calls >= 2
def test_direct_llama_server_streams_install_disconnect_watcher():