blitztext-app-linux/linux/blitztext/llm.py
mARTin-B78 8f056e8cf0 feat: cancel button during transcription and rewriting (v2.03.41)
- overlay: show × button in busy state (transcribing/rewriting), not only
  while recording — updates hit-region, draw call, and label layout
- daemon: cancel_dictation() now handles _busy via threading.Event
  (_abort_event); _process() clears the event at start, checks after STT
  returns and after LLM rewrite completes, skipping delivery if set
- llm: chat() and _read_stream() accept abort_event; streaming loop breaks
  immediately when the event is set so cancellation is near-instant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:57:41 +02:00

137 lines
4.8 KiB
Python

"""LLM engine abstraction for the rewrite step.
Engines are user-managed presets pointing at any OpenAI-compatible chat endpoint
(OpenAI, vLLM, llama-swap, Ollama /v1, LM Studio, Groq, OpenRouter, …). Provides
reachability status (online/offline) and a chat-completion call. Uses only the
standard library so the daemon needs no extra deps.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Callable
from .stt import reachable
@dataclass
class LLMEngine:
name: str
url: str = "https://api.openai.com/v1" # base URL (incl. /v1)
model: str = "gpt-4o-mini"
api_key_env: str = "OPENAI_API_KEY"
temperature: float = 0.3
type: str = "cloud" # "local" | "cloud" (label/category)
@property
def api_key(self) -> str | None:
return os.environ.get(self.api_key_env) if self.api_key_env else None
class LLMError(RuntimeError):
pass
def status(engine: LLMEngine, timeout: float = 2.0) -> bool:
"""Reachable if the endpoint host:port accepts a TCP connection."""
return reachable(engine.url, timeout)
def chat(
engine: LLMEngine,
system_prompt: str,
user_text: str,
*,
model: str | None = None,
temperature: float | None = None,
timeout: int = 45,
on_token: Callable[[str], None] | None = None,
abort_event=None,
) -> str:
"""Run a chat completion and return the full text.
When ``on_token`` is given, the request is streamed and each content delta is
handed to the callback as it arrives (so a UI can show the model writing in
real time). The callback is best-effort — it never affects the return value,
which is always the complete, stripped response.
"""
stream = on_token is not None
api_key = engine.api_key
body_obj = {
"model": model or engine.model,
"temperature": engine.temperature if temperature is None else temperature,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
}
if stream:
body_obj["stream"] = True
payload = json.dumps(body_obj).encode("utf-8")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
engine.url.rstrip("/") + "/chat/completions", data=payload, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if stream:
content = _read_stream(resp, on_token, abort_event)
else:
body = json.loads(resp.read().decode("utf-8"))
content = body["choices"][0]["message"]["content"]
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
raise LLMError(f"HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise LLMError(f"Connection failed: {exc.reason}") from exc
except (KeyError, IndexError, TypeError) as exc:
raise LLMError(f"Unexpected response: {exc}") from exc
except (TimeoutError, OSError) as exc:
# socket.timeout (subclass of OSError / TimeoutError) fires when the server
# stops sending data mid-stream. Not wrapped in URLError — must be caught
# separately or it would propagate uncaught and kill the background thread.
raise LLMError(f"Request timed out or connection lost: {exc}") from exc
except Exception as exc: # noqa: BLE001
raise LLMError(f"Unexpected error: {exc}") from exc
content = (content or "").strip()
if not content:
raise LLMError("Empty response from model.")
return content
def _read_stream(resp, on_token: Callable[[str], None], abort_event=None) -> str:
"""Parse an OpenAI-style SSE stream, returning the accumulated content and
feeding each delta to ``on_token``. Tolerant of keep-alive blanks and the
trailing ``[DONE]`` sentinel."""
parts: list[str] = []
for raw in resp:
if abort_event and abort_event.is_set():
break
line = raw.decode("utf-8", "replace").strip()
if not line or not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
break
try:
obj = json.loads(data)
delta = obj["choices"][0]["delta"].get("content")
except (ValueError, KeyError, IndexError, TypeError):
continue
if delta:
parts.append(delta)
try:
on_token(delta)
except Exception: # noqa: BLE001 - UI hiccups must not break delivery
pass
return "".join(parts)