Spaces:
Sleeping
Sleeping
| """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" | |
| 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}" | |