Skip to content

Commit d8d9f2f

Browse files
committed
ovhcloud ai endpoints: initial commit
1 parent b2fb5df commit d8d9f2f

22 files changed

Lines changed: 1911 additions & 0 deletions

.strict-typing

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ homeassistant.components.otp.*
428428
homeassistant.components.ouman_eh_800.*
429429
homeassistant.components.overkiz.*
430430
homeassistant.components.overseerr.*
431+
homeassistant.components.ovhcloud_ai_endpoints.*
431432
homeassistant.components.p1_monitor.*
432433
homeassistant.components.paj_gps.*
433434
homeassistant.components.panel_custom.*

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""The OVHcloud AI Endpoints integration."""
2+
3+
from openai import AsyncOpenAI, OpenAIError
4+
5+
from homeassistant.config_entries import ConfigEntry
6+
from homeassistant.const import CONF_API_KEY, Platform
7+
from homeassistant.core import HomeAssistant
8+
from homeassistant.exceptions import ConfigEntryNotReady
9+
from homeassistant.helpers.httpx_client import get_async_client
10+
11+
from .const import BASE_URL
12+
13+
PLATFORMS = [Platform.CONVERSATION]
14+
15+
type OVHcloudAIEndpointsConfigEntry = ConfigEntry[AsyncOpenAI]
16+
17+
18+
async def async_setup_entry(
19+
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
20+
) -> bool:
21+
"""Set up OVHcloud AI Endpoints from a config entry."""
22+
client = AsyncOpenAI(
23+
base_url=BASE_URL,
24+
api_key=entry.data[CONF_API_KEY],
25+
http_client=get_async_client(hass),
26+
)
27+
28+
try:
29+
# Unfortunately I couldn't find an endpoint that would authenticate the key
30+
# without calling an LLM. This always succeeds regardless of auth.
31+
async for _ in client.with_options(timeout=10.0).models.list():
32+
break
33+
except OpenAIError as err:
34+
raise ConfigEntryNotReady(err) from err
35+
36+
entry.runtime_data = client
37+
38+
entry.async_on_unload(entry.add_update_listener(async_update_entry))
39+
40+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
41+
42+
return True
43+
44+
45+
async def async_update_entry(
46+
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
47+
) -> None:
48+
"""Reload the entry when its data or subentries change."""
49+
await hass.config_entries.async_reload(entry.entry_id)
50+
51+
52+
async def async_unload_entry(
53+
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
54+
) -> bool:
55+
"""Unload OVHcloud AI Endpoints."""
56+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Config flow for the OVHcloud AI Endpoints integration."""
2+
3+
import logging
4+
from typing import Any
5+
6+
from openai import AsyncOpenAI, OpenAIError
7+
import voluptuous as vol
8+
9+
from homeassistant.config_entries import (
10+
ConfigEntry,
11+
ConfigEntryState,
12+
ConfigFlow,
13+
ConfigFlowResult,
14+
ConfigSubentryFlow,
15+
SubentryFlowResult,
16+
)
17+
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL
18+
from homeassistant.core import callback
19+
from homeassistant.helpers import llm
20+
from homeassistant.helpers.httpx_client import get_async_client
21+
from homeassistant.helpers.selector import (
22+
SelectOptionDict,
23+
SelectSelector,
24+
SelectSelectorConfig,
25+
SelectSelectorMode,
26+
TemplateSelector,
27+
)
28+
29+
from .const import BASE_URL, CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
30+
31+
_LOGGER = logging.getLogger(__name__)
32+
33+
34+
class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
35+
"""Handle a config flow for OVHcloud AI Endpoints."""
36+
37+
VERSION = 1
38+
MINOR_VERSION = 1
39+
40+
@classmethod
41+
@callback
42+
def async_get_supported_subentry_types(
43+
cls, config_entry: ConfigEntry
44+
) -> dict[str, type[ConfigSubentryFlow]]:
45+
"""Return subentries supported by this handler."""
46+
return {"conversation": ConversationFlowHandler}
47+
48+
async def async_step_user(
49+
self, user_input: dict[str, Any] | None = None
50+
) -> ConfigFlowResult:
51+
"""Handle the initial step."""
52+
errors: dict[str, str] = {}
53+
if user_input is not None:
54+
self._async_abort_entries_match(user_input)
55+
client = AsyncOpenAI(
56+
base_url=BASE_URL,
57+
api_key=user_input[CONF_API_KEY],
58+
http_client=get_async_client(self.hass),
59+
)
60+
try:
61+
# Unfortunately I couldn't find an endpoint that would authenticate the key
62+
# without calling an LLM. This always succeeds regardless of auth.
63+
async for _ in client.with_options(timeout=10.0).models.list():
64+
break
65+
except OpenAIError:
66+
errors["base"] = "cannot_connect"
67+
except Exception:
68+
_LOGGER.exception("Unexpected exception")
69+
errors["base"] = "unknown"
70+
else:
71+
return self.async_create_entry(
72+
title="OVHcloud AI Endpoints",
73+
data=user_input,
74+
)
75+
return self.async_show_form(
76+
step_id="user",
77+
data_schema=vol.Schema(
78+
{
79+
vol.Required(CONF_API_KEY): str,
80+
}
81+
),
82+
errors=errors,
83+
)
84+
85+
86+
class ConversationFlowHandler(ConfigSubentryFlow):
87+
"""Handle conversation subentry flow."""
88+
89+
def __init__(self) -> None:
90+
"""Initialize the subentry flow."""
91+
self.models: list[str] = []
92+
self.options: dict[str, Any] = {}
93+
94+
async def _get_models(self) -> None:
95+
"""Fetch models from OVHcloud AI Endpoints."""
96+
client: AsyncOpenAI = self._get_entry().runtime_data
97+
self.models = [
98+
model.id async for model in client.with_options(timeout=10.0).models.list()
99+
]
100+
101+
async def async_step_user(
102+
self, user_input: dict[str, Any] | None = None
103+
) -> SubentryFlowResult:
104+
"""User flow to create a conversation agent."""
105+
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
106+
return await self.async_step_init(user_input)
107+
108+
async def async_step_init(
109+
self, user_input: dict[str, Any] | None = None
110+
) -> SubentryFlowResult:
111+
"""Manage conversation agent configuration."""
112+
if self._get_entry().state != ConfigEntryState.LOADED:
113+
return self.async_abort(reason="entry_not_loaded")
114+
115+
if user_input is not None:
116+
if not user_input.get(CONF_LLM_HASS_API):
117+
user_input.pop(CONF_LLM_HASS_API, None)
118+
return self.async_create_entry(
119+
title=user_input[CONF_MODEL], data=user_input
120+
)
121+
122+
try:
123+
await self._get_models()
124+
except OpenAIError:
125+
return self.async_abort(reason="cannot_connect")
126+
except Exception:
127+
_LOGGER.exception("Unexpected exception")
128+
return self.async_abort(reason="unknown")
129+
130+
options = [
131+
SelectOptionDict(value=model_id, label=model_id) for model_id in self.models
132+
]
133+
134+
hass_apis: list[SelectOptionDict] = [
135+
SelectOptionDict(
136+
label=api.name,
137+
value=api.id,
138+
)
139+
for api in llm.async_get_apis(self.hass)
140+
]
141+
142+
return self.async_show_form(
143+
step_id="init",
144+
data_schema=vol.Schema(
145+
{
146+
vol.Required(CONF_MODEL): SelectSelector(
147+
SelectSelectorConfig(
148+
options=options,
149+
mode=SelectSelectorMode.DROPDOWN,
150+
sort=True,
151+
),
152+
),
153+
vol.Optional(
154+
CONF_PROMPT,
155+
description={
156+
"suggested_value": self.options.get(
157+
CONF_PROMPT,
158+
RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT],
159+
)
160+
},
161+
): TemplateSelector(),
162+
vol.Optional(
163+
CONF_LLM_HASS_API,
164+
default=self.options.get(
165+
CONF_LLM_HASS_API,
166+
RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API],
167+
),
168+
): SelectSelector(
169+
SelectSelectorConfig(options=hass_apis, multiple=True)
170+
),
171+
}
172+
),
173+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Constants for the OVHcloud AI Endpoints integration."""
2+
3+
import logging
4+
5+
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT
6+
from homeassistant.helpers import llm
7+
8+
DOMAIN = "ovhcloud_ai_endpoints"
9+
LOGGER = logging.getLogger(__package__)
10+
11+
BASE_URL = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"
12+
13+
RECOMMENDED_CONVERSATION_OPTIONS = {
14+
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
15+
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
16+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Conversation support for OVHcloud AI Endpoints."""
2+
3+
from typing import Literal
4+
5+
from homeassistant.components import conversation
6+
from homeassistant.config_entries import ConfigSubentry
7+
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
10+
11+
from . import OVHcloudAIEndpointsConfigEntry
12+
from .const import DOMAIN
13+
from .entity import OVHcloudAIEndpointsEntity
14+
15+
16+
async def async_setup_entry(
17+
hass: HomeAssistant,
18+
config_entry: OVHcloudAIEndpointsConfigEntry,
19+
async_add_entities: AddConfigEntryEntitiesCallback,
20+
) -> None:
21+
"""Set up conversation entities."""
22+
for subentry_id, subentry in config_entry.subentries.items():
23+
async_add_entities(
24+
[OVHcloudAIEndpointsConversationEntity(config_entry, subentry)],
25+
config_subentry_id=subentry_id,
26+
)
27+
28+
29+
class OVHcloudAIEndpointsConversationEntity(
30+
OVHcloudAIEndpointsEntity, conversation.ConversationEntity
31+
):
32+
"""OVHcloud AI Endpoints conversation agent."""
33+
34+
_attr_name = None
35+
36+
def __init__(
37+
self,
38+
entry: OVHcloudAIEndpointsConfigEntry,
39+
subentry: ConfigSubentry,
40+
) -> None:
41+
"""Initialize the agent."""
42+
super().__init__(entry, subentry)
43+
if self.subentry.data.get(CONF_LLM_HASS_API):
44+
self._attr_supported_features = (
45+
conversation.ConversationEntityFeature.CONTROL
46+
)
47+
48+
@property
49+
def supported_languages(self) -> list[str] | Literal["*"]:
50+
"""Return a list of supported languages."""
51+
return MATCH_ALL
52+
53+
async def _async_handle_message(
54+
self,
55+
user_input: conversation.ConversationInput,
56+
chat_log: conversation.ChatLog,
57+
) -> conversation.ConversationResult:
58+
"""Process the user input and call the API."""
59+
options = self.subentry.data
60+
61+
try:
62+
await chat_log.async_provide_llm_data(
63+
user_input.as_llm_context(DOMAIN),
64+
options.get(CONF_LLM_HASS_API),
65+
options.get(CONF_PROMPT),
66+
user_input.extra_system_prompt,
67+
)
68+
except conversation.ConverseError as err:
69+
return err.as_conversation_result()
70+
71+
await self._async_handle_chat_log(chat_log)
72+
73+
return conversation.async_get_result_from_chat_log(user_input, chat_log)

0 commit comments

Comments
 (0)