|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Callable
|
|
|
|
import httpx
|
|
|
|
from devplacepy import stealth
|
|
from .config import Settings
|
|
from .cost import record_cost, record_usage
|
|
from .errors import LLMError
|
|
|
|
logger = logging.getLogger("devii.llm")
|
|
|
|
|
|
class LLMClient:
|
|
def __init__(
|
|
self,
|
|
settings: Settings,
|
|
key_resolver: Callable[[], str] | None = None,
|
|
) -> None:
|
|
self._settings = settings
|
|
self._key_resolver = key_resolver
|
|
self._client = stealth.stealth_async_client(
|
|
timeout=settings.timeout_seconds,
|
|
headers={
|
|
"Authorization": f"Bearer {settings.ai_key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
|
|
async def aclose(self) -> None:
|
|
await self._client.aclose()
|
|
|
|
async def _post(self, payload: dict[str, Any]) -> httpx.Response:
|
|
response = await self._client.post(self._settings.ai_url, json=payload)
|
|
if response.status_code == 401 and self._refresh_key():
|
|
response = await self._client.post(self._settings.ai_url, json=payload)
|
|
return response
|
|
|
|
def _refresh_key(self) -> bool:
|
|
if self._key_resolver is None:
|
|
return False
|
|
try:
|
|
key = self._key_resolver()
|
|
except Exception: # noqa: BLE001 - refresh is best-effort; the original 401 still surfaces
|
|
logger.exception("Credential refresh failed after 401")
|
|
return False
|
|
if not key or self._client.headers.get("Authorization") == f"Bearer {key}":
|
|
return False
|
|
self._client.headers["Authorization"] = f"Bearer {key}"
|
|
logger.info("Refreshed model endpoint credentials after 401")
|
|
return True
|
|
|
|
async def complete(
|
|
self,
|
|
messages: list[dict[str, Any]],
|
|
tools: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
payload = {
|
|
"model": self._settings.ai_model,
|
|
"messages": messages,
|
|
"tools": tools,
|
|
"tool_choice": "auto",
|
|
}
|
|
try:
|
|
logger.debug("LLM request with %d messages", len(messages))
|
|
response = await self._post(payload)
|
|
except httpx.HTTPError as exc:
|
|
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
|
|
|
|
if response.status_code >= 400:
|
|
raise LLMError(
|
|
f"Model endpoint returned {response.status_code}: {self._reason(response)}",
|
|
status=response.status_code,
|
|
body=response.text[:500],
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
raise LLMError("Model endpoint returned invalid JSON.") from exc
|
|
|
|
choices = data.get("choices")
|
|
if not choices:
|
|
raise LLMError("Model response contained no choices.", body=str(data)[:500])
|
|
|
|
message = choices[0].get("message")
|
|
if message is None:
|
|
raise LLMError("Model response contained no message.", body=str(data)[:500])
|
|
|
|
record_usage(data.get("usage"))
|
|
self._record_native_cost(response)
|
|
logger.debug(
|
|
"LLM response received (tool_calls=%s)", bool(message.get("tool_calls"))
|
|
)
|
|
return message
|
|
|
|
async def complete_text(
|
|
self, messages: list[dict[str, Any]], temperature: float = 0.0
|
|
) -> str:
|
|
payload = {
|
|
"model": self._settings.ai_model,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
}
|
|
try:
|
|
response = await self._post(payload)
|
|
except httpx.HTTPError as exc:
|
|
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
|
|
if response.status_code >= 400:
|
|
raise LLMError(
|
|
f"Model endpoint returned {response.status_code}: {self._reason(response)}"
|
|
)
|
|
try:
|
|
data = response.json()
|
|
content = data["choices"][0]["message"]["content"] or ""
|
|
except (ValueError, KeyError, IndexError) as exc:
|
|
raise LLMError("Model endpoint returned an unexpected response.") from exc
|
|
record_usage(data.get("usage"))
|
|
self._record_native_cost(response)
|
|
return content
|
|
|
|
async def summarize(self, text: str) -> str:
|
|
payload = {
|
|
"model": self._settings.ai_model,
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": "You are a precise technical summarizer.",
|
|
},
|
|
{"role": "user", "content": text},
|
|
],
|
|
"temperature": 0.0,
|
|
}
|
|
try:
|
|
response = await self._post(payload)
|
|
except httpx.HTTPError as exc:
|
|
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
|
|
if response.status_code >= 400:
|
|
raise LLMError(
|
|
f"Model endpoint returned {response.status_code}: {self._reason(response)}"
|
|
)
|
|
try:
|
|
data = response.json()
|
|
content = data["choices"][0]["message"]["content"] or ""
|
|
except (ValueError, KeyError, IndexError) as exc:
|
|
raise LLMError(
|
|
"Model summarization returned an unexpected response."
|
|
) from exc
|
|
record_usage(data.get("usage"))
|
|
self._record_native_cost(response)
|
|
return content
|
|
|
|
@staticmethod
|
|
def _record_native_cost(response: httpx.Response) -> None:
|
|
raw = response.headers.get("X-Gateway-Cost-USD")
|
|
if raw is None:
|
|
return
|
|
try:
|
|
record_cost(float(raw))
|
|
except (TypeError, ValueError):
|
|
return
|
|
|
|
@staticmethod
|
|
def _reason(response: httpx.Response) -> str:
|
|
try:
|
|
error = response.json().get("error")
|
|
except ValueError:
|
|
return response.text[:200] or response.reason_phrase
|
|
if isinstance(error, dict):
|
|
return str(error.get("message", error))
|
|
if error:
|
|
return str(error)
|
|
return response.reason_phrase
|