Fix API stream lifecycle for tool-backed requests

This commit is contained in:
hanyuanling
2026-05-01 19:42:52 +08:00
committed by Xubin Ren
parent 73840b0af6
commit 1040124ede
3 changed files with 135 additions and 12 deletions
+26 -6
View File
@@ -7,6 +7,7 @@ All requests route to a single persistent API session.
from __future__ import annotations
import asyncio
import contextlib
import json as _json
import time
import uuid
@@ -18,8 +19,12 @@ from loguru import logger
from nanobot.config.paths import get_media_dir
from nanobot.utils.helpers import safe_filename
from nanobot.utils.media_decode import (
FileSizeExceeded as _FileSizeExceeded,
MAX_FILE_SIZE,
)
from nanobot.utils.media_decode import (
FileSizeExceeded as _FileSizeExceeded,
)
from nanobot.utils.media_decode import (
save_base64_data_url as _save_base64_data_url,
)
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
@@ -240,18 +245,25 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
queue: asyncio.Queue[str | None] = asyncio.Queue()
stream_failed = False
emitted_content = False
async def _on_stream(token: str) -> None:
nonlocal emitted_content
if token:
emitted_content = True
await queue.put(token)
async def _on_stream_end(*_a: Any, **_kw: Any) -> None:
await queue.put(None)
# Agent stream-end callbacks mark generation segment boundaries.
# Tool-backed requests may continue after a segment ends, so the
# HTTP SSE stream is closed only when process_direct returns.
return None
async def _run() -> None:
nonlocal stream_failed
try:
async with session_lock:
await asyncio.wait_for(
response = await asyncio.wait_for(
agent_loop.process_direct(
content=text,
media=media_paths if media_paths else None,
@@ -263,9 +275,14 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
),
timeout=timeout_s,
)
if not emitted_content:
response_text = _response_text(response)
if response_text.strip():
await queue.put(response_text)
except Exception:
stream_failed = True
logger.exception("Streaming error for session {}", session_key)
finally:
await queue.put(None)
task = asyncio.create_task(_run())
@@ -276,7 +293,10 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
break
await resp.write(_sse_chunk(token, model_name, chunk_id))
finally:
task.cancel()
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
if not stream_failed:
await resp.write(_sse_chunk("", model_name, chunk_id, finish_reason="stop"))
@@ -284,7 +304,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
return resp
# -- non-streaming path (original logic) --
_FALLBACK = EMPTY_FINAL_RESPONSE_MESSAGE
fallback = EMPTY_FINAL_RESPONSE_MESSAGE
try:
async with session_lock:
@@ -316,7 +336,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
response_text = _response_text(retry_response)
if not response_text or not response_text.strip():
logger.warning("Empty response after retry, using fallback")
response_text = _FALLBACK
response_text = fallback
except asyncio.TimeoutError:
return _error_json(504, f"Request timed out after {timeout_s}s")