"""LLM handler.""" from __future__ import annotations import asyncio import base64 from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta from functools import wraps import io import json import logging from typing import ( TYPE_CHECKING, Any, Literal, ParamSpec, TypedDict, TypeVar, cast, ) from aiohttp import ( ClientResponse, ClientResponseError, ClientTimeout, ContentTypeError, FormData, ) from ..api import ApiBase, CloudApiError, api_exception_handler from ..exceptions import NabuCasaNotLoggedInError from ..utils import utc_from_timestamp, utcnow from .errors import ( LLMAuthenticationError, LLMError, LLMRateLimitError, LLMRequestError, LLMResponseError, LLMServiceError, ) from .stream_events import ( LLMStreamEventParseError, ResponsesApiStreamEvent, parse_response_stream_event, ) if TYPE_CHECKING: from .. import Cloud, _ClientT _LOGGER = logging.getLogger(__name__) ResponsesAPIResponse = dict[str, Any] ResponseInputParam = dict[str, Any] | list[Any] ToolParam = dict[str, Any] ToolChoice = Literal["auto", "none"] | dict[str, Any] class LLMConnectionDetails(TypedDict): """LLM connection details from LLM API.""" token: str valid_until: int base_url: str generate_data_model: str generate_image_model: str conversation_model: str class LLMGeneratedData(TypedDict): """LLM data generation response.""" content: str role: Literal["assistant", "user"] | None class LLMGeneratedImage(TypedDict): """Normalized LLM image generation response.""" mime_type: str image_data: bytes model: str | None width: int | None height: int | None revised_prompt: str | None class LLMImageAttachment(TypedDict): """Image attachment for editing requests.""" filename: str | None mime_type: str | None data: bytes IMAGE_MIME_TYPE = "image/png" TOKEN_EXP_BUFFER_MINUTES = timedelta(minutes=5) RESPONSES_API_TIMEOUT = 30.0 IMAGE_API_TIMEOUT = 120.0 @dataclass class _ServerSentEvent: """Minimal Server-Sent Event container.""" event: str | None = None data: str = "" id: str | None = None retry: int | None = None type JSONPrimitive = str | int | float | bool | None P = ParamSpec("P") R = TypeVar("R") class _SSEDecoder: """Decode Server-Sent Events (SSE) lines into events. Based on the WHATWG SSE spec. """ def __init__(self) -> None: self._event: str | None = None self._data: list[str] = [] self._last_event_id: str | None = None self._retry: int | None = None def decode(self, line: str) -> _ServerSentEvent | None: """Feed a single decoded line (without trailing newline). Returns an event when a blank line indicates the end of an SSE event. See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation """ # Blank line terminates the current event if not line: if ( not self._event and not self._data and not self._last_event_id and self._retry is None ): return None sse = _ServerSentEvent( event=self._event, data="\n".join(self._data), id=self._last_event_id, retry=self._retry, ) # NOTE: as per the SSE spec, do not reset last_event_id. self._event = None self._data = [] self._retry = None return sse # Comment line if line.startswith(":"): return None fieldname, _, value = line.partition(":") value = value.removeprefix(" ") if fieldname == "event": self._event = value elif fieldname == "data": self._data.append(value) elif fieldname == "id": if "\0" not in value: self._last_event_id = value elif fieldname == "retry": with suppress(TypeError, ValueError): self._retry = int(value) return None def llm_http_exception_handler( func: Callable[P, Awaitable[R]], ) -> Callable[P, Awaitable[R]]: """Convert HTTP/client errors into LLM-specific exceptions.""" @wraps(func) async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: return await func(*args, **kwargs) except LLMRequestError: raise except ClientResponseError as err: if err.status == 401: raise LLMAuthenticationError("Cloud LLM authentication failed") from err if err.status == 429: raise LLMRateLimitError("Cloud LLM is rate limited") from err raise LLMServiceError("Couldn't process Cloud LLM response") from err except CloudApiError as err: raise LLMServiceError("Error talking to Cloud LLM") from err except Exception as err: raise LLMServiceError("Unknown error talking to Cloud LLM") from err return wrapper async def stream_llm_response_events( response: ClientResponse, ) -> AsyncIterator[ResponsesApiStreamEvent]: """Yield response events from the Cloud LLM stream.""" try: decoder = _SSEDecoder() while True: line_bytes = await response.content.readline() if not line_bytes: break line = line_bytes.decode("utf-8").rstrip("\r\n") if (sse := decoder.decode(line)) is None: continue if not (data := sse.data): # Allow keepalive/empty events continue if data.startswith("[DONE]"): break try: payload = json.loads(data) except json.JSONDecodeError as err: raise LLMResponseError( "There was an error processing the Cloud LLM response" ) from err if not isinstance(payload, dict): raise LLMResponseError("Unexpected event from Cloud LLM stream") try: yield parse_response_stream_event(payload) except LLMStreamEventParseError as err: raise LLMResponseError( "Unexpected event from Cloud LLM stream" ) from err finally: response.release() class LLMHandler(ApiBase): """Class to handle LLM services.""" def __init__(self, cloud: Cloud[_ClientT]) -> None: """Initialize LLM services.""" super().__init__(cloud) self._token: str | None = None self._base_url: str | None = None self._models: dict[str, str | None] = {} self._valid_until: datetime | None = None self._lock = asyncio.Lock() def _validate_token(self) -> bool: """Validate token outside of coroutine.""" # Check subscription and token expiry with buffer return self._cloud.valid_subscription and bool( self._valid_until and utcnow() + TOKEN_EXP_BUFFER_MINUTES < self._valid_until ) @api_exception_handler(LLMAuthenticationError) async def _get_connection_details(self) -> LLMConnectionDetails: details: LLMConnectionDetails = await self._call_cloud_api( action="llm_connection_details", ) return details @api_exception_handler(LLMServiceError) async def _async_fetch_image_from_url(self, url: str) -> bytes: """Fetch image data from a URL asynchronously.""" async with self._cloud.websession.get(url) as response: response.raise_for_status() return await response.read() async def _update_connection_details(self) -> None: """Update connection details.""" if not self._cloud.valid_subscription: raise LLMAuthenticationError("Invalid subscription") details: LLMConnectionDetails = await self._get_connection_details() self._token = details["token"] self._valid_until = utc_from_timestamp(float(details["valid_until"])) self._base_url = details["base_url"] self._models = { "generate_data": details["generate_data_model"], "generate_image": details["generate_image_model"], "conversation": details["conversation_model"], } async def async_ensure_token(self) -> None: """Ensure the LLM token is valid and available.""" async with self._lock: if not self._cloud.is_logged_in: raise NabuCasaNotLoggedInError("User is not logged in") if not self._validate_token(): await self._update_connection_details() if not self._token or not self._base_url: raise LLMError("Cloud LLM connection details are unavailable") @llm_http_exception_handler async def _call_llm_api( self, endpoint: str, *, method: str = "POST", accept: str = "application/json", content_type: str | None = None, payload: dict[str, Any] | None = None, data: Any | None = None, include_path_in_log: bool = False, api_timeout: float = RESPONSES_API_TIMEOUT, ) -> ClientResponse: """Call the Cloud LLM API and ensure errors are handled uniformly.""" if TYPE_CHECKING: assert self._base_url is not None await self.async_ensure_token() url = f"{self._base_url.rstrip('/')}/{endpoint.lstrip('/')}" headers: dict[str, str] = { "Authorization": f"Bearer {self._token}", "Accept": accept, **({"Content-Type": content_type} if content_type is not None else {}), } response = await self._call_raw_api( method=method, url=url, headers=headers, jsondata=payload, data=data, client_timeout=ClientTimeout(total=api_timeout), include_path_in_log=include_path_in_log, ) if response.status >= 400: response.raise_for_status() return response async def _get_response( self, response: ClientResponse, ) -> dict[str, Any]: """Parse a JSON response from the Cloud LLM API.""" try: data = cast("dict[str, Any]", await response.json()) except (ContentTypeError, json.JSONDecodeError) as err: raise LLMResponseError("Invalid JSON response from Cloud LLM") from err return data def _build_responses_payload( self, *, model: str, messages: str | ResponseInputParam, conversation_id: str, response_format: dict[str, Any] | None, stream: bool, tools: Iterable[ToolParam] | None, tool_choice: ToolChoice | None, ) -> dict[str, Any]: """Build the payload for the Responses API.""" payload: dict[str, Any] = { "model": model, "input": messages, "stream": stream, "conversation": conversation_id, } if response_format is not None: payload["response_format"] = response_format if tools is not None: payload["tools"] = list(tools) if tool_choice is not None: payload["tool_choice"] = tool_choice return payload @llm_http_exception_handler async def _responses_api_call( self, payload: dict[str, Any], *, stream: bool, ) -> ResponsesAPIResponse | AsyncIterator[ResponsesApiStreamEvent]: """Call the Responses API via HTTP.""" accept = "text/event-stream" if stream else "application/json" response = await self._call_llm_api( "responses", accept=accept, content_type="application/json", payload=payload, api_timeout=RESPONSES_API_TIMEOUT, ) if not stream: return await self._get_response(response) return cast( "ResponsesAPIResponse | AsyncIterator[ResponsesApiStreamEvent]", stream_llm_response_events(response), ) async def _extract_response_image_data( self, response: dict[str, Any], ) -> LLMGeneratedImage: data = response.get("data") if not (data and isinstance(data, list) and len(data) > 0): raise LLMResponseError("Unexpected response from Cloud LLM") item = data[0] if not (b64 := item.get("b64_json")) and (url := item.get("url")) is not None: image_bytes = await self._async_fetch_image_from_url(url) b64 = base64.b64encode(image_bytes).decode("utf-8") if not b64: raise LLMResponseError( "Image generation response contains neither url nor b64_json" ) return LLMGeneratedImage( mime_type=IMAGE_MIME_TYPE, model=response.get("model"), image_data=base64.b64decode(b64), width=item.get("width"), height=item.get("height"), revised_prompt=item.get("revised_prompt"), ) async def async_generate_data( self, *, messages: str | ResponseInputParam, conversation_id: str, response_format: dict[str, Any] | None = None, stream: bool = False, tools: Iterable[ToolParam] | None = None, tool_choice: ToolChoice | None = None, ) -> ResponsesAPIResponse | AsyncIterator[ResponsesApiStreamEvent]: """Generate structured or free-form LLM data.""" if TYPE_CHECKING: assert self._models["generate_data"] is not None payload = self._build_responses_payload( model=self._models["generate_data"], messages=messages, conversation_id=conversation_id, response_format=response_format, stream=stream, tools=tools, tool_choice=tool_choice, ) return await self._responses_api_call( payload, stream=stream, ) async def async_generate_image( self, *, prompt: str, ) -> LLMGeneratedImage: """Generate an image via Cloud LLM.""" if TYPE_CHECKING: assert self._models["generate_image"] is not None payload = { "prompt": prompt, "model": self._models["generate_image"], } response = await self._call_llm_api( "images/generations", payload=payload, content_type="application/json", api_timeout=IMAGE_API_TIMEOUT, ) data = await self._get_response(response) return await self._extract_response_image_data(data) async def async_edit_image( self, *, prompt: str, attachments: list[LLMImageAttachment], ) -> LLMGeneratedImage: """Edit an image via Cloud LLM.""" if TYPE_CHECKING: assert self._models["generate_image"] is not None file_buffers: list[tuple[io.BytesIO, str | None]] = [] for idx, attachment in enumerate(attachments): buffer = io.BytesIO(attachment["data"]) buffer.name = attachment["filename"] or f"attachment_{idx}" file_buffers.append((buffer, attachment["mime_type"])) if not file_buffers: raise LLMRequestError("No attachments provided for LLM image editing") image_buffers = ( file_buffers if len(file_buffers) == 1 # HA doesn't support masks, so we can ignore it here at file_buffers[1] else [file_buffers[0], *file_buffers[2:]] ) form = FormData() for key, value in { "prompt": prompt, "model": self._models["generate_image"], }.items(): form.add_field(key, str(value)) for image_buffer, mime_type in image_buffers: image_buffer.seek(0) form.add_field( "image", image_buffer, filename=getattr(image_buffer, "name", "image.png"), content_type=mime_type or IMAGE_MIME_TYPE, ) response = await self._call_llm_api( "images/edits", data=form, api_timeout=IMAGE_API_TIMEOUT, ) result = await self._get_response(response) return await self._extract_response_image_data(result) async def async_process_conversation( self, *, messages: str | ResponseInputParam, conversation_id: str, response_format: dict[str, Any] | None = None, stream: bool = False, tools: Iterable[ToolParam] | None = None, tool_choice: ToolChoice | None = None, ) -> ResponsesAPIResponse | AsyncIterator[ResponsesApiStreamEvent]: """Generate a response for a conversation.""" if TYPE_CHECKING: assert self._models["conversation"] is not None payload = self._build_responses_payload( model=self._models["conversation"], messages=messages, conversation_id=conversation_id, response_format=response_format, stream=stream, tools=tools, tool_choice=tool_choice, ) return await self._responses_api_call( payload, stream=stream, )