File size: 5,046 Bytes
233f67e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | """
llama.cpp HTTP client wrapper for FormScout.
Wraps the llama.cpp server's /completion and /embedding endpoints.
Falls back gracefully when the server is unavailable.
Model: Qwen3-VL-8B-Instruct (Q4_K_M GGUF) for VLM inference.
Model: Qwen3-VL-Embedding-8B (Q4_K_M GGUF) for embeddings.
Params: 8B each (shared backbone).
License: Apache-2.0.
"""
from __future__ import annotations
import base64
import json
import logging
from pathlib import Path
from typing import Any
import requests
from formscout import config
logger = logging.getLogger(__name__)
_TIMEOUT = 120 # seconds — VLM can be slow
class LlamaCppClient:
"""HTTP client for a llama.cpp server instance."""
def __init__(self, host: str | None = None, port: int | None = None):
self.host = host or config.LLAMA_CPP_HOST
self.port = port or config.LLAMA_CPP_PORT_VLM
self.base_url = f"http://{self.host}:{self.port}"
@property
def available(self) -> bool:
"""Check if the server is reachable."""
try:
r = requests.get(f"{self.base_url}/health", timeout=5)
return r.status_code == 200
except (requests.ConnectionError, requests.Timeout):
return False
def complete(
self,
prompt: str,
images: list[str] | None = None,
max_tokens: int = 512,
temperature: float = 0.1,
stop: list[str] | None = None,
) -> dict[str, Any]:
"""
Send a chat-completion request (OpenAI-compatible /v1/chat/completions —
required for multimodal: llama-server routes images through the mmproj
only on this endpoint). Returns parsed JSON if the response is JSON,
otherwise returns {"text": raw_text}.
Args:
prompt: The text prompt (system + user combined).
images: Optional list of base64-encoded JPEGs or file paths.
max_tokens: Max generation tokens.
temperature: Sampling temperature.
stop: Stop sequences (default: none — JSON output must not be truncated).
"""
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
for img in images or []:
if len(img) < 4096 and Path(img).exists():
with open(img, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
else:
b64 = img # already base64
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
})
payload: dict[str, Any] = {
"messages": [{"role": "user", "content": content}],
"max_tokens": max_tokens,
"temperature": temperature,
}
if stop:
payload["stop"] = stop
try:
r = requests.post(
f"{self.base_url}/v1/chat/completions",
json=payload,
timeout=_TIMEOUT,
)
r.raise_for_status()
result = r.json()
text = result["choices"][0]["message"]["content"] or ""
return self._parse_json_reply(text)
except requests.ConnectionError:
return {"error": "llama.cpp server not available", "text": ""}
except requests.Timeout:
return {"error": "llama.cpp server timeout", "text": ""}
except Exception as e:
return {"error": str(e), "text": ""}
@staticmethod
def _parse_json_reply(text: str) -> dict[str, Any]:
"""Parse model output as JSON, tolerating markdown fences."""
stripped = text.strip()
if stripped.startswith("```"):
stripped = stripped.split("\n", 1)[-1]
stripped = stripped.rsplit("```", 1)[0].strip()
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, TypeError):
pass
return {"text": text}
class EmbeddingClient:
"""HTTP client for the llama.cpp embedding server."""
def __init__(self, host: str | None = None, port: int | None = None):
self.host = host or config.LLAMA_CPP_HOST
self.port = port or config.LLAMA_CPP_PORT_EMBED
self.base_url = f"http://{self.host}:{self.port}"
@property
def available(self) -> bool:
try:
r = requests.get(f"{self.base_url}/health", timeout=5)
return r.status_code == 200
except (requests.ConnectionError, requests.Timeout):
return False
def embed(self, text: str) -> list[float] | None:
"""Get embedding vector for text. Returns None on failure."""
try:
r = requests.post(
f"{self.base_url}/embedding",
json={"content": text},
timeout=30,
)
r.raise_for_status()
data = r.json()
return data.get("embedding")
except Exception:
return None
|