|
# 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
|