Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions homeassistant/components/open_responses/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""The Open Responses integration."""

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType

from .client import OpenResponsesClient
from .const import CONF_BASE_URL, DOMAIN

PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)

type OpenResponsesConfigEntry = ConfigEntry[OpenResponsesClient]


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Open Responses."""
return True


async def async_setup_entry(
hass: HomeAssistant, entry: OpenResponsesConfigEntry
) -> bool:
"""Set up Open Responses from a config entry."""
client = OpenResponsesClient(
get_async_client(hass),
api_key=entry.data[CONF_API_KEY],
base_url=entry.data[CONF_BASE_URL],
)

entry.runtime_data = client

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

entry.async_on_unload(entry.add_update_listener(async_update_options))

return True


async def async_unload_entry(
hass: HomeAssistant, entry: OpenResponsesConfigEntry
) -> bool:
"""Unload Open Responses."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


async def async_update_options(
hass: HomeAssistant, entry: OpenResponsesConfigEntry
) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)
92 changes: 92 additions & 0 deletions homeassistant/components/open_responses/ai_task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""AI Task integration for Open Responses."""

from json import JSONDecodeError
import logging
from typing import TYPE_CHECKING

from homeassistant.components import ai_task, conversation
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads

from .entity import OpenResponsesEntity

if TYPE_CHECKING:
from homeassistant.config_entries import ConfigSubentry

from . import OpenResponsesConfigEntry

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: OpenResponsesConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "ai_task_data":
continue

async_add_entities(
[OpenResponsesTaskEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)


class OpenResponsesTaskEntity(
ai_task.AITaskEntity,
OpenResponsesEntity,
):
"""Open Responses AI Task entity."""

def __init__(
self, entry: OpenResponsesConfigEntry, subentry: ConfigSubentry
) -> None:
"""Initialize the entity."""
super().__init__(entry, subentry)
self._attr_supported_features = (
ai_task.AITaskEntityFeature.GENERATE_DATA
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
)
Comment on lines +50 to +53

async def _async_generate_data(
self,
task: ai_task.GenDataTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(
chat_log, task.name, task.structure, max_iterations=1000
)

if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
"Last content in chat log is not an AssistantContent"
)

text = chat_log.content[-1].content or ""

if not task.structure:
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=text,
)
try:
data = json_loads(text)
except JSONDecodeError as err:
Comment on lines +61 to +79
_LOGGER.error(
"Failed to parse JSON response: %s. Response: %s",
err,
text,
)
raise HomeAssistantError(
"Error with Open Responses structured response"
) from err

return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=data,
)
182 changes: 182 additions & 0 deletions homeassistant/components/open_responses/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Client for Open Responses endpoints."""

from collections.abc import AsyncGenerator, AsyncIterable
import json
from typing import Any

from httpx import AsyncClient, HTTPStatusError, RequestError, Response
from openresponses_types.types import CreateResponseBody


class OpenResponsesError(Exception):
"""Base Open Responses client error."""


class OpenResponsesAuthError(OpenResponsesError):
"""Open Responses authentication error."""


class OpenResponsesRateLimitError(OpenResponsesError):
"""Open Responses rate limit error."""


class OpenResponsesInvalidModelError(OpenResponsesError):
"""Open Responses model validation error."""


class OpenResponsesConnectionError(OpenResponsesError):
"""Open Responses connection error."""


class OpenResponsesClient:
"""Minimal Open Responses HTTP client."""

def __init__(self, http_client: AsyncClient, api_key: str, base_url: str) -> None:
"""Initialize the client."""
self._http_client = http_client
self._url = f"{base_url.rstrip('/')}/responses"
self._json_headers = {
"authorization": f"Bearer {api_key}",
"accept": "application/json",
}
self._stream_headers = {
"authorization": f"Bearer {api_key}",
"accept": "text/event-stream",
}

async def create_response(self, **params: Any) -> dict[str, Any]:
"""Create a non-streaming response."""
body = _format_request_body({**params, "stream": False})

try:
response = await self._http_client.post(
self._url,
json=body,
headers=self._json_headers,
)
response.raise_for_status()
except HTTPStatusError as err:
_raise_client_error(err)
except RequestError as err:
raise OpenResponsesConnectionError(
"Error connecting to Open Responses endpoint"
) from err

return response.json()
Comment on lines +51 to +65

async def stream_response(self, **params: Any) -> AsyncGenerator[dict[str, Any]]:
"""Create a streaming response."""
body = _format_request_body({**params, "stream": True})

try:
async with self._http_client.stream(
"POST",
self._url,
json=body,
headers=self._stream_headers,
) as response:
response.raise_for_status()
async for event in _iter_sse_events(response.aiter_lines()):
yield event
except HTTPStatusError as err:
_raise_client_error(err)
except RequestError as err:
raise OpenResponsesConnectionError(
"Error connecting to Open Responses endpoint"
) from err


def _format_request_body(params: dict[str, Any]) -> dict[str, Any]:
"""Validate and format an Open Responses request body."""
CreateResponseBody(**params)
return _strip_none_values(params)


def _strip_none_values(value: Any) -> Any:
"""Strip null values while preserving request dictionaries."""
if isinstance(value, dict):
return {
key: _strip_none_values(item)
for key, item in value.items()
if item is not None
}
if isinstance(value, list):
return [_strip_none_values(item) for item in value]
return value


async def _iter_sse_events(lines: AsyncIterable[str]) -> AsyncGenerator[dict[str, Any]]:
"""Yield JSON server-sent events from an Open Responses stream."""
event_type: str | None = None
data_lines: list[str] = []
done = False

async def flush_event() -> dict[str, Any] | None:
nonlocal done, event_type, data_lines

if not data_lines:
event_type = None
return None

data = "\n".join(data_lines)
event_type_for_payload = event_type
event_type = None
data_lines = []

if data == "[DONE]":
done = True
return None

event = json.loads(data)
if event_type_for_payload and "type" not in event:
event["type"] = event_type_for_payload
return event

async for line in lines:
if done:
return
if not line:
if event := await flush_event():
yield event
if done:
return
continue
if line.startswith("event:"):
event_type = line.split(":", 1)[1].strip()
continue
if not line.startswith("data:"):
continue

data_lines.append(line.split(":", 1)[1].strip())

if event := await flush_event():
yield event


Comment on lines +134 to +155
Comment on lines +134 to +155
Comment on lines +134 to +155
def _raise_client_error(err: HTTPStatusError) -> None:
"""Raise Home Assistant-friendly client errors."""
status_code = err.response.status_code
if status_code in (401, 403):
raise OpenResponsesAuthError("Authentication failed")
if status_code == 429:
raise OpenResponsesRateLimitError("Rate limited")
if status_code == 400 and _response_error_mentions_model(err.response):
raise OpenResponsesInvalidModelError("Invalid model")
raise OpenResponsesConnectionError("Open Responses endpoint error")


def _response_error_mentions_model(response: Response) -> bool:
"""Return whether an error response points at the requested model."""
try:
body = response.json()
except ValueError:
return False

error = body.get("error") if isinstance(body, dict) else None
if not isinstance(error, dict):
return False

return any(
"model" in str(error.get(key, "")).lower()
for key in ("code", "param", "message")
)
Loading
Loading