feat(chat): stream model thinking over SSE and show it in a collapsible block
This commit is contained in:
@@ -16,7 +16,13 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.rag.llm import EmbeddingDimensionError, EmbeddingError, LLMClient, LLMError
|
||||
from app.rag.llm import (
|
||||
EmbeddingDimensionError,
|
||||
EmbeddingError,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
StreamPiece,
|
||||
)
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
@@ -236,11 +242,21 @@ def test_single_oversized_text_fails_actionably() -> None:
|
||||
# ---------- chat streaming (phase 03) ----------
|
||||
|
||||
|
||||
def _chunk(content: str | None = "text", empty: bool = False):
|
||||
"""One fake ChatCompletionChunk (``choices[].delta.content`` shape)."""
|
||||
def _chunk(
|
||||
content: str | None = "text", empty: bool = False, reasoning: str | None = None
|
||||
):
|
||||
"""One fake ChatCompletionChunk (``choices[].delta`` shape).
|
||||
|
||||
``reasoning_content`` is present on the delta only when *reasoning*
|
||||
is not None — mirroring the real wire, where the field exists only
|
||||
when the model sends it.
|
||||
"""
|
||||
if empty:
|
||||
return SimpleNamespace(choices=[])
|
||||
return SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=content))])
|
||||
delta: SimpleNamespace = SimpleNamespace(content=content)
|
||||
if reasoning is not None:
|
||||
delta.reasoning_content = reasoning
|
||||
return SimpleNamespace(choices=[SimpleNamespace(delta=delta)])
|
||||
|
||||
|
||||
class _FakeChatStream:
|
||||
@@ -284,7 +300,7 @@ def _make_stream_client(
|
||||
return llm, completions
|
||||
|
||||
|
||||
async def _collect(llm: LLMClient, messages: list[dict[str, str]]) -> list[str]:
|
||||
async def _collect(llm: LLMClient, messages: list[dict[str, str]]) -> list[StreamPiece]:
|
||||
return [p async for p in llm.chat_stream(messages)]
|
||||
|
||||
|
||||
@@ -293,7 +309,13 @@ def test_chat_stream_yields_deltas_in_order() -> None:
|
||||
[_chunk("Hey "), _chunk("you've "), _chunk("got this! 🧠")]
|
||||
)
|
||||
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
||||
assert pieces == ["Hey ", "you've ", "got this! 🧠"]
|
||||
# Content-only chunks yield content pieces in wire order.
|
||||
assert [(p.kind, p.text) for p in pieces] == [
|
||||
("content", "Hey "),
|
||||
("content", "you've "),
|
||||
("content", "got this! 🧠"),
|
||||
]
|
||||
assert all(isinstance(p, StreamPiece) for p in pieces)
|
||||
|
||||
|
||||
def test_chat_stream_uses_locked_generation_params() -> None:
|
||||
@@ -322,7 +344,73 @@ def test_chat_stream_max_tokens_comes_from_settings() -> None:
|
||||
|
||||
def test_chat_stream_skips_empty_deltas_and_choiceless_chunks() -> None:
|
||||
llm, _ = _make_stream_client([_chunk("a"), _chunk(empty=True), _chunk(None), _chunk("b")])
|
||||
assert asyncio.run(_collect(llm, [{"role": "user", "content": "q"}])) == ["a", "b"]
|
||||
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
||||
assert [(p.kind, p.text) for p in pieces] == [("content", "a"), ("content", "b")]
|
||||
|
||||
|
||||
def test_chat_stream_maps_reasoning_content_to_thinking_pieces() -> None:
|
||||
"""The verified aipi wire field (``delta.reasoning_content``) maps to
|
||||
``thinking`` pieces; content chunks are untouched by the presence of
|
||||
reasoning elsewhere in the stream."""
|
||||
llm, _ = _make_stream_client(
|
||||
[
|
||||
_chunk("", reasoning="Step 1: parse the question."),
|
||||
_chunk("", reasoning="Step 2: cite the doc."),
|
||||
_chunk("Talos."),
|
||||
]
|
||||
)
|
||||
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
||||
assert [(p.kind, p.text) for p in pieces] == [
|
||||
("thinking", "Step 1: parse the question."),
|
||||
("thinking", "Step 2: cite the doc."),
|
||||
("content", "Talos."),
|
||||
]
|
||||
|
||||
|
||||
def test_chat_stream_falls_back_to_reasoning_field() -> None:
|
||||
"""Future-proofing: a bare ``delta.reasoning`` field (no
|
||||
``reasoning_content``) is picked up by the fallback getattr."""
|
||||
chunk = SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(delta=SimpleNamespace(content="ans", reasoning="why not"))
|
||||
]
|
||||
)
|
||||
llm, _ = _make_stream_client([chunk])
|
||||
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
||||
assert [(p.kind, p.text) for p in pieces] == [
|
||||
("thinking", "why not"),
|
||||
("content", "ans"),
|
||||
]
|
||||
|
||||
|
||||
def test_chat_stream_thinking_yields_before_content_in_chunk() -> None:
|
||||
"""One chunk carrying both fields yields the thinking piece first."""
|
||||
llm, _ = _make_stream_client([_chunk("answer", reasoning="hmm")])
|
||||
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
||||
assert [(p.kind, p.text) for p in pieces] == [
|
||||
("thinking", "hmm"),
|
||||
("content", "answer"),
|
||||
]
|
||||
|
||||
|
||||
def test_chat_stream_interleaved_thinking_and_content_order_preserved() -> None:
|
||||
"""The piece sequence must match the chunk sequence exactly — a late
|
||||
or interleaved thinking chunk is emitted at its wire position."""
|
||||
llm, _ = _make_stream_client(
|
||||
[
|
||||
_chunk("", reasoning="t1"),
|
||||
_chunk("c1"),
|
||||
_chunk("", reasoning="t2"),
|
||||
_chunk("c2"),
|
||||
]
|
||||
)
|
||||
pieces = asyncio.run(_collect(llm, [{"role": "user", "content": "q"}]))
|
||||
assert [(p.kind, p.text) for p in pieces] == [
|
||||
("thinking", "t1"),
|
||||
("content", "c1"),
|
||||
("thinking", "t2"),
|
||||
("content", "c2"),
|
||||
]
|
||||
|
||||
|
||||
def test_chat_stream_wraps_failures_as_llm_error() -> None:
|
||||
|
||||
Reference in New Issue
Block a user