Files
omnisvg-lite/lm_client.py
T
Mavis 36d9906e1f 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.
2026-06-13 16:40:19 +03:00

539 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Клиент к LM Studio через OpenAI-compatible chat completions.
Использует синхронный httpx, чтобы не тащить openai SDK и не ловить
несовместимости их версий. Поддерживает текстовые и мультимодальные
(text + image_url) сообщения, а также n>1 для batch-генерации кандидатов.
"""
from __future__ import annotations
import base64
import io
import json
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Any, Iterable, Iterator
import httpx
log = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"
DEFAULT_MODEL = "qwen/qwen3.5-35b-a3b"
DEFAULT_API_KEY = "lm-studio" # LM Studio игнорирует значение, но заголовок обязателен
DEFAULT_TIMEOUT_S = 300.0 # qwen3.5 может долго рассуждать, не режем по таймауту
DEFAULT_MAX_TOKENS = 32768 # без жёсткого лимита: qwen3.5 ест reasoning + content в одном budget
class LMStudioUnavailable(RuntimeError):
"""LM Studio не отвечает, вернул 5xx или вернул неожиданный формат."""
@dataclass(frozen=True)
class LMTurnResult:
"""Результат одного запроса к LM Studio.
Attributes:
raw_texts: список текстов ответов — по одному на кандидат. Если модель
вернула tool_calls без `content`, элемент будет пустой строкой.
elapsed_s: время запроса в секундах.
model: фактическое имя модели, которое вернул сервер (если есть) или
то, что мы послали.
usage: словарь usage от сервера, может быть None.
finish_reasons: список finish_reason по кандидатам ("stop"/"length"/...).
"""
raw_texts: list[str]
elapsed_s: float
model: str
usage: dict | None = None
finish_reasons: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class StreamEvent:
"""Событие потокового ответа LM Studio.
Attributes:
type: 'delta' — очередной кусочек текста (delta content от сервера);
'end' — стрим завершён нормально; 'error' — стрим прерван ошибкой.
content: текст delta (для type='delta') либо '' для остальных.
usage: usage-блок, который некоторые серверы шлют в последнем чанке
(либо None, если не пришёл).
model: фактическое имя модели из ответа сервера.
finish_reason: 'stop' / 'length' / 'tool_calls' / '' (если ещё не пришёл).
"""
type: str # 'delta' | 'end' | 'error'
content: str = ""
usage: dict | None = None
model: str = ""
finish_reason: str = ""
def _coerce_text_part(part: Any) -> str:
"""Достаёт текст из элемента content — поддерживает str и list[dict]."""
if isinstance(part, str):
return part
if isinstance(part, dict):
return str(part.get("text", "") or "")
return str(part)
def _normalize_messages(messages: Iterable[dict]) -> list[dict]:
"""Готовит сообщения к отправке: текст либо строкой, либо [{type:text},...].
Допускаем на входе content как str или list[dict] (text + image_url).
Возвращаем список в формате OpenAI chat completions.
"""
out: list[dict] = []
for msg in messages:
role = msg.get("role")
content = msg.get("content")
if role is None or content is None:
log.warning("пропускаю сообщение без role/content: %r", msg)
continue
if isinstance(content, str):
out.append({"role": role, "content": content})
else:
parts: list[dict] = []
for piece in content:
if isinstance(piece, str):
parts.append({"type": "text", "text": piece})
elif isinstance(piece, dict):
ptype = piece.get("type")
if ptype == "text":
parts.append({"type": "text", "text": str(piece.get("text", ""))})
elif ptype == "image_url":
url = piece.get("image_url")
if isinstance(url, dict):
url = url.get("url")
parts.append({"type": "image_url", "image_url": {"url": str(url)}})
else:
log.warning("неизвестный тип части content: %r", ptype)
out.append({"role": role, "content": parts})
return out
def chat(
*,
messages: list[dict],
model: str | None = None,
n: int = 1,
temperature: float = 0.4,
max_tokens: int = DEFAULT_MAX_TOKENS,
base_url: str | None = None,
api_key: str | None = None,
timeout_s: float | None = None,
) -> LMTurnResult:
"""Шлёт chat completion в LM Studio и возвращает N текстов ответов.
Args:
messages: список сообщений в формате OpenAI. content может быть str
или list[dict] (text/image_url).
model: имя модели; если None — берём DEFAULT_MODEL / env `DEFAULT_MODEL`.
n: количество кандидатов, 1..8.
temperature: 0.0..1.5.
max_tokens: верхняя граница длины ответа.
base_url: эндпоинт LM Studio; дефолт http://127.0.0.1:1234/v1.
api_key: Bearer-токен; дефолт "lm-studio".
timeout_s: общий таймаут httpx, дефолт 120 секунд.
Returns:
LMTurnResult с N текстами и метаданными.
Raises:
LMStudioUnavailable: при сетевых/HTTP/ошибках парсинга.
"""
if n < 1:
raise ValueError(f"n должно быть >= 1, получено {n}")
base = (base_url or os.environ.get("LM_STUDIO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
key = api_key if api_key is not None else os.environ.get("LM_STUDIO_API_KEY", DEFAULT_API_KEY)
mdl = model or os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)
timeout = float(
os.environ.get("REQUEST_TIMEOUT_S", str(timeout_s if timeout_s is not None else DEFAULT_TIMEOUT_S))
)
url = f"{base}/chat/completions"
payload: dict[str, Any] = {
"model": mdl,
"messages": _normalize_messages(messages),
"n": n,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
# попытка отключить thinking у qwen3.5; если LM Studio/модель не уважают — не страшно
"chat_template_kwargs": {"enable_thinking": False},
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
}
log.info(
"LM Studio → %s model=%s n=%d temp=%.2f timeout=%.0fs",
url, mdl, n, temperature, timeout,
)
started = time.monotonic()
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
raise LMStudioUnavailable(
f"LM Studio timeout at {url}: запрос превысил {timeout:.0f}с"
) from exc
except httpx.HTTPError as exc:
raise LMStudioUnavailable(
f"LM Studio недоступен по адресу {url}: {exc}"
) from exc
elapsed = time.monotonic() - started
if resp.status_code >= 500:
raise LMStudioUnavailable(
f"LM Studio error: {resp.status_code} {resp.text[:200]}"
)
if resp.status_code >= 400:
# 4xx — это наша ошибка (плохой запрос / модель не загружена / квота).
raise LMStudioUnavailable(
f"LM Studio вернул {resp.status_code}: {resp.text[:200]}"
)
try:
data = resp.json()
except ValueError as exc:
raise LMStudioUnavailable(
f"LM Studio вернул не-JSON: {resp.text[:200]}"
) from exc
choices = data.get("choices") or []
if not choices:
raise LMStudioUnavailable(
f"LM Studio не вернул ни одного choice: {resp.text[:200]}"
)
raw_texts: list[str] = []
finish_reasons: list[str] = []
for ch in choices:
msg = ch.get("message") or {}
content = msg.get("content")
if isinstance(content, str):
raw_texts.append(content)
elif isinstance(content, list):
raw_texts.append("".join(_coerce_text_part(p) for p in content))
elif content is None:
# Модель могла вернуть tool_calls — для нас это пустой кандидат.
raw_texts.append("")
else:
raw_texts.append(_coerce_text_part(content))
finish_reasons.append(str(ch.get("finish_reason") or ""))
# Если сервер вернул меньше choice'ов, чем n — добиваем пустыми строками.
while len(raw_texts) < n:
raw_texts.append("")
finish_reasons.append("missing")
return LMTurnResult(
raw_texts=raw_texts,
elapsed_s=elapsed,
model=str(data.get("model") or mdl),
usage=data.get("usage"),
finish_reasons=finish_reasons,
)
def stream_chat(
*,
messages: list[dict],
model: str | None = None,
n: int = 1,
temperature: float = 0.4,
max_tokens: int = DEFAULT_MAX_TOKENS,
base_url: str | None = None,
api_key: str | None = None,
timeout_s: float | None = None,
) -> Iterator[StreamEvent]:
"""Шлёт chat completion в LM Studio со stream=True и отдаёт чанки контента.
Yields:
StreamEvent(type='delta', content=...) — очередной кусочек текста.
StreamEvent(type='end', ...) — финальное событие с метаданными
(model, finish_reason, usage если сервер прислал).
Особенности:
- Reasoning-токены qwen3.5 приходят В ОСНОВНОМ `content` (как обычный
текст), мы их не отделяем — это работа incremental_svg-парсера в UI.
- OpenAI не поддерживает `n>1` в стриме. Если пользователь передал
`n>1`, логируем WARNING и идём с `n=1` в payload.
- На ошибках (HTTP 4xx/5xx, network, timeout, битый SSE) —
бросает LMStudioUnavailable. (StreamEvent(type='error') зарезервирован
на будущее, но в текущей реализации ошибки идут через raise.)
Raises:
LMStudioUnavailable при сетевых/HTTP/парсинговых ошибках.
"""
if n < 1:
raise ValueError(f"n должно быть >= 1, получено {n}")
if n > 1:
log.warning(
"stream_chat: n=%d запрошено, но в stream-режиме OpenAI не "
"поддерживает n>1 — идём с n=1",
n,
)
effective_n = 1
else:
effective_n = n
base = (base_url or os.environ.get("LM_STUDIO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
key = api_key if api_key is not None else os.environ.get("LM_STUDIO_API_KEY", DEFAULT_API_KEY)
mdl = model or os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL)
timeout = float(
os.environ.get("REQUEST_TIMEOUT_S", str(timeout_s if timeout_s is not None else DEFAULT_TIMEOUT_S))
)
url = f"{base}/chat/completions"
payload: dict[str, Any] = {
"model": mdl,
"messages": _normalize_messages(messages),
"n": effective_n,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
# LM Studio / qwen3.5 уважают этот флаг, чтобы не слать thinking
# отдельным reasoning_content-полем (qwen3.5 кладёт рассуждение в
# основной content, и мы не пытаемся его отделить).
"chat_template_kwargs": {"enable_thinking": False},
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
}
log.info(
"LM Studio (stream) → %s model=%s n=%d temp=%.2f timeout=%.0fs",
url, mdl, effective_n, temperature, timeout,
)
started = time.monotonic()
# Поля, которые аккумулируются по ходу стрима: финальный чанк часто
# содержит finish_reason/usage, а model сервер может прислать в первом чанке.
final_model = ""
final_usage: dict | None = None
final_finish_reason = ""
saw_done = False
try:
with httpx.Client(timeout=timeout) as client:
with client.stream("POST", url, json=payload, headers=headers) as resp:
# HTTP-ошибки — до чтения тела.
if resp.status_code >= 400:
# Сливаем тело для сообщения об ошибке, но не отдаём
# его в стрим.
body_preview = ""
try:
body_preview = resp.read().decode("utf-8", errors="replace")[:200]
except Exception: # noqa: BLE001
body_preview = "<no body>"
if resp.status_code >= 500:
raise LMStudioUnavailable(
f"LM Studio error: {resp.status_code} {body_preview}"
)
raise LMStudioUnavailable(
f"LM Studio вернул {resp.status_code}: {body_preview}"
)
# Читаем SSE: каждая строка — это `data: <...>` или пустая
# строка-разделитель. События разделены пустой строкой.
for raw_line in resp.iter_lines():
if not raw_line:
continue
# SSE-префикс — `data: ` (с пробелом). Без префикса — мусор.
if not raw_line.startswith("data:"):
# Может быть комментарий (`: ...`) или event/id —
# мы их игнорируем.
continue
payload_str = raw_line[len("data:"):].strip()
if payload_str == "[DONE]":
saw_done = True
break
try:
chunk = json.loads(payload_str)
except ValueError:
# Битый SSE — бросаем, как делает обычный chat().
raise LMStudioUnavailable(
f"LM Studio stream: не-JSON в SSE-чанке: {payload_str[:200]!r}"
)
# Достаём метаданные из чанка.
if isinstance(chunk, dict):
if "model" in chunk and chunk["model"]:
final_model = str(chunk["model"])
if "usage" in chunk and chunk["usage"]:
final_usage = chunk["usage"]
choices = chunk.get("choices") or []
if not choices:
# Heartbeat-чанки без choices — пропускаем.
continue
first = choices[0]
delta = first.get("delta") or {}
content = delta.get("content")
if content:
# content может прийти str или list[dict] (мультимодальный
# стрим). Склеиваем в строку.
if isinstance(content, list):
content = "".join(_coerce_text_part(p) for p in content)
yield StreamEvent(type="delta", content=str(content))
fr = first.get("finish_reason")
if fr:
final_finish_reason = str(fr)
except httpx.TimeoutException as exc:
raise LMStudioUnavailable(
f"LM Studio stream timeout at {url}: запрос превысил {timeout:.0f}с"
) from exc
except httpx.HTTPError as exc:
raise LMStudioUnavailable(
f"LM Studio stream недоступен по адресу {url}: {exc}"
) from exc
elapsed = time.monotonic() - started
if not saw_done and not final_finish_reason:
# Стрим оборвался без [DONE] и без finish_reason — считаем это
# незавершённым. Не бросаем исключение, чтобы UI мог показать
# частичный текст; помечаем финал как обрезанный.
log.warning("LM Studio stream: выход без [DONE] (elapsed=%.2fs)", elapsed)
yield StreamEvent(
type="end",
model=final_model or mdl,
usage=final_usage,
finish_reason=final_finish_reason,
)
def encode_pil_to_data_url(image: Any, *, mime: str = "image/png") -> str:
"""Кодирует PIL-картинку в data: URL для передачи в image_url.
Args:
image: объект PIL.Image.
mime: MIME-тип, по умолчанию image/png (LM Studio VLM лучше работают с PNG).
Returns:
Строка вида `data:image/png;base64,<...>`.
"""
from PIL import Image # локальный импорт, чтобы не требовать Pillow без надобности
buf = io.BytesIO()
img = image
if getattr(img, "mode", None) == "RGBA" and mime == "image/jpeg":
img = img.convert("RGB")
elif getattr(img, "mode", None) not in ("RGB", "RGBA", "L") and mime == "image/png":
img = img.convert("RGBA")
img.save(buf, format="PNG" if mime == "image/png" else "JPEG", optimize=True)
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:{mime};base64,{encoded}"
def validate_image(image: Any, *, max_bytes: int = 10 * 1024 * 1024, max_side: int = 4096) -> None:
"""Проверяет картинку перед отправкой в LM Studio (US-2, edge case #3).
Args:
image: PIL.Image.
max_bytes: максимальный размер PNG-байт после перекодирования.
max_side: максимальная сторона в пикселях.
Raises:
ValueError: с человеко-читаемой причиной на русском.
"""
from PIL import Image, UnidentifiedImageError
if image is None:
raise ValueError("изображение не передано")
if not isinstance(image, Image.Image):
raise ValueError(f"ожидался PIL.Image, получен {type(image).__name__}")
w, h = image.size
if max(w, h) > max_side:
raise ValueError(
f"изображение слишком большое: {w}x{h}, максимум {max_side}x{max_side}"
)
if getattr(image, "format", None) and image.format not in ("PNG", "JPEG", "WEBP"):
raise ValueError(
f"неподдерживаемый формат: {image.format}; допустимы PNG, JPEG, WEBP"
)
# Прикидка размера: для RGB 3 байта/пиксель + служебные.
approx = (w * h * 4) + (1024 * 64)
if approx > max_bytes:
raise ValueError(
f"изображение превышает {max_bytes // (1024*1024)} МБ после кодирования"
)
# ---------------------------------------------------------------------------
# Удобный высокоуровневый helper, чтобы UI не думал о формате messages.
# ---------------------------------------------------------------------------
def generate_svg(
prompt: str,
*,
image_b64: str | None = None,
mode: str = "icon",
n: int = 1,
temperature: float = 0.4,
model: str | None = None,
system_prompt: str | None = None,
) -> list[dict]:
"""Собирает messages, отправляет в LM Studio и возвращает сырые ответы.
Эта функция — тонкая обёртка над `chat()`, вынесенная для удобства UI.
За реальную сборку messages отвечает `prompts.build_messages`.
Args:
prompt: текст промпта пользователя.
image_b64: data: URL картинки (или None для text-to-SVG).
mode: "icon" или "illustration" — для тега system, если не задан явно.
n: число кандидатов.
temperature: 0..1.5.
model: имя модели (опц.).
system_prompt: если задан — используется как system; иначе caller должен
сам подставить его в messages (эта функция не знает про prompts/).
Returns:
Список словарей `{'svg_text': str, 'raw': str, 'finish_reason': str}`.
"""
if not (1 <= len(prompt.strip()) <= 1000):
raise ValueError("промпт должен быть от 1 до 1000 символов")
messages: list[dict] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
if image_b64:
user_content: Any = [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": image_b64}},
]
else:
user_content = prompt
messages.append({"role": "user", "content": user_content})
result = chat(
messages=messages,
model=model,
n=n,
temperature=temperature,
)
return [
{
"svg_text": raw, # сырой текст; SVG вытащим extract_svg в валидаторе
"raw": raw,
"finish_reason": fr,
}
for raw, fr in zip(result.raw_texts, result.finish_reasons)
]