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"