feat(processor_lmstudio): openai-compatible client with health-check and retry
This commit is contained in:
parent
3f61204444
commit
2cbe19fecc
|
|
@ -0,0 +1,77 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from .processor_protocol import ProcessorInput, ProcessorOutput
|
||||||
|
|
||||||
|
|
||||||
|
class LMStudioProcessor:
|
||||||
|
"""OpenAI-compatible processor against LM Studio local server."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
model: str,
|
||||||
|
system_prompt: str,
|
||||||
|
max_retries: int = 2,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.system_prompt = system_prompt
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self._http = httpx.Client(timeout=timeout)
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
try:
|
||||||
|
r = self._http.get(f"{self.base_url}/models", timeout=2.0)
|
||||||
|
return r.status_code == 200
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _user_message(self, payload: ProcessorInput) -> dict:
|
||||||
|
ctx = {
|
||||||
|
"today": payload.today,
|
||||||
|
"weekday": payload.weekday,
|
||||||
|
"received_time": payload.received_time,
|
||||||
|
"persons": payload.persons,
|
||||||
|
"projects": payload.projects,
|
||||||
|
"text": payload.text,
|
||||||
|
"image_embed": payload.image_embed,
|
||||||
|
"image_caption": payload.image_caption,
|
||||||
|
}
|
||||||
|
content: list[dict] = [{"type": "text", "text": json.dumps(ctx, ensure_ascii=False)}]
|
||||||
|
if payload.image_local_path:
|
||||||
|
p = Path(payload.image_local_path)
|
||||||
|
if p.exists():
|
||||||
|
b64 = base64.b64encode(p.read_bytes()).decode("ascii")
|
||||||
|
content.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
||||||
|
})
|
||||||
|
return {"role": "user", "content": content}
|
||||||
|
|
||||||
|
def process(self, payload: ProcessorInput) -> ProcessorOutput:
|
||||||
|
body = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": self.system_prompt},
|
||||||
|
self._user_message(payload),
|
||||||
|
],
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"temperature": 0.2,
|
||||||
|
}
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
for _ in range(self.max_retries + 1):
|
||||||
|
r = self._http.post(f"{self.base_url}/chat/completions", json=body)
|
||||||
|
r.raise_for_status()
|
||||||
|
content = r.json()["choices"][0]["message"]["content"]
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
return ProcessorOutput.model_validate(data)
|
||||||
|
except (json.JSONDecodeError, ValidationError) as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"LM Studio produced invalid output after retries: {last_error}")
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import respx
|
||||||
|
import pytest
|
||||||
|
from journal_bot.processor_lmstudio import LMStudioProcessor
|
||||||
|
from journal_bot.processor_protocol import ProcessorInput
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def processor():
|
||||||
|
return LMStudioProcessor(
|
||||||
|
base_url="http://localhost:1234/v1",
|
||||||
|
model="qwen/qwen3-vl-8b",
|
||||||
|
system_prompt="SYS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_input(text="Hallo Welt"):
|
||||||
|
return ProcessorInput(
|
||||||
|
today="2026-06-14",
|
||||||
|
weekday="Sonntag",
|
||||||
|
received_time="14:32",
|
||||||
|
persons=[],
|
||||||
|
projects=[],
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_health_check_ok(processor):
|
||||||
|
respx.get("http://localhost:1234/v1/models").mock(
|
||||||
|
return_value=httpx.Response(200, json={"data": [{"id": "qwen/qwen3-vl-8b"}]})
|
||||||
|
)
|
||||||
|
assert processor.health_check() is True
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_health_check_down(processor):
|
||||||
|
respx.get("http://localhost:1234/v1/models").mock(
|
||||||
|
side_effect=httpx.ConnectError("no")
|
||||||
|
)
|
||||||
|
assert processor.health_check() is False
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_process_returns_validated_output(processor):
|
||||||
|
payload = {
|
||||||
|
"target_date": "2026-06-14",
|
||||||
|
"target_path": "05 Daily Notes/2026-06-14.md",
|
||||||
|
"entry_markdown": "## 14:32\nHallo Welt",
|
||||||
|
"clarifications": [],
|
||||||
|
"raw_excluded": [],
|
||||||
|
}
|
||||||
|
respx.post("http://localhost:1234/v1/chat/completions").mock(
|
||||||
|
return_value=httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": json.dumps(payload)}}],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
out = processor.process(make_input())
|
||||||
|
assert out.target_date == "2026-06-14"
|
||||||
|
assert out.entry_markdown.startswith("## 14:32")
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_process_retries_on_schema_mismatch(processor):
|
||||||
|
bad = {"choices": [{"message": {"content": json.dumps({"target_date": "bad"})}}]}
|
||||||
|
good = {"choices": [{"message": {"content": json.dumps({
|
||||||
|
"target_date": "2026-06-14",
|
||||||
|
"target_path": "05 Daily Notes/2026-06-14.md",
|
||||||
|
"entry_markdown": "## 14:32\nHallo",
|
||||||
|
"clarifications": [], "raw_excluded": [],
|
||||||
|
})}}]}
|
||||||
|
respx.post("http://localhost:1234/v1/chat/completions").mock(
|
||||||
|
side_effect=[httpx.Response(200, json=bad), httpx.Response(200, json=good)]
|
||||||
|
)
|
||||||
|
out = processor.process(make_input())
|
||||||
|
assert out.target_date == "2026-06-14"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_process_raises_after_max_retries(processor):
|
||||||
|
bad = {"choices": [{"message": {"content": "not json"}}]}
|
||||||
|
respx.post("http://localhost:1234/v1/chat/completions").mock(
|
||||||
|
return_value=httpx.Response(200, json=bad)
|
||||||
|
)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
processor.process(make_input())
|
||||||
Loading…
Reference in New Issue