diff --git a/.strict-typing b/.strict-typing index 9dd09c23e2bfae..81840e7d272ccc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -428,6 +428,7 @@ homeassistant.components.otp.* homeassistant.components.ouman_eh_800.* homeassistant.components.overkiz.* homeassistant.components.overseerr.* +homeassistant.components.ovhcloud_ai_endpoints.* homeassistant.components.p1_monitor.* homeassistant.components.paj_gps.* homeassistant.components.panel_custom.* diff --git a/CODEOWNERS b/CODEOWNERS index e8df625134e30f..3941899130c084 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1319,6 +1319,8 @@ CLAUDE.md @home-assistant/core /tests/components/overkiz/ @imicknl /homeassistant/components/overseerr/ @joostlek @AmGarera /tests/components/overseerr/ @joostlek @AmGarera +/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon +/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon /homeassistant/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001 /homeassistant/components/p1_monitor/ @klaasnicolaas diff --git a/homeassistant/components/ovhcloud_ai_endpoints/__init__.py b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py new file mode 100644 index 00000000000000..b01f04b39a4910 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py @@ -0,0 +1,80 @@ +"""The OVHcloud AI Endpoints integration.""" + +from openai import AsyncOpenAI, AuthenticationError, BadRequestError, OpenAIError +from openai.types.chat import ChatCompletionUserMessageParam + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client + +from .const import BASE_URL + +PLATFORMS = [Platform.CONVERSATION] + +type OVHcloudAIEndpointsConfigEntry = ConfigEntry[AsyncOpenAI] + + +def _create_client(hass: HomeAssistant, api_key: str) -> AsyncOpenAI: + """Create the AsyncOpenAI client used by this integration.""" + return AsyncOpenAI( + base_url=BASE_URL, + api_key=api_key, + http_client=get_async_client(hass), + ) + + +async def _validate_api_key(client: AsyncOpenAI) -> None: + """Validate the API key against the chat completions endpoint. + + We send a chat completion request with an unknown ``extra_body`` field + to prevent valid usage and billing. + A valid key triggers a 400 (BadRequestError), which we treat as success. + An invalid key triggers a 401 (AuthenticationError),which propagates + along with any other exception. + """ + try: + await client.with_options(timeout=10.0).chat.completions.create( + model="llama@latest", + messages=[ChatCompletionUserMessageParam(role="user", content="ping")], + extra_body={"foo": "bar"}, + ) + except BadRequestError: + return + + +async def async_setup_entry( + hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry +) -> bool: + """Set up OVHcloud AI Endpoints from a config entry.""" + client = _create_client(hass, entry.data[CONF_API_KEY]) + + try: + await _validate_api_key(client) + except AuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except OpenAIError as err: + raise ConfigEntryNotReady(err) from err + + entry.runtime_data = client + + entry.async_on_unload(entry.add_update_listener(async_update_entry)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_update_entry( + hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry +) -> None: + """Reload the entry when its data or subentries change.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry( + hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry +) -> bool: + """Unload OVHcloud AI Endpoints.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py new file mode 100644 index 00000000000000..00cca5f65f5f0f --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py @@ -0,0 +1,168 @@ +"""Config flow for the OVHcloud AI Endpoints integration.""" + +import logging +from typing import Any + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import callback +from homeassistant.helpers import llm +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from . import _create_client, _validate_api_key +from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS + +_LOGGER = logging.getLogger(__name__) + + +class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OVHcloud AI Endpoints.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + client = _create_client(self.hass, user_input[CONF_API_KEY]) + try: + await _validate_api_key(client) + except AuthenticationError: + errors["base"] = "invalid_auth" + except OpenAIError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="OVHcloud AI Endpoints", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class ConversationFlowHandler(ConfigSubentryFlow): + """Handle conversation subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.models: list[str] = [] + self.options: dict[str, Any] = {} + + async def _get_models(self) -> None: + """Fetch models from OVHcloud AI Endpoints.""" + client: AsyncOpenAI = self._get_entry().runtime_data + self.models = [ + model.id async for model in client.with_options(timeout=10.0).models.list() + ] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a conversation agent.""" + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + return await self.async_step_init(user_input) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage conversation agent configuration.""" + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + return self.async_create_entry( + title=user_input[CONF_MODEL], data=user_input + ) + + try: + await self._get_models() + except OpenAIError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + options = [ + SelectOptionDict(value=model_id, label=model_id) for model_id in self.models + ] + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": self.options.get( + CONF_PROMPT, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT], + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=self.options.get( + CONF_LLM_HASS_API, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ), + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/const.py b/homeassistant/components/ovhcloud_ai_endpoints/const.py new file mode 100644 index 00000000000000..e2a2f5b4d7ece0 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/const.py @@ -0,0 +1,16 @@ +"""Constants for the OVHcloud AI Endpoints integration.""" + +import logging + +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.helpers import llm + +DOMAIN = "ovhcloud_ai_endpoints" +LOGGER = logging.getLogger(__package__) + +BASE_URL = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/ovhcloud_ai_endpoints/conversation.py b/homeassistant/components/ovhcloud_ai_endpoints/conversation.py new file mode 100644 index 00000000000000..c033d5e35a2c8a --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/conversation.py @@ -0,0 +1,74 @@ +"""Conversation support for OVHcloud AI Endpoints.""" + +from typing import Literal + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OVHcloudAIEndpointsConfigEntry +from .const import DOMAIN +from .entity import OVHcloudAIEndpointsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OVHcloudAIEndpointsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + + for subentry in config_entry.get_subentries_of_type("conversation"): + async_add_entities( + [OVHcloudAIEndpointsConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OVHcloudAIEndpointsConversationEntity( + OVHcloudAIEndpointsEntity, conversation.ConversationEntity +): + """OVHcloud AI Endpoints conversation agent.""" + + _attr_name = None + + def __init__( + self, + entry: OVHcloudAIEndpointsConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process the user input and call the API.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/entity.py b/homeassistant/components/ovhcloud_ai_endpoints/entity.py new file mode 100644 index 00000000000000..06a094fb758f85 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/entity.py @@ -0,0 +1,228 @@ +"""Base entity for OVHcloud AI Endpoints.""" + +from collections.abc import AsyncGenerator, Callable +import json +import re +from typing import Any, Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCallParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.json import json_dumps + +from . import OVHcloudAIEndpointsConfigEntry +from .const import DOMAIN, LOGGER + +MAX_TOOL_ITERATIONS = 10 + +_THINK_PATTERN = re.compile(r"(.*?)", re.DOTALL) + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json_dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageFunctionToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json_dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +def _split_thinking(content: str | None) -> tuple[str | None, str | None]: + """Return (cleaned_content, thinking_content) extracted from ```` tags.""" + if not content: + return content, None + thinking_parts = [m.group(1).strip() for m in _THINK_PATTERN.finditer(content)] + if not thinking_parts: + return content, None + cleaned = _THINK_PATTERN.sub("", content).strip() or None + thinking = "\n\n".join(part for part in thinking_parts if part) or None + return cleaned, thinking + + +def _extract_thinking( + message: ChatCompletionMessage, +) -> tuple[str | None, str | None]: + """Return (cleaned_content, thinking_content) for an assistant message. + + Priority order: + 1. ``message.reasoning`` (OpenRouter, and vLLM >= 0.16.0 with a + ``reasoning_parser`` configured, following OpenAI's recommendation + for gpt-oss). + 2. ``message.reasoning_content`` (DeepSeek API, and vLLM < 0.16.0 + with a ``reasoning_parser`` configured). + 3. Inline ```` markup in ``message.content`` (any + reasoning model on vLLM without a ``reasoning_parser`` set). + """ + extras = message.model_extra or {} + for key in ("reasoning", "reasoning_content"): + value = extras.get(key) + if isinstance(value, str) and value.strip(): + return message.content, value.strip() + return _split_thinking(message.content) + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OVHcloud AI Endpoints message to a ChatLog format.""" + cleaned_content, thinking_content = _extract_thinking(message) + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": cleaned_content, + } + if thinking_content: + data["thinking_content"] = thinking_content + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if tool_call.type == "function" + ] + yield data + + +class OVHcloudAIEndpointsEntity(Entity): + """Base entity for OVHcloud AI Endpoints.""" + + _attr_has_entity_name = True + + def __init__( + self, + entry: OVHcloudAIEndpointsConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log(self, chat_log: conversation.ChatLog) -> None: + """Generate an answer for the chat log.""" + model_args: dict[str, Any] = { + "model": self.model, + } + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if tools: + model_args["tools"] = tools + + model_args["messages"] = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + client = self.entry.runtime_data + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create(**model_args) + except openai.OpenAIError as err: + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + if not result.choices: + LOGGER.error("API returned empty choices") + raise HomeAssistantError("API returned empty response") + + result_message = result.choices[0].message + + model_args["messages"].extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json new file mode 100644 index 00000000000000..25a133b638ca8b --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "ovhcloud_ai_endpoints", + "name": "OVHcloud AI Endpoints", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@Crocmagnon"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/ovhcloud_ai_endpoints", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==2.21.0"] +} diff --git a/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml b/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml new file mode 100644 index 00000000000000..6319411c1749a6 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml @@ -0,0 +1,94 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: configuration is per-subentry; documented via subentry strings + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless conversation entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates stateless entities + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: devices are created via subentries, not discovered dynamically + entity-category: + status: exempt + comment: conversation entity does not use entity categories + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity per subentry + entity-translations: + status: exempt + comment: conversation entity name comes from subentry title + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, deleted with the subentry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ovhcloud_ai_endpoints/strings.json b/homeassistant/components/ovhcloud_ai_endpoints/strings.json new file mode 100644 index 00000000000000..db699996bd1837 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/strings.json @@ -0,0 +1,50 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "An OVHcloud AI Endpoints API key" + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before configuring.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "model": "[%key:common::generic::model%]", + "prompt": "[%key:common::config_flow::data::prompt%]" + }, + "data_description": { + "llm_hass_api": "Select which tools the model can use to interact with your devices and entities.", + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." + }, + "description": "Configure the conversation agent" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 027db1f3deb345..146b7308e8c3b4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -549,6 +549,7 @@ "ourgroceries", "overkiz", "overseerr", + "ovhcloud_ai_endpoints", "ovo_energy", "owntracks", "p1_monitor", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 74528f76b1f0e8..f0a70f231a3b6c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5199,6 +5199,12 @@ "config_flow": true, "iot_class": "local_push" }, + "ovhcloud_ai_endpoints": { + "name": "OVHcloud AI Endpoints", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ovo_energy": { "name": "OVO Energy", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index b3f4f8e1dc4a91..ae3a12cc9ec04a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4037,6 +4037,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ovhcloud_ai_endpoints.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.p1_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index f34edafa14bb26..bfbcfd98830b7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1745,6 +1745,7 @@ open-meteo==0.3.2 # homeassistant.components.cloud # homeassistant.components.open_router # homeassistant.components.openai_conversation +# homeassistant.components.ovhcloud_ai_endpoints openai==2.21.0 # homeassistant.components.openerz diff --git a/tests/components/ovhcloud_ai_endpoints/__init__.py b/tests/components/ovhcloud_ai_endpoints/__init__.py new file mode 100644 index 00000000000000..3e90159874e1a1 --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/__init__.py @@ -0,0 +1,33 @@ +"""Tests for the OVHcloud AI Endpoints integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_openai_client.chat.completions.create.reset_mock() + + +def get_subentry_id(mock_config_entry: MockConfigEntry, subentry_type: str) -> str: + """Get the subentry ID for a given subentry type.""" + ids = [ + subentry_id + for subentry_id, subentry in mock_config_entry.subentries.items() + if subentry.subentry_type == subentry_type + ] + if not ids: + raise ValueError(f"No subentry found for type {subentry_type}") + return ids[0] diff --git a/tests/components/ovhcloud_ai_endpoints/conftest.py b/tests/components/ovhcloud_ai_endpoints/conftest.py new file mode 100644 index 00000000000000..02e534ca0347f3 --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/conftest.py @@ -0,0 +1,126 @@ +"""Fixtures for OVHcloud AI Endpoints integration tests.""" + +from collections.abc import AsyncGenerator, Generator +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from openai.types import CompletionUsage, Model +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest + +from homeassistant.components.ovhcloud_ai_endpoints.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_PROMPT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ovhcloud_ai_endpoints.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def enable_assist() -> bool: + """Toggle for whether the conversation subentry exposes the Assist API.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: + """Mock conversation subentry data.""" + res: dict[str, Any] = { + CONF_MODEL: "Meta-Llama-3_3-70B-Instruct", + CONF_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + conversation_subentry_data: dict[str, Any], +) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="OVHcloud AI Endpoints", + domain=DOMAIN, + data={ + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data=conversation_subentry_data, + subentry_id="ABCDEF", + subentry_type="conversation", + title="Meta-Llama-3_3-70B-Instruct", + unique_id=None, + ), + ], + ) + + +async def _build_models(hass: HomeAssistant) -> list[Model]: + """Load mocked models from the fixture file.""" + raw = await async_load_fixture(hass, "models.json", DOMAIN) + return [Model.model_validate(m) for m in json.loads(raw)["data"]] + + +@pytest.fixture +async def mock_openai_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Mock the AsyncOpenAI client used by the integration.""" + models = await _build_models(hass) + + async def _list_models(*args: Any, **kwargs: Any) -> AsyncGenerator[Model]: + for model in models: + yield model + + with patch( + "homeassistant.components.ovhcloud_ai_endpoints.AsyncOpenAI" + ) as mock_async_openai: + client = mock_async_openai.return_value + client.with_options.return_value = client + client.models.list = MagicMock(side_effect=_list_models) + client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="Meta-Llama-3_3-70B-Instruct", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/ovhcloud_ai_endpoints/fixtures/models.json b/tests/components/ovhcloud_ai_endpoints/fixtures/models.json new file mode 100644 index 00000000000000..a10ea85f7f5f39 --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/fixtures/models.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "id": "Meta-Llama-3_3-70B-Instruct", + "object": "model", + "created": 1700000000, + "owned_by": "ovhcloud" + }, + { + "id": "Mistral-Nemo-Instruct-2407", + "object": "model", + "created": 1700000000, + "owned_by": "ovhcloud" + } + ] +} diff --git a/tests/components/ovhcloud_ai_endpoints/snapshots/test_conversation.ambr b/tests/components/ovhcloud_ai_endpoints/snapshots/test_conversation.ambr new file mode 100644 index 00000000000000..835c1b96b7f151 --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/snapshots/test_conversation.ambr @@ -0,0 +1,230 @@ +# serializer version: 1 +# name: test_all_entities[assist][conversation.meta_llama_3_3_70b_instruct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.meta_llama_3_3_70b_instruct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ovhcloud_ai_endpoints', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.meta_llama_3_3_70b_instruct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Meta-Llama-3_3-70B-Instruct', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.meta_llama_3_3_70b_instruct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.meta_llama_3_3_70b_instruct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.meta_llama_3_3_70b_instruct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ovhcloud_ai_endpoints', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.meta_llama_3_3_70b_instruct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Meta-Llama-3_3-70B-Instruct', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.meta_llama_3_3_70b_instruct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', + 'content': 'Hello, how can I help you?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'What time is it?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'mock_tool_call_id', + 'tool_args': dict({ + }), + 'tool_name': 'HassGetCurrentTime', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'mock_tool_call_id', + 'tool_name': 'HassGetCurrentTime', + 'tool_result': dict({ + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), + }), + }), + }), + dict({ + 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', + 'content': '12:00 PM', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', + 'content': 'I have successfully called the function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/ovhcloud_ai_endpoints/test_config_flow.py b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py new file mode 100644 index 00000000000000..5c0e29df5476c5 --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py @@ -0,0 +1,248 @@ +"""Test the OVHcloud AI Endpoints config flow.""" + +from unittest.mock import AsyncMock + +import httpx +from openai import AuthenticationError, OpenAIError +import pytest + +from homeassistant.components.ovhcloud_ai_endpoints.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_PROMPT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_full_flow(hass: HomeAssistant, mock_openai_client: AsyncMock) -> None: + """Test the full config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OVHcloud AI Endpoints" + assert result["data"] == {CONF_API_KEY: "bla"} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_second_account( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a second account with a different API key can be added.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "different_key"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OVHcloud AI Endpoints" + assert result["data"] == {CONF_API_KEY: "different_key"} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + AuthenticationError( + message="invalid key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="POST", url="https://example.com"), + ), + body=None, + ), + "invalid_auth", + ), + (OpenAIError("boom"), "cannot_connect"), + (Exception("boom"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_errors( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test errors raised while validating the API key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_openai_client.chat.completions.create.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_openai_client.chat.completions.create.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_duplicate_entry( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry with the same API key already exists.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent subentry.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "init" + + assert result["data_schema"].schema["model"].config["options"] == [ + { + "value": "Meta-Llama-3_3-70B-Instruct", + "label": "Meta-Llama-3_3-70B-Instruct", + }, + {"value": "Mistral-Nemo-Instruct-2407", "label": "Mistral-Nemo-Instruct-2407"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "Meta-Llama-3_3-70B-Instruct", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Meta-Llama-3_3-70B-Instruct" + assert result["data"] == { + CONF_MODEL: "Meta-Llama-3_3-70B-Instruct", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without LLM API control.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "Mistral-Nemo-Instruct-2407", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "Mistral-Nemo-Instruct-2407", + CONF_PROMPT: "you are an assistant", + } + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (OpenAIError("boom"), "cannot_connect"), + (Exception("boom"), "unknown"), + ], +) +async def test_subentry_exceptions( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test the subentry flow aborts when the API call fails.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_openai_client.models.list.side_effect = exception + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_subentry_entry_not_loaded( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the subentry flow aborts when the parent entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" diff --git a/tests/components/ovhcloud_ai_endpoints/test_conversation.py b/tests/components/ovhcloud_ai_endpoints/test_conversation.py new file mode 100644 index 00000000000000..185f8ccc71e41b --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/test_conversation.py @@ -0,0 +1,461 @@ +"""Tests for the OVHcloud AI Endpoints conversation entity.""" + +import datetime +from unittest.mock import AsyncMock + +from freezegun import freeze_time +from openai import OpenAIError +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import ToolInput + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity registry snapshot for conversation entities.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + assert call["model"] == "Meta-Llama-3_3-70B-Instruct" + assert "extra_headers" not in call + assert "extra_body" not in call + assert "user" not in call + + +async def test_thinking_tags_extracted( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """```` markup must be extracted into thinking_content.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-thinking", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Let me think.\n\nThe answer is 42.", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="Qwen3-32B", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + + result = await conversation.async_converse( + hass, + "What is the answer?", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assistant = mock_chat_log.content[-1] + assert isinstance(assistant, conversation.AssistantContent) + assert assistant.content == "The answer is 42." + assert assistant.thinking_content == "Let me think." + + +async def test_thinking_only_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """A response containing only ```` should leave content as None.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-think-only", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Reasoning…", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="Qwen3-32B", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=5, prompt_tokens=8, total_tokens=13 + ), + ) + ) + + await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assistant = mock_chat_log.content[-1] + assert isinstance(assistant, conversation.AssistantContent) + assert assistant.content is None + assert assistant.thinking_content == "Reasoning…" + + +def _completion_with_extras(content: str | None, **extras: str) -> ChatCompletion: + """Build a ChatCompletion whose message carries extra (vLLM) fields.""" + return ChatCompletion( + id="chatcmpl-extras", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content=content, + role="assistant", + function_call=None, + tool_calls=None, + **extras, + ), + ) + ], + created=1700000000, + model="Qwen3-32B", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage(completion_tokens=9, prompt_tokens=8, total_tokens=17), + ) + + +async def test_reasoning_field_extracted( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Reasoning text in ``message.reasoning`` must populate thinking_content.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=_completion_with_extras( + "The answer is 42.", reasoning="Hidden chain of thought" + ) + ) + + await conversation.async_converse( + hass, + "What is the answer?", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assistant = mock_chat_log.content[-1] + assert isinstance(assistant, conversation.AssistantContent) + assert assistant.content == "The answer is 42." + assert assistant.thinking_content == "Hidden chain of thought" + + +async def test_reasoning_content_field_extracted( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Reasoning text in ``message.reasoning_content`` must populate thinking_content.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=_completion_with_extras( + "Final answer.", reasoning_content="DeepSeek-style reasoning" + ) + ) + + await conversation.async_converse( + hass, + "Question", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assistant = mock_chat_log.content[-1] + assert isinstance(assistant, conversation.AssistantContent) + assert assistant.content == "Final answer." + assert assistant.thinking_content == "DeepSeek-style reasoning" + + +async def test_reasoning_priority_over_think_tags( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """``message.reasoning`` wins over inline ```` markup in content.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=_completion_with_extras( + "from tagactual", reasoning="from field" + ) + ) + + await conversation.async_converse( + hass, + "Question", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assistant = mock_chat_log.content[-1] + assert isinstance(assistant, conversation.AssistantContent) + assert assistant.thinking_content == "from field" + # When the reasoning field is present, content is kept as-is — we trust + # the server to have placed the user-facing answer in `content` already. + assert assistant.content == "from tagactual" + + +async def test_empty_api_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """An empty choices response should yield an error conversation result.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[], + created=1700000000, + model="Meta-Llama-3_3-70B-Instruct", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8), + ) + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test tool calling end-to-end with the conversation entity.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_chat_log.async_add_user_content( + conversation.UserContent(content="What time is it?") + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id="conversation.meta_llama_3_3_70b_instruct", + tool_calls=[ + ToolInput( + tool_name="HassGetCurrentTime", + tool_args={}, + id="mock_tool_call_id", + external=True, + ) + ], + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.ToolResultContent( + agent_id="conversation.meta_llama_3_3_70b_instruct", + tool_call_id="mock_tool_call_id", + tool_name="HassGetCurrentTime", + tool_result={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + }, + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id="conversation.meta_llama_3_3_70b_instruct", + content="12:00 PM", + ) + ) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + } + ) + + mock_openai_client.chat.completions.create.side_effect = ( + ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="Meta-Llama-3_3-70B-Instruct", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="Meta-Llama-3_3-70B-Instruct", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ) + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + assert mock_openai_client.chat.completions.create.call_count == 2 + + +async def test_openai_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """An OpenAIError from the SDK should surface an error conversation result.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + + mock_openai_client.chat.completions.create.side_effect = OpenAIError("boom") + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.meta_llama_3_3_70b_instruct", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR diff --git a/tests/components/ovhcloud_ai_endpoints/test_init.py b/tests/components/ovhcloud_ai_endpoints/test_init.py new file mode 100644 index 00000000000000..0a2cbd9669ae71 --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/test_init.py @@ -0,0 +1,81 @@ +"""Tests for the OVHcloud AI Endpoints integration setup.""" + +from unittest.mock import AsyncMock + +from openai import OpenAIError + +from homeassistant.components.ovhcloud_ai_endpoints.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState, ConfigSubentry +from homeassistant.const import CONF_API_KEY, CONF_MODEL, CONF_PROMPT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_unload( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the integration is set up and torn down cleanly.""" + await setup_integration(hass, mock_config_entry, mock_openai_client) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_cannot_connect( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a connection error surfaces a setup retry.""" + mock_openai_client.chat.completions.create.side_effect = OpenAIError("boom") + + await setup_integration(hass, mock_config_entry, mock_openai_client) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_new_subentry_creates_entity_and_device( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A subentry added after setup must spawn its conversation entity and device.""" + entry = MockConfigEntry( + title="OVHcloud AI Endpoints", + domain=DOMAIN, + data={CONF_API_KEY: "bla"}, + ) + await setup_integration(hass, entry, mock_openai_client) + assert entry.state is ConfigEntryState.LOADED + assert not er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + subentry = ConfigSubentry( + data={ + CONF_MODEL: "Meta-Llama-3_3-70B-Instruct", + CONF_PROMPT: "You are a helpful assistant.", + }, + subentry_type="conversation", + title="Meta-Llama-3_3-70B-Instruct", + unique_id=None, + ) + assert hass.config_entries.async_add_subentry(entry, subentry) is True + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert len(entities) == 1 + assert entities[0].domain == "conversation" + assert entities[0].unique_id == subentry.subentry_id + + device = device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + assert device is not None + assert device.name == "Meta-Llama-3_3-70B-Instruct"