feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down
This commit is contained in:
+28
-5
@@ -20,12 +20,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
from openai import AsyncOpenAI, AsyncStream
|
||||
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
@@ -318,7 +318,7 @@ class LLMClient:
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
||||
"""Stream assistant pieces from the chat model (PLAN A5/A15, phase 17).
|
||||
|
||||
``stream=True`` against the OpenAI-compatible endpoint, yielding
|
||||
@@ -355,6 +355,17 @@ class LLMClient:
|
||||
Any failure (network, HTTP, malformed stream) surfaces as
|
||||
:class:`LLMError` so the API layer can turn it into an SSE
|
||||
``error`` event instead of a hung request.
|
||||
|
||||
Teardown (phase 48, 2026-08-29, ``TODO.md`` L3): once
|
||||
``create()`` succeeded, the endpoint stream's lifetime is
|
||||
explicit — it is closed on **every** exit: normal exhaustion
|
||||
(a quiet no-op on the already-ended SDK stream, so the
|
||||
completed path stays byte-identical), a wrapped mid-stream
|
||||
failure, and consumer abandon (stop/cancel — ``GeneratorExit``;
|
||||
awaiting in the ``finally`` is safe because it does not yield).
|
||||
The SDK's ``close()`` awaits the underlying httpx response's
|
||||
``aclose()``, so the local model stops generating as soon as
|
||||
the SSE consumer goes away.
|
||||
"""
|
||||
kwargs: dict[str, Any] = {
|
||||
# ``{role, content}`` dicts are exactly what the message params
|
||||
@@ -367,8 +378,12 @@ class LLMClient:
|
||||
}
|
||||
if tools is not None:
|
||||
kwargs["tools"] = tools
|
||||
stream: AsyncStream[ChatCompletionChunk] | None = None
|
||||
try:
|
||||
stream = await self._client.chat.completions.create(**kwargs)
|
||||
stream = cast(
|
||||
"AsyncStream[ChatCompletionChunk]",
|
||||
await self._client.chat.completions.create(**kwargs),
|
||||
)
|
||||
calls: dict[int, _ToolCallSlot] = {}
|
||||
emitted = False
|
||||
async for chunk in stream:
|
||||
@@ -417,6 +432,14 @@ class LLMClient:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||
raise LLMError(f"chat stream from {self.settings.llm_base_url} failed: {e}") from e
|
||||
finally:
|
||||
# Phase 48: deterministic teardown — whenever ``create()``
|
||||
# succeeded, close the endpoint's stream on every subsequent
|
||||
# exit (normal exhaustion, wrapped failures, and consumer
|
||||
# abandon). A failure of ``create()`` itself never sets
|
||||
# ``stream``, so it stays the plain wrap above.
|
||||
if stream is not None:
|
||||
await stream.close()
|
||||
|
||||
|
||||
async def check_models(llm: LLMClient) -> None:
|
||||
|
||||
Reference in New Issue
Block a user