feat(chat): stream model thinking over SSE and show it in a collapsible block

This commit is contained in:
2026-08-24 09:52:27 -04:00
parent cbc263a4b2
commit b16deb2b1d
18 changed files with 1045 additions and 63 deletions
+54 -14
View File
@@ -1,7 +1,12 @@
"""Async OpenAI-compatible client for the self-hosted aipi endpoint (PLAN A5).
Provides the embeddings surface (importer, retrieval) and chat streaming
(PLAN A15) for the RAG pipeline.
(PLAN A15) for the RAG pipeline. Chat streaming yields typed
:class:`StreamPiece` values (phase 17): aipi's ``turbo`` model streams
its reasoning as ``delta.reasoning_content`` chunks (deepseek/litellm
wire convention, verified live 2026-08-23) **before** the answer's
``delta.content`` chunks, and reasoning counts against ``max_tokens``
(an answer can in principle be empty).
Fail-loud rule (PLAN A6): the ``chunks.embedding`` column is fixed at 768
dimensions when the table is created, so a model that returns a different
@@ -12,7 +17,8 @@ from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import cast
from dataclasses import dataclass
from typing import Literal, cast
from openai import AsyncOpenAI
from openai.types.chat import ChatCompletionMessageParam
@@ -34,6 +40,19 @@ class LLMError(RuntimeError):
"""The chat-completions endpoint failed (network, HTTP, or mid-stream)."""
@dataclass(frozen=True)
class StreamPiece:
"""One piece of a streamed chat turn (phase 17, PLAN §4 extension).
``kind`` is ``"content"`` for answer text (an SSE ``delta`` frame)
or ``"thinking"`` for the model's reasoning (an SSE ``thinking``
frame). Frozen: pieces are immutable wire values, not accumulators.
"""
kind: Literal["content", "thinking"]
text: str
# aipi's local embedding model rejects requests over ~1024 input tokens
# ("input is too large to process"). Batch by estimated tokens, with a
# safety margin under that cap — code-dense text can tokenize at ~3
@@ -168,17 +187,30 @@ class LLMClient:
(vec,) = await self.embed([text])
return vec
async def chat_stream(self, messages: list[dict[str, str]]) -> AsyncIterator[str]:
"""Stream assistant text deltas from the chat model (PLAN A5/A15).
async def chat_stream(
self, messages: list[dict[str, str]]
) -> AsyncIterator[StreamPiece]:
"""Stream assistant pieces from the chat model (PLAN A5/A15, phase 17).
``stream=True`` against the OpenAI-compatible endpoint; yields only
non-empty ``delta.content`` pieces. 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.
``stream=True`` against the OpenAI-compatible endpoint, yielding
typed :class:`StreamPiece` values. Wire convention (verified live
against aipi's ``turbo`` on 2026-08-23): the model's reasoning
arrives as ``delta.reasoning_content`` chunks (deepseek/litellm
convention) **before** the first ``delta.content`` chunk, so in
practice thinking pieces precede content pieces. The ``openai``
SDK keeps unknown delta fields in ``model_extra``, so ``getattr``
is the right accessor — no raw-HTTP parsing is needed. A chunk
carrying both fields yields the thinking piece **first**.
Answers are allowed up to ``BOR_MAX_OUTPUT_TOKENS`` (default 32 768)
output tokens — the old hard 700-token cap cut long answers off
mid-sentence (owner report 2026-08-22).
Reasoning counts against ``max_tokens``: an answer can in principle
be empty (thinking with no content) — the UI handles that.
Answers are allowed up to ``BOR_MAX_OUTPUT_TOKENS`` (default
32 768) output tokens — the old hard 700-token cap cut long
answers off mid-sentence (owner report 2026-08-22).
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.
"""
try:
# ``{role, content}`` dicts are exactly what the message params
@@ -193,9 +225,17 @@ class LLMClient:
async for chunk in stream:
if not chunk.choices:
continue
piece = chunk.choices[0].delta.content
if piece:
yield piece
delta = chunk.choices[0].delta
reasoning = getattr(delta, "reasoning_content", None)
if not reasoning:
# Future-proofing: the same wire convention under a
# shorter field name.
reasoning = getattr(delta, "reasoning", None)
if reasoning:
yield StreamPiece("thinking", reasoning)
content = delta.content
if content:
yield StreamPiece("content", content)
except LLMError:
raise
except Exception as e: # noqa: BLE001 — wrap transport-level failures