# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Optional
import httpx
from devplacepy.stealth import stealth_async_client
from devplacepy.services.jobs.isslop.config import (
LLM_MAX_RETRIES,
LLM_RETRY_BACKOFF_SECONDS,
LLM_TEMPERATURE,
LLM_TIMEOUT_SECONDS,
WorkerSettings,
)
logger = logging.getLogger(__name__)
class LlmUnavailableError(Exception):
pass
class LlmClient:
def __init__(self, settings: WorkerSettings) -> None:
self._endpoint = settings.llm_endpoint
self._model = settings.llm_model
self._api_key = settings.llm_api_key
self._review_enabled = settings.ai_review_enabled
self._vision_enabled = settings.image_review_enabled
self._client: Optional[httpx.AsyncClient] = None
@property
def active_backend_name(self) -> str:
return self._model
@property
def review_available(self) -> bool:
return self._review_enabled and bool(self._endpoint and self._api_key)
@property
def vision_available(self) -> bool:
return self._vision_enabled and bool(self._endpoint and self._api_key)
async def _http(self) -> httpx.AsyncClient:
if self._client is None:
self._client = stealth_async_client(timeout=httpx.Timeout(LLM_TIMEOUT_SECONDS))
return self._client
async def aclose(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
async def _call(self, messages: list[dict[str, Any]], timeout: Optional[float] = None) -> str:
if not self._endpoint or not self._api_key:
raise LlmUnavailableError("AI gateway not configured")
client = await self._http()
payload = {
"model": self._model,
"messages": messages,
"temperature": LLM_TEMPERATURE,
}
headers = {
"authorization": f"Bearer {self._api_key}",
"content-type": "application/json",
"X-App-Reference": "devplace-isslop-v-1-0-0",
}
request_timeout = httpx.Timeout(timeout) if timeout else None
last_error = "unknown"
for attempt in range(1, LLM_MAX_RETRIES + 1):
try:
response = await client.post(
self._endpoint, json=payload, headers=headers, timeout=request_timeout
)
except httpx.HTTPError as error:
last_error = f"{type(error).__name__}: {error}"
logger.warning("Gateway attempt %d transport failure: %s", attempt, last_error)
await asyncio.sleep(LLM_RETRY_BACKOFF_SECONDS * attempt)
continue
if response.status_code >= 400:
last_error = f"HTTP {response.status_code}: {response.text[:300]}"
logger.warning("Gateway attempt %d failed: %s", attempt, last_error)
await asyncio.sleep(LLM_RETRY_BACKOFF_SECONDS * attempt)
continue
try:
body = response.json()
except ValueError:
last_error = "invalid JSON envelope"
continue
choices = body.get("choices")
if isinstance(choices, list) and choices:
content = choices[0].get("message", {}).get("content")
if isinstance(content, str) and content.strip():
return content
last_error = json.dumps(body)[:300]
logger.warning("Gateway attempt %d empty completion: %s", attempt, last_error)
await asyncio.sleep(LLM_RETRY_BACKOFF_SECONDS * attempt)
raise LlmUnavailableError(f"AI gateway failed after {LLM_MAX_RETRIES} attempts: {last_error}")
async def complete(self, system: str, user: str) -> str:
if not self.review_available:
raise LlmUnavailableError("AI review disabled")
return await self._call(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
]
)
async def describe_image(self, prompt: str, image_data_url: str, timeout: float) -> str:
if not self.vision_available:
raise LlmUnavailableError("vision backend unavailable")
return await self._call(
[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": image_data_url}},
],
}
],
timeout=timeout,
)
def extract_json_object(text: str) -> Optional[dict[str, Any]]:
start = text.find("{")
while start != -1:
depth = 0
in_string = False
escaped = False
for position in range(start, len(text)):
char = text[position]
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
candidate = text[start:position + 1]
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
break
if isinstance(parsed, dict):
return parsed
break
start = text.find("{", start + 1)
return None