Files
ha-openai-compatible/custom_components/openai_compatible/ai_task.py
T
Adam SmithandClaude Sonnet 5 9d8c36f793 feat: vendor openai_conversation from core 2026.9.2, rename domain
Verbatim copy from home-assistant/core at tag 2026.9.2, with only the
domain constant and manifest changed. Apache-2.0, attribution in NOTICE.

Also fixed self-references the domain rename left dangling: the
config_entry selector's `integration:` field in services.yaml, the
`[%key:component::openai_conversation::...%]` translation
cross-references in strings.json, the deprecated-action descriptions
naming the old domain, and config_flow.py's instructions_url (now
pointing at this repo instead of the core docs page).

Local pytest run skipped per owner decision (see prior commit); files
validated with py_compile / json.load / yaml.safe_load instead. CI
(added in the prior commit) is the real gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01739Seac5eMAfNwFeiwV5r4
2026-09-17 17:23:37 -04:00

159 lines
5.2 KiB
Python

"""AI Task integration for OpenAI."""
import base64
from json import JSONDecodeError
import logging
from typing import TYPE_CHECKING, override
from openai.types.responses.response_output_item import ImageGenerationCall
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 .const import (
CONF_CHAT_MODEL,
CONF_IMAGE_MODEL,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_IMAGE_MODEL,
UNSUPPORTED_IMAGE_MODELS,
)
from .entity import OpenAIBaseLLMEntity
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigSubentry
from . import OpenAIConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OpenAIConfigEntry,
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(
[OpenAITaskEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class OpenAITaskEntity(
ai_task.AITaskEntity,
OpenAIBaseLLMEntity,
):
"""OpenAI AI Task entity."""
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity."""
super().__init__(entry, subentry)
self._attr_supported_features = (
ai_task.AITaskEntityFeature.GENERATE_DATA
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
)
model = self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
if not model.startswith(tuple(UNSUPPORTED_IMAGE_MODELS)):
self._attr_supported_features |= ai_task.AITaskEntityFeature.GENERATE_IMAGE
@override
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:
_LOGGER.error(
"Failed to parse JSON response: %s. Response: %s",
err,
text,
)
raise HomeAssistantError("Error with OpenAI structured response") from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=data,
)
@override
async def _async_generate_image(
self,
task: ai_task.GenImageTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenImageTaskResult:
"""Handle a generate image task."""
await self._async_handle_chat_log(chat_log, task.name, force_image=True)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
"Last content in chat log is not an AssistantContent"
)
image_call: ImageGenerationCall | None = None
for content in reversed(chat_log.content):
if not isinstance(content, conversation.AssistantContent):
break
if isinstance(content.native, ImageGenerationCall):
if image_call is None or image_call.result is None:
image_call = content.native
else: # Remove image data from chat log to save memory
content.native.result = None
if image_call is None or image_call.result is None:
raise HomeAssistantError("No image returned")
image_data = base64.b64decode(image_call.result)
image_call.result = None
if hasattr(image_call, "output_format") and (
output_format := image_call.output_format
):
mime_type = f"image/{output_format}"
else:
mime_type = "image/png"
if hasattr(image_call, "size") and (size := image_call.size):
width, height = tuple(size.split("x"))
else:
width, height = None, None
return ai_task.GenImageTaskResult(
image_data=image_data,
conversation_id=chat_log.conversation_id,
mime_type=mime_type,
width=int(width) if width else None,
height=int(height) if height else None,
model=self.subentry.data.get(CONF_IMAGE_MODEL, RECOMMENDED_IMAGE_MODEL),
revised_prompt=image_call.revised_prompt
if hasattr(image_call, "revised_prompt")
else None,
)