From cd65faed0decb4fb86bd3a94430be593f8c705a9 Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Tue, 19 May 2026 23:45:32 +0200 Subject: [PATCH 1/6] ovhcloud ai endpoints: initial commit --- .strict-typing | 1 + CODEOWNERS | 2 + .../ovhcloud_ai_endpoints/__init__.py | 61 +++ .../ovhcloud_ai_endpoints/config_flow.py | 169 +++++++ .../components/ovhcloud_ai_endpoints/const.py | 16 + .../ovhcloud_ai_endpoints/conversation.py | 75 +++ .../ovhcloud_ai_endpoints/entity.py | 230 +++++++++ .../ovhcloud_ai_endpoints/manifest.json | 13 + .../ovhcloud_ai_endpoints/quality_scale.yaml | 94 ++++ .../ovhcloud_ai_endpoints/strings.json | 49 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../ovhcloud_ai_endpoints/__init__.py | 25 + .../ovhcloud_ai_endpoints/conftest.py | 132 +++++ .../fixtures/models.json | 16 + .../snapshots/test_conversation.ambr | 230 +++++++++ .../ovhcloud_ai_endpoints/test_config_flow.py | 236 +++++++++ .../test_conversation.py | 461 ++++++++++++++++++ .../ovhcloud_ai_endpoints/test_init.py | 81 +++ 22 files changed, 1910 insertions(+) create mode 100644 homeassistant/components/ovhcloud_ai_endpoints/__init__.py create mode 100644 homeassistant/components/ovhcloud_ai_endpoints/config_flow.py create mode 100644 homeassistant/components/ovhcloud_ai_endpoints/const.py create mode 100644 homeassistant/components/ovhcloud_ai_endpoints/conversation.py create mode 100644 homeassistant/components/ovhcloud_ai_endpoints/entity.py create mode 100644 homeassistant/components/ovhcloud_ai_endpoints/manifest.json create mode 100644 homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml create mode 100644 homeassistant/components/ovhcloud_ai_endpoints/strings.json create mode 100644 tests/components/ovhcloud_ai_endpoints/__init__.py create mode 100644 tests/components/ovhcloud_ai_endpoints/conftest.py create mode 100644 tests/components/ovhcloud_ai_endpoints/fixtures/models.json create mode 100644 tests/components/ovhcloud_ai_endpoints/snapshots/test_conversation.ambr create mode 100644 tests/components/ovhcloud_ai_endpoints/test_config_flow.py create mode 100644 tests/components/ovhcloud_ai_endpoints/test_conversation.py create mode 100644 tests/components/ovhcloud_ai_endpoints/test_init.py diff --git a/.strict-typing b/.strict-typing index 9dd09c23e2bfa..81840e7d272cc 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 19412a1081e6a..009e0887bbcf3 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 0000000000000..5b6eaa3c6076c --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py @@ -0,0 +1,61 @@ +"""The OVHcloud AI Endpoints integration.""" + +from openai import AsyncOpenAI, OpenAIError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import 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 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: + # Unfortunately I couldn't find an endpoint that would authenticate the key + # without calling an LLM. This always succeeds regardless of auth. + async for _ in client.with_options(timeout=10.0).models.list(): + break + 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 0000000000000..322e41bcd72fc --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py @@ -0,0 +1,169 @@ +"""Config flow for the OVHcloud AI Endpoints integration.""" + +import logging +from typing import Any + +from openai import AsyncOpenAI, 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 +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: + # Unfortunately I couldn't find an endpoint that would authenticate the key + # without calling an LLM. This always succeeds regardless of auth. + async for _ in client.with_options(timeout=10.0).models.list(): + break + 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 0000000000000..e2a2f5b4d7ece --- /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 0000000000000..21a345036be28 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/conversation.py @@ -0,0 +1,75 @@ +"""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_id, subentry in config_entry.subentries.items(): + if subentry.subentry_type != "conversation": + continue + async_add_entities( + [OVHcloudAIEndpointsConversationEntity(config_entry, subentry)], + config_subentry_id=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 0000000000000..2e8d0c7911e98 --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/entity.py @@ -0,0 +1,230 @@ +"""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 + +# Reasoning models served by vLLM (Qwen3, gpt-oss, …) emit their chain of +# thought wrapped in when no server-side reasoning parser is +# configured. We extract it into AssistantContent.thinking_content so it is +# not spoken/displayed by Assist. +_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 some vLLM configurations). + 2. ``message.reasoning_content`` (vLLM with ``--reasoning-parser``, + DeepSeek API). + 3. Inline ```` markup in ``message.content`` (Qwen3 + on vLLM without a server-side parser). + """ + 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 0000000000000..25a133b638ca8 --- /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 0000000000000..6319411c1749a --- /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 0000000000000..3fcfd3b11272b --- /dev/null +++ b/homeassistant/components/ovhcloud_ai_endpoints/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "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 027db1f3deb34..146b7308e8c3b 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 da97a011a38f4..8c2e256d8cc09 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5204,6 +5204,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 b3f4f8e1dc4a9..ae3a12cc9ec04 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 fe3a557503358..0ba13488fd942 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/requirements_test_all.txt b/requirements_test_all.txt index 1cfe6cf766174..a1a06297d52a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1537,6 +1537,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 0000000000000..5c01db5df763b --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/__init__.py @@ -0,0 +1,25 @@ +"""Tests for the OVHcloud AI Endpoints integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> 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() + + +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 0000000000000..f554f126d8fb1 --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/conftest.py @@ -0,0 +1,132 @@ +"""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._create_client" + ) as mock_create_client, + patch( + "homeassistant.components.ovhcloud_ai_endpoints.config_flow._create_client", + new=mock_create_client, + ), + ): + client = mock_create_client.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 0000000000000..a10ea85f7f5f3 --- /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 0000000000000..835c1b96b7f15 --- /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 0000000000000..c2dd1eb756142 --- /dev/null +++ b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py @@ -0,0 +1,236 @@ +"""Test the OVHcloud AI Endpoints config flow.""" + +from unittest.mock import AsyncMock + +from openai import 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"), + [ + (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.models.list.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.models.list.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) + + 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) + + 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.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 0000000000000..cac4bc6c9c43c --- /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) + + 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) + 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.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.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.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.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.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.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_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.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 0000000000000..ca2f4ad28b3d7 --- /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) + 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.models.list.side_effect = OpenAIError("boom") + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_dynamic_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) + 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" From 24b47c7569c80bd99212e57630646afe8c5b066f Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Thu, 21 May 2026 11:31:58 +0200 Subject: [PATCH 2/6] ovhcloud_ai_endpoints: check for authentication --- .../ovhcloud_ai_endpoints/__init__.py | 31 +++++++++++++++---- .../ovhcloud_ai_endpoints/config_flow.py | 11 +++---- .../ovhcloud_ai_endpoints/entity.py | 16 +++++----- .../ovhcloud_ai_endpoints/strings.json | 1 + .../ovhcloud_ai_endpoints/__init__.py | 4 +++ .../ovhcloud_ai_endpoints/test_config_flow.py | 18 +++++++++-- .../ovhcloud_ai_endpoints/test_init.py | 2 +- 7 files changed, 58 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/__init__.py b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py index 5b6eaa3c6076c..b01f04b39a491 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/__init__.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/__init__.py @@ -1,11 +1,12 @@ """The OVHcloud AI Endpoints integration.""" -from openai import AsyncOpenAI, OpenAIError +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 ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client from .const import BASE_URL @@ -24,6 +25,25 @@ def _create_client(hass: HomeAssistant, api_key: str) -> AsyncOpenAI: ) +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: @@ -31,10 +51,9 @@ async def async_setup_entry( client = _create_client(hass, entry.data[CONF_API_KEY]) try: - # Unfortunately I couldn't find an endpoint that would authenticate the key - # without calling an LLM. This always succeeds regardless of auth. - async for _ in client.with_options(timeout=10.0).models.list(): - break + await _validate_api_key(client) + except AuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err except OpenAIError as err: raise ConfigEntryNotReady(err) from err diff --git a/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py index 322e41bcd72fc..00cca5f65f5f0 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/config_flow.py @@ -3,7 +3,7 @@ import logging from typing import Any -from openai import AsyncOpenAI, OpenAIError +from openai import AsyncOpenAI, AuthenticationError, OpenAIError import voluptuous as vol from homeassistant.config_entries import ( @@ -25,7 +25,7 @@ TemplateSelector, ) -from . import _create_client +from . import _create_client, _validate_api_key from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS _LOGGER = logging.getLogger(__name__) @@ -54,10 +54,9 @@ async def async_step_user( self._async_abort_entries_match(user_input) client = _create_client(self.hass, user_input[CONF_API_KEY]) try: - # Unfortunately I couldn't find an endpoint that would authenticate the key - # without calling an LLM. This always succeeds regardless of auth. - async for _ in client.with_options(timeout=10.0).models.list(): - break + await _validate_api_key(client) + except AuthenticationError: + errors["base"] = "invalid_auth" except OpenAIError: errors["base"] = "cannot_connect" except Exception: diff --git a/homeassistant/components/ovhcloud_ai_endpoints/entity.py b/homeassistant/components/ovhcloud_ai_endpoints/entity.py index 2e8d0c7911e98..06a094fb758f8 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/entity.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/entity.py @@ -33,10 +33,6 @@ MAX_TOOL_ITERATIONS = 10 -# Reasoning models served by vLLM (Qwen3, gpt-oss, …) emit their chain of -# thought wrapped in when no server-side reasoning parser is -# configured. We extract it into AssistantContent.thinking_content so it is -# not spoken/displayed by Assist. _THINK_PATTERN = re.compile(r"(.*?)", re.DOTALL) @@ -121,11 +117,13 @@ def _extract_thinking( """Return (cleaned_content, thinking_content) for an assistant message. Priority order: - 1. ``message.reasoning`` (OpenRouter and some vLLM configurations). - 2. ``message.reasoning_content`` (vLLM with ``--reasoning-parser``, - DeepSeek API). - 3. Inline ```` markup in ``message.content`` (Qwen3 - on vLLM without a server-side parser). + 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"): diff --git a/homeassistant/components/ovhcloud_ai_endpoints/strings.json b/homeassistant/components/ovhcloud_ai_endpoints/strings.json index 3fcfd3b11272b..db699996bd183 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/strings.json +++ b/homeassistant/components/ovhcloud_ai_endpoints/strings.json @@ -5,6 +5,7 @@ }, "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": { diff --git a/tests/components/ovhcloud_ai_endpoints/__init__.py b/tests/components/ovhcloud_ai_endpoints/__init__.py index 5c01db5df763b..73418a05fb023 100644 --- a/tests/components/ovhcloud_ai_endpoints/__init__.py +++ b/tests/components/ovhcloud_ai_endpoints/__init__.py @@ -12,6 +12,10 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + client = getattr(config_entry, "runtime_data", None) + if client is not None: + 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.""" diff --git a/tests/components/ovhcloud_ai_endpoints/test_config_flow.py b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py index c2dd1eb756142..f9e5cf5d6aac8 100644 --- a/tests/components/ovhcloud_ai_endpoints/test_config_flow.py +++ b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock -from openai import OpenAIError +import httpx +from openai import AuthenticationError, OpenAIError import pytest from homeassistant.components.ovhcloud_ai_endpoints.const import DOMAIN @@ -61,6 +62,17 @@ async def test_second_account( @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"), ], @@ -77,7 +89,7 @@ async def test_form_errors( DOMAIN, context={"source": SOURCE_USER} ) - mock_openai_client.models.list.side_effect = exception + mock_openai_client.chat.completions.create.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -87,7 +99,7 @@ async def test_form_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_openai_client.models.list.side_effect = None + mock_openai_client.chat.completions.create.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/ovhcloud_ai_endpoints/test_init.py b/tests/components/ovhcloud_ai_endpoints/test_init.py index ca2f4ad28b3d7..9828aad453226 100644 --- a/tests/components/ovhcloud_ai_endpoints/test_init.py +++ b/tests/components/ovhcloud_ai_endpoints/test_init.py @@ -35,7 +35,7 @@ async def test_setup_cannot_connect( mock_config_entry: MockConfigEntry, ) -> None: """Test that a connection error surfaces a setup retry.""" - mock_openai_client.models.list.side_effect = OpenAIError("boom") + mock_openai_client.chat.completions.create.side_effect = OpenAIError("boom") await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From bdd4b728e010627be03584d315c7cf1dbe2e8daf Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Thu, 21 May 2026 21:43:42 +0200 Subject: [PATCH 3/6] use helper --- .../components/ovhcloud_ai_endpoints/conversation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ovhcloud_ai_endpoints/conversation.py b/homeassistant/components/ovhcloud_ai_endpoints/conversation.py index 21a345036be28..c033d5e35a2c8 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/conversation.py +++ b/homeassistant/components/ovhcloud_ai_endpoints/conversation.py @@ -19,12 +19,11 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - for subentry_id, subentry in config_entry.subentries.items(): - if subentry.subentry_type != "conversation": - continue + + for subentry in config_entry.get_subentries_of_type("conversation"): async_add_entities( [OVHcloudAIEndpointsConversationEntity(config_entry, subentry)], - config_subentry_id=subentry_id, + config_subentry_id=subentry.subentry_id, ) From c2640f6a458aa393345d31cdbf704fdb5d05589b Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Thu, 21 May 2026 21:48:27 +0200 Subject: [PATCH 4/6] refactor tests --- .../ovhcloud_ai_endpoints/__init__.py | 12 +++++++---- .../ovhcloud_ai_endpoints/test_config_flow.py | 6 +++--- .../test_conversation.py | 20 +++++++++---------- .../ovhcloud_ai_endpoints/test_init.py | 6 +++--- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/tests/components/ovhcloud_ai_endpoints/__init__.py b/tests/components/ovhcloud_ai_endpoints/__init__.py index 73418a05fb023..3e90159874e1a 100644 --- a/tests/components/ovhcloud_ai_endpoints/__init__.py +++ b/tests/components/ovhcloud_ai_endpoints/__init__.py @@ -1,20 +1,24 @@ """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) -> None: +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() - client = getattr(config_entry, "runtime_data", None) - if client is not None: - client.chat.completions.create.reset_mock() + mock_openai_client.chat.completions.create.reset_mock() def get_subentry_id(mock_config_entry: MockConfigEntry, subentry_type: str) -> str: diff --git a/tests/components/ovhcloud_ai_endpoints/test_config_flow.py b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py index f9e5cf5d6aac8..5c0e29df5476c 100644 --- a/tests/components/ovhcloud_ai_endpoints/test_config_flow.py +++ b/tests/components/ovhcloud_ai_endpoints/test_config_flow.py @@ -138,7 +138,7 @@ async def test_create_conversation_agent( mock_config_entry: MockConfigEntry, ) -> None: """Test creating a conversation agent subentry.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) result = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "conversation"), @@ -180,7 +180,7 @@ async def test_create_conversation_agent_no_control( mock_config_entry: MockConfigEntry, ) -> None: """Test creating a conversation agent without LLM API control.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) result = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "conversation"), @@ -219,7 +219,7 @@ async def test_subentry_exceptions( reason: str, ) -> None: """Test the subentry flow aborts when the API call fails.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_openai_client.models.list.side_effect = exception diff --git a/tests/components/ovhcloud_ai_endpoints/test_conversation.py b/tests/components/ovhcloud_ai_endpoints/test_conversation.py index cac4bc6c9c43c..185f8ccc71e41 100644 --- a/tests/components/ovhcloud_ai_endpoints/test_conversation.py +++ b/tests/components/ovhcloud_ai_endpoints/test_conversation.py @@ -43,7 +43,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test entity registry snapshot for conversation entities.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -56,7 +56,7 @@ async def test_default_prompt( mock_chat_log: MockChatLog, # noqa: F811 ) -> None: """Test that the default prompt works.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) result = await conversation.async_converse( hass, "hello", @@ -81,7 +81,7 @@ async def test_thinking_tags_extracted( mock_chat_log: MockChatLog, # noqa: F811 ) -> None: """```` markup must be extracted into thinking_content.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_openai_client.chat.completions.create = AsyncMock( return_value=ChatCompletion( @@ -130,7 +130,7 @@ async def test_thinking_only_response( mock_chat_log: MockChatLog, # noqa: F811 ) -> None: """A response containing only ```` should leave content as None.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_openai_client.chat.completions.create = AsyncMock( return_value=ChatCompletion( @@ -203,7 +203,7 @@ async def test_reasoning_field_extracted( mock_chat_log: MockChatLog, # noqa: F811 ) -> None: """Reasoning text in ``message.reasoning`` must populate thinking_content.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_openai_client.chat.completions.create = AsyncMock( return_value=_completion_with_extras( @@ -232,7 +232,7 @@ async def test_reasoning_content_field_extracted( mock_chat_log: MockChatLog, # noqa: F811 ) -> None: """Reasoning text in ``message.reasoning_content`` must populate thinking_content.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_openai_client.chat.completions.create = AsyncMock( return_value=_completion_with_extras( @@ -261,7 +261,7 @@ async def test_reasoning_priority_over_think_tags( mock_chat_log: MockChatLog, # noqa: F811 ) -> None: """``message.reasoning`` wins over inline ```` markup in content.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_openai_client.chat.completions.create = AsyncMock( return_value=_completion_with_extras( @@ -292,7 +292,7 @@ async def test_empty_api_response( mock_chat_log: MockChatLog, # noqa: F811 ) -> None: """An empty choices response should yield an error conversation result.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_openai_client.chat.completions.create = AsyncMock( return_value=ChatCompletion( @@ -326,7 +326,7 @@ async def test_function_call( mock_openai_client: AsyncMock, ) -> None: """Test tool calling end-to-end with the conversation entity.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_chat_log.async_add_user_content( conversation.UserContent(content="What time is it?") @@ -446,7 +446,7 @@ async def test_openai_error( 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) + await setup_integration(hass, mock_config_entry, mock_openai_client) mock_openai_client.chat.completions.create.side_effect = OpenAIError("boom") diff --git a/tests/components/ovhcloud_ai_endpoints/test_init.py b/tests/components/ovhcloud_ai_endpoints/test_init.py index 9828aad453226..bed0210cb575f 100644 --- a/tests/components/ovhcloud_ai_endpoints/test_init.py +++ b/tests/components/ovhcloud_ai_endpoints/test_init.py @@ -21,7 +21,7 @@ async def test_setup_unload( mock_config_entry: MockConfigEntry, ) -> None: """Test the integration is set up and torn down cleanly.""" - await setup_integration(hass, mock_config_entry) + 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) @@ -37,7 +37,7 @@ async def test_setup_cannot_connect( """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) + await setup_integration(hass, mock_config_entry, mock_openai_client) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -53,7 +53,7 @@ async def test_dynamic_subentry_creates_entity_and_device( domain=DOMAIN, data={CONF_API_KEY: "bla"}, ) - await setup_integration(hass, entry) + 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) From 47a5fe1d8fc3b339f9e5b37776f42c1eec4bb542 Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Thu, 21 May 2026 22:04:09 +0200 Subject: [PATCH 5/6] fix test patch --- tests/components/ovhcloud_ai_endpoints/conftest.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/components/ovhcloud_ai_endpoints/conftest.py b/tests/components/ovhcloud_ai_endpoints/conftest.py index f554f126d8fb1..02e534ca0347f 100644 --- a/tests/components/ovhcloud_ai_endpoints/conftest.py +++ b/tests/components/ovhcloud_ai_endpoints/conftest.py @@ -87,16 +87,10 @@ async def _list_models(*args: Any, **kwargs: Any) -> AsyncGenerator[Model]: for model in models: yield model - with ( - patch( - "homeassistant.components.ovhcloud_ai_endpoints._create_client" - ) as mock_create_client, - patch( - "homeassistant.components.ovhcloud_ai_endpoints.config_flow._create_client", - new=mock_create_client, - ), - ): - client = mock_create_client.return_value + 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( From 1968bfbe66b8421e2907430fa2b8864ea8f2ab41 Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Thu, 21 May 2026 22:05:31 +0200 Subject: [PATCH 6/6] rename test --- tests/components/ovhcloud_ai_endpoints/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/ovhcloud_ai_endpoints/test_init.py b/tests/components/ovhcloud_ai_endpoints/test_init.py index bed0210cb575f..0a2cbd9669ae7 100644 --- a/tests/components/ovhcloud_ai_endpoints/test_init.py +++ b/tests/components/ovhcloud_ai_endpoints/test_init.py @@ -41,7 +41,7 @@ async def test_setup_cannot_connect( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_dynamic_subentry_creates_entity_and_device( +async def test_new_subentry_creates_entity_and_device( hass: HomeAssistant, mock_openai_client: AsyncMock, entity_registry: er.EntityRegistry,