"""Small OpenAI-compatible chat completions client using the standard library.""" from __future__ import annotations import json from typing import Any import urllib.error import urllib.request from iris.config import IrisConfig from iris.errors import IrisAPIError class ChatCompletionsClient: """Calls a /v1/chat/completions-compatible endpoint.""" def __init__(self, config: IrisConfig): self.config = config def complete( self, messages: list[dict[str, str]], temperature: float | None = None ) -> str: payload = { "model": self.config.model, "messages": messages, "temperature": ( self.config.temperature if temperature is None else temperature ), "max_tokens": self.config.max_tokens, } response = self._post_json("/chat/completions", payload) try: content = response["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: raise IrisAPIError(f"Unexpected chat completion response: {response!r}") from exc if not isinstance(content, str) or not content.strip(): raise IrisAPIError( "Model endpoint returned no final message content. " "Increase IRIS_MAX_TOKENS or use a non-thinking model." ) return content def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: url = f"{self.config.api_base_url}{path}" headers = {"Content-Type": "application/json"} if self.config.api_key: headers["Authorization"] = f"Bearer {self.config.api_key}" request = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST", ) try: with urllib.request.urlopen( request, timeout=self.config.timeout_seconds ) as response: body = response.read().decode("utf-8") except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace")[:1000] raise IrisAPIError(f"HTTP {exc.code} from model endpoint: {detail}") from exc except urllib.error.URLError as exc: raise IrisAPIError(f"Could not reach model endpoint: {exc.reason}") from exc except TimeoutError as exc: raise IrisAPIError("Timed out waiting for model endpoint response") from exc try: return json.loads(body) except json.JSONDecodeError as exc: raise IrisAPIError(f"Endpoint returned non-JSON response: {body[:1000]}") from exc