htr-vlm-annotator / provider.py
dhuser's picture
Initial HTR VLM Annotator app
58cd314
Raw
History Blame Contribute Delete
5.91 kB
"""OpenRouter async client with multimodal (image) support."""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import Optional
import httpx
DEFAULT_TIMEOUT = 120.0
BASE_URL = "https://openrouter.ai/api/v1"
@dataclass
class ChatResult:
model: str
ok: bool
content: str = ""
latency_s: float = 0.0
error: str = ""
usage: Optional[dict] = None # {prompt_tokens, completion_tokens, total_tokens, cost?}
def _headers(api_key: str) -> dict:
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://htr-vlm-app.local",
"X-Title": "HTR VLM + MoE Post-correction",
}
def text_part(text: str) -> dict:
return {"type": "text", "text": text}
def image_part(image_b64: str, mime: str = "image/jpeg") -> dict:
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
}
class LLMClient:
def __init__(self, api_key: str, timeout: float = DEFAULT_TIMEOUT):
self.api_key = api_key
self.timeout = timeout
self.endpoint = BASE_URL + "/chat/completions"
self._client: httpx.AsyncClient | None = None
async def __aenter__(self):
self._client = httpx.AsyncClient(
timeout=self.timeout,
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
return self
async def __aexit__(self, exc_type, exc, tb):
if self._client:
await self._client.aclose()
self._client = None
async def chat(
self,
*,
model: str,
system: str,
user_parts: list[dict],
temperature: float = 0.0,
max_tokens: int = 4096,
force_json: bool = True,
max_retries: int = 2,
) -> ChatResult:
if not self.api_key:
return ChatResult(model=model, ok=False, error="Missing OpenRouter API key.")
if self._client is None:
raise RuntimeError("LLMClient must be used inside `async with` block")
msgs = [
{"role": "system", "content": system},
{"role": "user", "content": user_parts},
]
payload: dict = {
"model": model,
"messages": msgs,
"temperature": temperature,
"max_tokens": max_tokens,
# OpenRouter usage-accounting: include token + cost details in response
"usage": {"include": True},
}
if force_json:
payload["response_format"] = {"type": "json_object"}
start = time.time()
last_error = ""
backoff = 1.0
for attempt in range(max_retries + 1):
try:
resp = await self._client.post(self.endpoint, headers=_headers(self.api_key), json=payload)
if resp.status_code == 400 and force_json:
# Some routes reject response_format — strip and retry once.
payload_no_fmt = {k: v for k, v in payload.items() if k != "response_format"}
resp = await self._client.post(self.endpoint, headers=_headers(self.api_key), json=payload_no_fmt)
if resp.status_code >= 400:
last_error = f"HTTP {resp.status_code}: {resp.text[:300]}"
else:
data = resp.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content") or ""
if not content and isinstance(data.get("choices", [{}])[0].get("message", {}).get("reasoning"), str):
content = data["choices"][0]["message"]["reasoning"]
usage = data.get("usage") or None
if isinstance(usage, dict):
usage = {
"prompt_tokens": usage.get("prompt_tokens"),
"completion_tokens": usage.get("completion_tokens"),
"total_tokens": usage.get("total_tokens"),
"cost": usage.get("cost"),
}
return ChatResult(
model=model,
ok=True,
content=content,
latency_s=time.time() - start,
usage=usage,
)
except (httpx.TimeoutException, httpx.TransportError) as e:
last_error = f"{type(e).__name__}: {e}"
except Exception as e: # noqa: BLE001
last_error = f"{type(e).__name__}: {e}"
if attempt < max_retries:
await asyncio.sleep(backoff)
backoff *= 2
return ChatResult(
model=model,
ok=False,
error=last_error or "unknown error",
latency_s=time.time() - start,
)
def test_connection_sync(api_key: str, model: Optional[str] = None) -> tuple[bool, str]:
"""Quick blocking test for the 'Test' button in the API key modal."""
model = model or "mistralai/mistral-large-2512"
try:
resp = httpx.post(
BASE_URL + "/chat/completions",
headers=_headers(api_key),
json={
"model": model,
"messages": [{"role": "user", "content": "Reply with the single word: OK"}],
"max_tokens": 5,
"temperature": 0.0,
},
timeout=20.0,
)
if resp.status_code >= 400:
return False, f"HTTP {resp.status_code} from OpenRouter: {resp.text[:200]}"
content = resp.json()["choices"][0]["message"]["content"]
return True, f"OpenRouter key accepted."
except Exception as e: # noqa: BLE001
return False, f"OpenRouter request failed: {e}"