Finalize live-streaming feature: docs and tests

- docs/live_streaming.md: feature description, perf, limitations
- 183 tests passing (was 157; added 26+ for streaming + live UI)
- All previous regressions fixed

Owner-action: completed final-integration myself after tester session
got stuck on the e2e attempt (likely trying to spawn a real Gradio on
an already-busy port). Manual verification: 183 passed, 1 skipped,
0 failed; feature works end-to-end via Gradio UI on 127.0.0.1:8788.
This commit is contained in:
Mavis
2026-06-13 16:40:19 +03:00
parent 17a114e015
commit 36d9906e1f
5 changed files with 1431 additions and 23 deletions
+357
View File
@@ -18,6 +18,7 @@ Manual first-click on bad input would surface a TypeError."
from __future__ import annotations
import sys
import time
from pathlib import Path
from typing import Any
from unittest.mock import patch
@@ -246,3 +247,359 @@ def test_on_mode_change_returns_illustration_default_n():
update = on_mode_change("illustration")
assert update["value"] == 1
# ---------------------------------------------------------------------------
# Live-режим (on_generate_live)
#
# Пять обязательных тестов из ТЗ live-ui:
# 1. yields ≥ 2 промежуточных превью при 5 дельтах
# 2. финальный yield содержит status "Сгенерировано" и валидный SVG
# 3. throttle режет быстрые дельты (10 за 50мс → ≤ 4-5 yield'ов)
# 4. после end event в SQLite появляется запись (status=ok/partial)
# 5. при LMStudioUnavailable вызывается gr.Error
#
# Плюс sanity-тест: on_generate_live — генератор-функция.
# ---------------------------------------------------------------------------
# Реалистичный поток дельт, который последовательно строит валидный SVG.
# Используется в тестах 1, 2, 4 — здесь важно, чтобы parse_to_valid давал
# рендерабельный SVG на большинстве промежуточных стадий, а не только в конце.
_VALID_SVG_DELTAS = [
"<svg ",
'xmlns="http://www.w3.org/2000/svg" ',
'viewBox="0 0 64 64">',
'<rect x="10" y="10" width="44" height="44" fill="red"/>',
"</svg>",
]
def _make_stream_mock(deltas, *, sleep_s: float = 0.20):
"""Возвращает мок-функцию stream_chat, отдающую дельты + 'end'.
Args:
deltas: список строк-дельта.
sleep_s: пауза между дельтами (default 200мс) — чтобы throttle
не скипнул промежуточные yield'ы в "нормальном" сценарии.
"""
from lm_client import StreamEvent
def mock_stream_chat(*args, **kwargs):
for d in deltas:
if sleep_s > 0:
time.sleep(sleep_s)
yield StreamEvent(type="delta", content=d)
yield StreamEvent(type="end", model="test-model", finish_reason="stop")
return mock_stream_chat
def _call_live(**overrides):
"""Дёргает on_generate_live с разумными дефолтами + overrides.
Возвращает список yields (т.е. материализованный генератор).
"""
from app import on_generate_live
defaults: dict[str, Any] = dict(
prompt="filled magnifying glass",
mode="icon",
n_candidates=1,
temperature=0.4,
image=None,
palette="",
model="test-model",
base_url="http://127.0.0.1:1234/v1",
api_key="lm-studio",
use_live=True,
)
defaults.update(overrides)
gen = on_generate_live(**defaults)
return list(gen)
def _preview_yields(yields):
"""Возвращает только те yield'ы, в которых gallery содержит превью."""
out = []
for y in yields:
# y = (gallery, gallery_state, svg, history_df, status, paths, error)
gallery = y[0]
if gallery and isinstance(gallery, list) and len(gallery) > 0:
first = gallery[0]
if isinstance(first, tuple) and len(first) >= 1 and first[0]:
out.append(y)
return out
# --- Sanity: on_generate_live — генератор-функция ---------------------------
def test_on_generate_live_is_generator_function():
"""on_generate_live должна быть генератор-функцией (содержит yield)."""
import inspect
from app import on_generate_live
assert inspect.isgeneratorfunction(on_generate_live), (
"on_generate_live должна быть генератор-функцией "
"(содержать yield) для Gradio streaming pattern"
)
# --- Тест 1: ≥ 2 промежуточных yield'а при 5 дельтах ------------------------
def test_live_mode_yields_intermediate_previews(tmp_path, monkeypatch):
"""Мок stream_chat отдаёт 5 дельт; on_generate_live делает ≥ 2 yield'а
с обновлениями Gallery (промежуточные превью)."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
mock = _make_stream_mock(_VALID_SVG_DELTAS, sleep_s=0.20)
with patch("app.stream_chat", side_effect=mock):
yields = _call_live()
previews = _preview_yields(yields)
assert len(previews) >= 2, (
f"ожидалось ≥ 2 промежуточных preview-yield'а, получено {len(previews)}; "
f"всего yields={len(yields)}"
)
# Sanity: у промежуточных yield'ов в gallery действительно лежит файл
# (path), не просто заглушка.
for y in previews[:-1]: # все, кроме последнего (финального)
gallery = y[0]
path_str = gallery[0][0]
assert path_str.endswith(".png"), f"ожидался .png путь, получено {path_str!r}"
# --- Тест 2: финальный yield содержит "Сгенерировано" и валидный SVG --------
def test_live_mode_final_yield_after_end(tmp_path, monkeypatch):
"""Последний yield содержит status "Сгенерировано" и валидный SVG."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
mock = _make_stream_mock(_VALID_SVG_DELTAS, sleep_s=0.20)
with patch("app.stream_chat", side_effect=mock):
yields = _call_live()
assert len(yields) >= 1
final = yields[-1]
# (gallery, gallery_state, svg, history_df, status_md, paths, error)
status_md = final[4]
final_svg = final[2]
assert "Сгенерировано" in status_md, (
f"ожидалось 'Сгенерировано' в status_md, получено {status_md!r}"
)
# Валидный SVG: парсится lxml'ом, начинается с <svg, содержит </svg>.
assert final_svg, "финальный SVG не должен быть пустым"
assert "<svg" in final_svg
assert "</svg>" in final_svg
from lxml import etree
etree.fromstring(final_svg.encode("utf-8")) # должно парситься без ошибок
# --- Тест 3: throttle режет быстрые дельты --------------------------------
def test_live_mode_throttle_skips_rapid_updates(tmp_path, monkeypatch):
"""10 дельт подряд → preview-yield'ов строго меньше, чем 10.
Чтобы изолировать throttle от скорости рендера (resvg-py + диск),
мокаем render_png на мгновенный возврат фиктивных PNG-байт.
Без throttle мы получили бы 10 preview-yield'ов; с throttle=0.15s
на быстром стриме (10 дельт за <50мс) — максимум 1-2 preview-yield'а.
"""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
# Мок render_png: возвращает фейковые PNG-байты мгновенно, чтобы
# тест измерял ТОЛЬКО throttle, а не скорость resvg/диска.
fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 # фейк-PNG заголовок
with patch("app.render_png", return_value=fake_png):
fast_deltas = [
"<svg ", # deltas 1
'xmlns="http://www.w3.org/2000/svg" ',
'viewBox="0 0 64 64">',
'<rect x="10" y="10" width="44" height="44" fill="red"/>',
'<circle cx="32" cy="32" r="5" fill="blue"/>',
'<line x1="0" y1="0" x2="64" y2="64" stroke="green"/>',
'<text x="32" y="32">A</text>',
'<ellipse cx="20" cy="20" rx="5" ry="3" fill="purple"/>',
'<polygon points="50,10 60,30 40,30" fill="orange"/>', # deltas 10
"</svg>",
]
mock = _make_stream_mock(fast_deltas, sleep_s=0.0)
started = time.monotonic()
with patch("app.stream_chat", side_effect=mock):
yields = _call_live()
elapsed_ms = (time.monotonic() - started) * 1000
previews = _preview_yields(yields)
# 10 дельт без throttle = 10 preview-yield'ов (плюс 1 начальный "поехали"
# без gallery). С throttle=0.15s и мгновенным render_png: ≤ 1-2 yields
# (первая дельта даёт, остальные скипнуты т.к. < 150мс).
# Ставим жёсткий потолок 5, чтобы тест не флакал.
assert len(previews) <= 5, (
f"throttle не сработал: {len(previews)} preview-yield'ов за {elapsed_ms:.0f}мс; "
f"предел 5"
)
# Sanity: если throttle был ВООБЩЕ выключен, было бы ~10. Проверяем,
# что превью-yield'ов сильно меньше количества дельт (= 10).
assert len(previews) < len(fast_deltas), (
f"throttle не режет: {len(previews)} превью на {len(fast_deltas)} дельт"
)
# --- Тест 4: после end event в SQLite появилась запись (ok/partial) ---------
def test_live_mode_records_in_history(tmp_path, monkeypatch):
"""После end event в SQLite появилась запись со status=ok/partial."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
mock = _make_stream_mock(_VALID_SVG_DELTAS, sleep_s=0.20)
with patch("app.stream_chat", side_effect=mock):
yields = _call_live()
# Генерируем запись. Проверяем, что в SQLite есть новая строка.
from history import History
with History() as h:
records = h.list_recent(limit=5)
assert len(records) >= 1, "в History() нет ни одной записи после live-стрима"
last = records[0] # list_recent сортирует DESC, новая запись — первая
assert last["status"] in ("ok", "partial"), (
f"ожидался status ok/partial, получено {last['status']!r}"
)
assert last["prompt"] == "filled magnifying glass"
assert last["mode"] == "icon"
assert last["n_requested"] == 1
# raw_outputs должен содержать полный склеенный текст стрима.
assert last["raw_outputs"], "raw_outputs пуст"
assert "".join(last["raw_outputs"]).startswith("<svg ")
# --- Тест 5: LMStudioUnavailable → gr.Error ---------------------------------
def test_live_mode_handles_stream_error(tmp_path, monkeypatch):
"""Если stream_chat бросает LMStudioUnavailable, callback вызывает gr.Error
и возвращает пустой результат."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
from lm_client import LMStudioUnavailable
def boom(*args, **kwargs):
raise LMStudioUnavailable("test: connection refused")
yield # generator-never-yield, помечает boom как генератор
with patch("app.stream_chat", side_effect=boom), \
patch("app.gr.Error") as mock_error:
yields = _call_live()
# Должен быть вызван gr.Error (без raise).
assert mock_error.called, "gr.Error не был вызван при LMStudioUnavailable"
# Должен быть ≥ 1 yield. Реально 2: первый — "Live-стрим запущен…",
# второй — _empty_result после except. Главное — последний yield
# содержит пустые плейсхолдеры (7 элементов).
assert len(yields) >= 1
last = yields[-1]
assert isinstance(last, tuple) and len(last) == 7
# Финальный gallery / status пустые.
assert last[0] == [] or last[0] is None or last[0] == ()
# И в SQLite записался failed-кейс (для аудита попыток).
from history import History
with History() as h:
records = h.list_recent(limit=5)
assert len(records) >= 1
failed = records[0]
assert failed["status"] == "failed"
assert "connection refused" in (failed["error_reason"] or "")
# --- Бонус: use_live=False — fallback на синхронный on_generate ------------
def test_live_mode_false_falls_back_to_sync(tmp_path, monkeypatch):
"""Если use_live=False, генератор делает один yield с результатом on_generate."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
# Стрим вообще не должен вызываться.
with patch("app.stream_chat") as mock_stream, \
patch("app.chat") as mock_chat:
# chat() возвращает фиктивный результат с 1 валидным SVG.
from dataclasses import dataclass
@dataclass
class FakeResult:
raw_texts: list[str]
elapsed_s: float = 0.1
model: str = "test"
usage: dict | None = None
finish_reasons: list[str] = None
def _fr():
return ["stop"]
mock_chat.return_value = FakeResult(
raw_texts=[
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
'<rect x="10" y="10" width="44" height="44" fill="red"/></svg>'
],
finish_reasons=_fr(),
)
yields = _call_live(use_live=False)
assert mock_stream.call_count == 0, (
"stream_chat был вызван при use_live=False — не должен"
)
# Один yield с финальным результатом.
assert len(yields) == 1
result = yields[0]
assert isinstance(result, tuple) and len(result) == 7
# --- Бонус: pre-check fail в live-режиме ----------------------------------
def test_live_mode_precheck_fail_does_not_call_stream(tmp_path, monkeypatch):
"""Если pre-check падает (пустой промпт), stream_chat НЕ вызывается."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
with patch("app.stream_chat") as mock_stream:
yields = _call_live(prompt="")
assert mock_stream.call_count == 0
assert len(yields) == 1
assert len(yields[0]) == 7 # _empty_result
# --- Бонус: build_ui содержит Live-стрим checkbox -------------------------
def test_build_ui_has_live_checkbox():
"""В build_ui() должен быть gr.Checkbox с label про Live-стрим."""
from app import build_ui
demo = build_ui()
# Спускаемся по дереву компонентов в поисках Checkbox с нужным label.
# В Gradio 5.37 components живут в blocks.blocks.values().
found = False
label_seen: str = ""
for comp in demo.blocks.values():
if getattr(comp, "type", "") == "checkbox" or comp.__class__.__name__ == "Checkbox":
label = getattr(comp, "label", "") or ""
label_seen = label
if "Live" in label or "стрим" in label.lower() or "live" in label.lower():
found = True
break
assert found, (
f"не нашли gr.Checkbox с label 'Live'/'стрим' среди {len(demo.blocks)} "
f"компонентов; последний увиденный label={label_seen!r}"
)
+458
View File
@@ -0,0 +1,458 @@
"""Юнит-тесты для `stream_chat()` и `StreamEvent` в lm_client.py.
Покрывают:
- Mock SSE-ответ: 3 data-чанка + [DONE] → 3 delta-события + 1 end
- HTTP 500: stream_chat() бросает LMStudioUnavailable
- HTTP 4xx: stream_chat() бросает LMStudioUnavailable
- Timeout: stream_chat() бросает LMStudioUnavailable (с упоминанием timeout)
- Пустой стрим (только [DONE]): 0 delta'ов, 1 end
- n>1 в стриме: WARNING в логах, payload уходит с n=1
- n=0 валидация
- Модель/usage/finish_reason приходят в end из последнего чанка
- Skip не-SSE строк (комментариев/heartbeat)
- Битый JSON в SSE → LMStudioUnavailable
- reasoning-токены из content НЕ отделяются (проходят как обычный delta)
httpx мокается через unittest.mock: подменяем `httpx.Client`,
а у экземпляра — `.stream(...)` (это context manager, отдаёт response
с iter_lines).
Запуск: `python -m pytest tests/test_lm_streaming.py -v`
"""
from __future__ import annotations
import logging
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
import httpx # noqa: E402
from lm_client import ( # noqa: E402
LMStudioUnavailable,
StreamEvent,
stream_chat,
)
# ---------------------------------------------------------------------------
# Хелперы для построения mock-стрима
# ---------------------------------------------------------------------------
def _sse_chunk(*deltas: str, model: str = "qwen3.5", finish_reason: str | None = None) -> list[dict]:
"""Собирает фиктивный JSON-чанк в стиле OpenAI-SSE.
Каждый delta — это content, который LM Studio кладёт в
`choices[0].delta.content`. Если задан finish_reason, он попадает
в этот же чанк (как в финальном чанке OpenAI-стрима).
"""
choice: dict[str, Any] = {"index": 0, "delta": {"role": "assistant"}}
if deltas:
choice["delta"]["content"] = "".join(deltas) if len(deltas) > 1 else deltas[0]
if finish_reason:
choice["finish_reason"] = finish_reason
choice["delta"]["content"] = "".join(deltas) if deltas else ""
return [{"id": "cmpl-x", "object": "chat.completion.chunk", "model": model, "choices": [choice]}]
def _make_stream_response(
*,
sse_lines: list[str] | None = None,
sse_iter: Iterator[str] | None = None,
status_code: int = 200,
error_body: str = "",
) -> MagicMock:
"""Создаёт mock-объект, имитирующий httpx.Response внутри `client.stream(...)`.
Args:
sse_lines: фиксированный список строк (для простых случаев) — имитация
тела SSE. Каждая строка — это ровно одна строка из `iter_lines()`.
sse_iter: кастомный итератор (если хотим имитировать ошибку посреди стрима).
status_code: HTTP status.
error_body: тело для случая status_code >= 400 (читается через .read()).
"""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
if sse_iter is not None:
resp.iter_lines.return_value = sse_iter
else:
resp.iter_lines.return_value = sse_lines or []
if status_code >= 400:
# resp.read() вызывается в stream_chat для превью ошибки.
resp.read.return_value = error_body.encode("utf-8")
else:
resp.read.return_value = b""
return resp
@contextmanager
def _patched_stream(response: MagicMock | None = None, side_effect: Exception | None = None):
"""Патчит `httpx.Client` так, что `client.stream(...)` отдаёт заданный response.
Args:
response: mock-Response (то, что отдаёт __enter__ контекст-менеджера).
side_effect: если задан — `client.stream(...)` бросает это исключение
ДО входа в context (имитация timeout на этапе open).
"""
with patch("lm_client.httpx.Client") as MockClient:
mock_inst = MagicMock()
MockClient.return_value.__enter__.return_value = mock_inst
if side_effect is not None:
mock_inst.stream.side_effect = side_effect
else:
# .stream(...) возвращает context manager, у которого __enter__
# возвращает наш response.
cm = MagicMock()
cm.__enter__.return_value = response
cm.__exit__.return_value = False
mock_inst.stream.return_value = cm
yield mock_inst
# ---------------------------------------------------------------------------
# Успешный стрим: 3 чанка + [DONE]
# ---------------------------------------------------------------------------
def test_stream_chat_parses_three_sse_chunks():
"""SSE из 3 чанков + [DONE] → 3 StreamEvent(type='delta') + 1 end."""
sse_lines = [
'data: {"id":"1","choices":[{"index":0,"delta":{"content":"<"}}]}',
"",
'data: {"id":"2","choices":[{"index":0,"delta":{"content":"svg "}}]}',
"",
'data: {"id":"3","choices":[{"index":0,"delta":{"content":"xmlns=..."}}]}',
"",
"data: [DONE]",
"",
]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp) as mock_inst:
events = list(stream_chat(
messages=[{"role": "user", "content": "hi"}],
base_url="http://m:1/v1",
))
# 3 delta + 1 end = 4 события.
assert len(events) == 4
assert all(e.type == "delta" for e in events[:3])
assert events[3].type == "end"
# Содержимое каждого delta.
assert events[0].content == "<"
assert events[1].content == "svg "
assert events[2].content == "xmlns=..."
# Конкатенация = полный текст.
full = "".join(e.content for e in events if e.type == "delta")
assert full == "<svg xmlns=..."
# End-event: пустой content, но type='end'.
assert events[3].content == ""
assert events[3].finish_reason == ""
def test_stream_chat_payload_uses_stream_true_and_n_one():
"""Payload должен содержать stream=True и n=1."""
sse_lines = ['data: {"choices":[{"delta":{"content":"x"}}]}', "", "data: [DONE]", ""]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp) as mock_inst:
list(stream_chat(
messages=[{"role": "user", "content": "hi"}],
n=1,
temperature=0.7,
max_tokens=1024,
base_url="http://m:1/v1",
))
call = mock_inst.stream.call_args
body = call.kwargs["json"]
assert body["stream"] is True
assert body["n"] == 1
assert body["temperature"] == 0.7
assert body["max_tokens"] == 1024
assert body["model"] # non-empty
# URL сформирован правильно.
method, url = call.args[0], call.args[1]
assert method == "POST"
assert url == "http://m:1/v1/chat/completions"
headers = call.kwargs["headers"]
assert headers["Authorization"] == "Bearer lm-studio"
assert headers["Content-Type"] == "application/json"
def test_stream_chat_collects_model_and_usage_in_end_event():
"""Последний чанк содержит model/usage/finish_reason — это попадает в end."""
sse_lines = [
'data: {"model":"qwen3.5","choices":[{"delta":{"content":"Hel"}}]}',
"",
'data: {"model":"qwen3.5","choices":[{"delta":{"content":"lo"}}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}',
"",
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
"",
"data: [DONE]",
"",
]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp):
events = list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
assert len(events) == 3 # 2 delta + 1 end
end = events[-1]
assert end.type == "end"
assert end.model == "qwen3.5"
assert end.usage == {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}
assert end.finish_reason == "stop"
def test_stream_chat_handles_empty_stream_only_done():
"""Стрим сразу [DONE] → 0 delta'ов, 1 end."""
sse_lines = ["data: [DONE]", ""]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp):
events = list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
assert len(events) == 1
assert events[0].type == "end"
assert events[0].content == ""
# ---------------------------------------------------------------------------
# Ошибки HTTP / network / timeout
# ---------------------------------------------------------------------------
def test_stream_chat_500_raises_lmstudio_unavailable():
"""HTTP 500 → LMStudioUnavailable, в сообщении есть 500 и body preview."""
resp = _make_stream_response(status_code=500, error_body="internal error")
with _patched_stream(response=resp):
with pytest.raises(LMStudioUnavailable) as excinfo:
list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
msg = str(excinfo.value)
assert "500" in msg
assert "internal error" in msg
def test_stream_chat_4xx_raises_lmstudio_unavailable():
"""HTTP 401 → LMStudioUnavailable (4xx — тоже клиентская ошибка)."""
resp = _make_stream_response(status_code=401, error_body="unauthorized")
with _patched_stream(response=resp):
with pytest.raises(LMStudioUnavailable) as excinfo:
list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
msg = str(excinfo.value)
assert "401" in msg
assert "unauthorized" in msg
def test_stream_chat_timeout_raises_lmstudio_unavailable():
"""httpx.TimeoutException на open стрима → LMStudioUnavailable с 'timeout'."""
with _patched_stream(side_effect=httpx.TimeoutException("stream timed out")):
with pytest.raises(LMStudioUnavailable) as excinfo:
list(stream_chat(
messages=[{"role": "user", "content": "x"}],
base_url="http://m:1/v1",
timeout_s=5.0,
))
msg = str(excinfo.value).lower()
assert "timeout" in msg
def test_stream_chat_network_error_raises_lmstudio_unavailable():
"""Любой httpx.HTTPError → LMStudioUnavailable."""
with _patched_stream(side_effect=httpx.ConnectError("connection refused")):
with pytest.raises(LMStudioUnavailable) as excinfo:
list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
msg = str(excinfo.value).lower()
assert "недоступен" in msg or "unavailable" in msg
assert "connection refused" in msg
def test_stream_chat_broken_sse_json_raises_lmstudio_unavailable():
"""Битый JSON в SSE-чанке → LMStudioUnavailable."""
sse_lines = [
'data: {"choices":[{"delta":{"content":"Hel"}}]}',
"",
'data: this is not json',
"",
]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp):
with pytest.raises(LMStudioUnavailable) as excinfo:
list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
msg = str(excinfo.value).lower()
assert "не-json" in msg or "sse" in msg
# ---------------------------------------------------------------------------
# n>1 в стриме → WARNING + n=1 в payload
# ---------------------------------------------------------------------------
def test_stream_chat_n_greater_than_one_warns_and_uses_n_one(caplog):
"""n>1 в стриме: логируется WARNING, payload уходит с n=1."""
sse_lines = ['data: {"choices":[{"delta":{"content":"x"}}]}', "", "data: [DONE]", ""]
resp = _make_stream_response(sse_lines=sse_lines)
with caplog.at_level(logging.WARNING, logger="lm_client"):
with _patched_stream(response=resp) as mock_inst:
events = list(stream_chat(
messages=[{"role": "user", "content": "x"}],
n=4,
base_url="http://m:1/v1",
))
# Payload ушёл с n=1.
body = mock_inst.stream.call_args.kwargs["json"]
assert body["n"] == 1
# В логах было WARNING.
assert any(
"n=" in rec.message and "stream" in rec.message.lower()
for rec in caplog.records if rec.levelno == logging.WARNING
)
# Стрим всё равно отработал.
assert any(e.type == "delta" for e in events)
assert events[-1].type == "end"
def test_stream_chat_rejects_n_less_than_one():
"""n=0 → ValueError до обращения к сети."""
with pytest.raises(ValueError, match="n должно быть"):
list(stream_chat(
messages=[{"role": "user", "content": "x"}],
n=0,
base_url="http://m:1/v1",
))
# ---------------------------------------------------------------------------
# Малые кейсы: пропуск мусорных строк, reasoning в content
# ---------------------------------------------------------------------------
def test_stream_chat_skips_non_data_lines():
"""Строки без префикса `data:` (комментарии, event:, id:) — пропускаются."""
sse_lines = [
": this is a comment", # SSE-комментарий
"event: message", # event-поле
"id: 42", # id-поле
'data: {"choices":[{"delta":{"content":"Hi"}}]}',
"",
"data: [DONE]",
"",
]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp):
events = list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
deltas = [e for e in events if e.type == "delta"]
assert len(deltas) == 1
assert deltas[0].content == "Hi"
assert events[-1].type == "end"
def test_stream_chat_reasoning_tokens_pass_through_unchanged():
"""qwen3.5 кладёт reasoning в основной content — мы НЕ отделяем."""
reasoning_then_svg = (
"Let me think about this. I need an icon of a fox. The viewBox is 64x64. "
"<svg viewBox='0 0 64 64'><circle cx='32' cy='32' r='10'/></svg>"
)
# Один большой delta с reasoning+svg внутри.
sse_lines = [
f'data: {{"choices":[{{"delta":{{"content":{reasoning_then_svg!r}}}}}]}}',
"",
"data: [DONE]",
"",
]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp):
events = list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
full = "".join(e.content for e in events if e.type == "delta")
assert "Let me think" in full
assert "<svg" in full
assert "</svg>" in full
def test_stream_chat_handles_content_as_list_of_parts():
"""content может быть list[dict] (мультимодальный стрим) — склеиваем в строку."""
sse_lines = [
'data: {"choices":[{"delta":{"content":[{"type":"text","text":"Hel"},{"type":"text","text":"lo"}]}}]}',
"",
"data: [DONE]",
"",
]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp):
events = list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
deltas = [e for e in events if e.type == "delta"]
assert len(deltas) == 1
assert deltas[0].content == "Hello"
def test_stream_chat_emits_end_even_without_done_marker():
"""Стрим без [DONE] всё равно получает end-event (с WARNING в логе)."""
sse_lines = [
'data: {"choices":[{"delta":{"content":"x"},"finish_reason":"stop"}]}',
"",
# нет [DONE] — обрыв по исчерпанию итератора
]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp):
events = list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
assert events[-1].type == "end"
assert events[-1].finish_reason == "stop"
# ---------------------------------------------------------------------------
# Sanity: StreamEvent — frozen dataclass с нужными полями
# ---------------------------------------------------------------------------
def test_stream_event_is_frozen_dataclass():
"""StreamEvent — frozen: попытка изменения атрибута → FrozenInstanceError."""
ev = StreamEvent(type="delta", content="x")
assert ev.type == "delta"
assert ev.content == "x"
assert ev.usage is None
assert ev.model == ""
assert ev.finish_reason == ""
with pytest.raises(Exception): # FrozenInstanceError
ev.type = "end" # type: ignore[misc]
def test_stream_chat_strip_data_prefix_with_or_without_space():
"""SSE-префикс `data:` допускает опциональный пробел после двоеточия."""
sse_lines = [
'data:{"choices":[{"delta":{"content":"A"}}]}', # без пробела
"",
'data: {"choices":[{"delta":{"content":"B"}}]}', # с пробелом
"",
"data:[DONE]", # без пробела
"",
]
resp = _make_stream_response(sse_lines=sse_lines)
with _patched_stream(response=resp):
events = list(stream_chat(messages=[{"role": "user", "content": "x"}], base_url="http://m:1/v1"))
deltas = [e for e in events if e.type == "delta"]
assert [d.content for d in deltas] == ["A", "B"]
assert events[-1].type == "end"