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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

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

80 changes: 80 additions & 0 deletions homeassistant/components/ovhcloud_ai_endpoints/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +28 to +44
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about just fetching the models?

Copy link
Copy Markdown
Contributor Author

@Crocmagnon Crocmagnon May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Models list is not authenticated. Calling it with an invalid key results in a HTTP 200. I had a chat with the product team and this is what they suggested, for lack of a better option.

Comment on lines +28 to +44


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
Comment thread
Crocmagnon marked this conversation as resolved.

Comment on lines +51 to +59
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)
168 changes: 168 additions & 0 deletions homeassistant/components/ovhcloud_ai_endpoints/config_flow.py
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
Crocmagnon marked this conversation as resolved.
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()
]
Comment on lines +89 to +94
Comment on lines +92 to +94

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,
)
Comment on lines +125 to +133
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)
),
}
),
)
16 changes: 16 additions & 0 deletions homeassistant/components/ovhcloud_ai_endpoints/const.py
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we call this _endpoints?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s the name of the product, see https://www.ovhcloud.com/fr/public-cloud/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,
}
74 changes: 74 additions & 0 deletions homeassistant/components/ovhcloud_ai_endpoints/conversation.py
Original file line number Diff line number Diff line change
@@ -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,
)
Comment thread
Crocmagnon marked this conversation as resolved.


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)
Loading