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:
2026-08-29 17:27:04 -04:00
parent 6bf7f456d4
commit 1a60ecbd8b
186 changed files with 1738 additions and 7796 deletions
+28 -5
View File
@@ -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: