mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 03:33:19 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61edf4b0eb | |||
| 759429a9d9 | |||
| 8ca7ab7957 | |||
| 66d4124439 | |||
| 99877d79e3 | |||
| 978171b600 | |||
| 4bd011702e | |||
| 64bc689bcf |
@@ -55,9 +55,10 @@ jobs:
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.24.1
|
||||
rev: v1.25.2
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -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.*
|
||||
|
||||
Generated
+2
@@ -1317,6 +1317,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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: Certificates are checked every 12 hours via DataUpdateCoordinator.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: done
|
||||
comment: test_abort_on_socket_failed can be parametrized and should end in CREATE_ENTRY to test flow recovery.
|
||||
config-flow: done
|
||||
dependency-transparency:
|
||||
status: exempt
|
||||
comment: Integration has no external library dependencies.
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: todo
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Config flow only collects host/port; the integration does not authenticate.
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Consider creating a mock_config_entry fixture and use that throughout tests.
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
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: Integration supports a single device per config entry.
|
||||
entity-category:
|
||||
status: todo
|
||||
comment: Extra state attributes (is_valid, error) should be moved to separate entities in the future.
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Integration supports a single device per config entry.
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -16,6 +16,10 @@
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the server to monitor.",
|
||||
"port": "The port to connect to on the server."
|
||||
},
|
||||
"title": "Reconfigure the certificate to test"
|
||||
},
|
||||
"user": {
|
||||
@@ -24,6 +28,10 @@
|
||||
"name": "The name of the certificate",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the server to monitor.",
|
||||
"port": "The port to connect to on the server."
|
||||
},
|
||||
"title": "Define the certificate to test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +131,8 @@ ATTR_AUTO_UPDATE = "auto_update"
|
||||
ATTR_VERSION = "version"
|
||||
ATTR_VERSION_LATEST = "version_latest"
|
||||
ATTR_CPU_PERCENT = "cpu_percent"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCATION = "location"
|
||||
ATTR_MEMORY_PERCENT = "memory_percent"
|
||||
ATTR_SLUG = "slug"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_STATE = "state"
|
||||
ATTR_STARTED = "started"
|
||||
ATTR_URL = "url"
|
||||
ATTR_REPOSITORY = "repository"
|
||||
@@ -177,19 +173,6 @@ CORE_CONTAINER = "homeassistant"
|
||||
SUPERVISOR_CONTAINER = "hassio_supervisor"
|
||||
|
||||
CONTAINER_STATS = "stats"
|
||||
CONTAINER_INFO = "info"
|
||||
|
||||
# This is a mapping of which endpoint the key in the addon data
|
||||
# is obtained from so we know which endpoint to update when the
|
||||
# coordinator polls for updates.
|
||||
KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
|
||||
ATTR_VERSION_LATEST: {CONTAINER_INFO},
|
||||
ATTR_MEMORY_PERCENT: {CONTAINER_STATS},
|
||||
ATTR_CPU_PERCENT: {CONTAINER_STATS},
|
||||
ATTR_VERSION: {CONTAINER_INFO},
|
||||
ATTR_STATE: {CONTAINER_INFO},
|
||||
}
|
||||
|
||||
REQUEST_REFRESH_DELAY = 10
|
||||
|
||||
HELP_URLS = {
|
||||
|
||||
@@ -15,7 +15,7 @@ from aiohasupervisor.models import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_LOCATION, ATTR_NAME
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
@@ -43,7 +43,6 @@ from .const import (
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_SLUG,
|
||||
DOMAIN,
|
||||
|
||||
@@ -21,7 +21,7 @@ from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"room_priority": {
|
||||
"default": "mdi:home-thermometer"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"setpoint_status": {
|
||||
"default": "mdi:thermostat"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Support for Honeywell Lyric select platform."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiolyric.objects.device import LyricDevice
|
||||
from aiolyric.objects.location import LyricLocation
|
||||
from aiolyric.objects.priority import LyricRoom
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import LYRIC_EXCEPTIONS
|
||||
from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
|
||||
from .entity import LyricDeviceEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Honeywell Lyric API priority types
|
||||
PRIORITY_TYPE_PICK_A_ROOM = "PickARoom"
|
||||
PRIORITY_TYPE_FOLLOW_ME = "FollowMe"
|
||||
PRIORITY_TYPE_WHOLE_HOUSE = "WholeHouse"
|
||||
|
||||
# Option shown in the select for the FollowMe mode
|
||||
OPTION_FOLLOW_ME = "follow_me"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LyricConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Honeywell Lyric select entities based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
LyricRoomPrioritySelect(coordinator, location, device)
|
||||
for location in coordinator.data.locations
|
||||
for device in location.devices
|
||||
if device.device_class == "Thermostat"
|
||||
and device.device_id.startswith("LCC")
|
||||
and coordinator.data.rooms_dict.get(device.mac_id)
|
||||
)
|
||||
|
||||
|
||||
class LyricRoomPrioritySelect(LyricDeviceEntity, SelectEntity):
|
||||
"""Select entity for Honeywell Lyric thermostat room priority."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "room_priority"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: LyricDataUpdateCoordinator,
|
||||
location: LyricLocation,
|
||||
device: LyricDevice,
|
||||
) -> None:
|
||||
"""Initialize the room priority select entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
location,
|
||||
device,
|
||||
f"{device.mac_id}_room_priority",
|
||||
)
|
||||
|
||||
@property
|
||||
def _rooms(self) -> dict[int, LyricRoom]:
|
||||
"""Return the rooms for this thermostat."""
|
||||
return self.coordinator.data.rooms_dict.get(self._mac_id, {})
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return the list of available room priority options."""
|
||||
room_options = sorted(
|
||||
room.room_name for room in self._rooms.values() if room.room_name
|
||||
)
|
||||
return [OPTION_FOLLOW_ME, *room_options]
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the currently selected room priority."""
|
||||
priority = self.coordinator.data.priorities_dict.get(self._mac_id)
|
||||
if priority is None:
|
||||
return None
|
||||
|
||||
current = priority.current_priority
|
||||
if current.priority_type == PRIORITY_TYPE_FOLLOW_ME:
|
||||
return OPTION_FOLLOW_ME
|
||||
|
||||
if current.priority_type == PRIORITY_TYPE_PICK_A_ROOM:
|
||||
selected = current.selected_rooms
|
||||
if selected:
|
||||
room = self._rooms.get(selected[0])
|
||||
if room is not None:
|
||||
return room.room_name
|
||||
|
||||
return None
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the room priority."""
|
||||
if option == OPTION_FOLLOW_ME:
|
||||
priority_type = PRIORITY_TYPE_FOLLOW_ME
|
||||
rooms: list[int] = []
|
||||
else:
|
||||
priority_type = PRIORITY_TYPE_PICK_A_ROOM
|
||||
room_id = next(
|
||||
(rid for rid, room in self._rooms.items() if room.room_name == option),
|
||||
None,
|
||||
)
|
||||
if room_id is None:
|
||||
_LOGGER.error("Room not found: %s", option)
|
||||
return
|
||||
rooms = [room_id]
|
||||
|
||||
_LOGGER.debug("Set room priority: type=%s, rooms=%s", priority_type, rooms)
|
||||
try:
|
||||
await self.coordinator.data.update_priority(
|
||||
self.location,
|
||||
self.device,
|
||||
priority_type=priority_type,
|
||||
rooms=rooms,
|
||||
)
|
||||
except LYRIC_EXCEPTIONS as exception:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to set room priority: {exception}"
|
||||
) from exception
|
||||
await self.coordinator.async_refresh()
|
||||
@@ -37,6 +37,14 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"select": {
|
||||
"room_priority": {
|
||||
"name": "Room priority",
|
||||
"state": {
|
||||
"follow_me": "Follow me"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"indoor_humidity": {
|
||||
"name": "Indoor humidity"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"<think>(.*?)</think>", 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 ``<think>`` 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 ``<think>…</think>`` 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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for Qbus sensor."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from qbusmqttapi.discovery import QbusMqttOutput
|
||||
from qbusmqttapi.state import (
|
||||
@@ -13,6 +14,7 @@ from qbusmqttapi.state import (
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DEVICE_CLASS_UNITS,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
@@ -308,9 +310,32 @@ class QbusGaugeVariantSensor(QbusEntity, SensorEntity):
|
||||
variant = str(mqtt_output.variant)
|
||||
self.entity_description = _GAUGE_VARIANT_DESCRIPTIONS[variant.upper()]
|
||||
|
||||
allowed_units = (
|
||||
DEVICE_CLASS_UNITS.get(self.entity_description.device_class)
|
||||
if self.entity_description.device_class
|
||||
else None
|
||||
)
|
||||
value_properties: dict = mqtt_output.properties.get("currentValue", {})
|
||||
unit = self._find_matching_unit(value_properties.get("unit"), allowed_units)
|
||||
|
||||
if allowed_units is not None and unit in allowed_units:
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttGaugeState) -> None:
|
||||
self._attr_native_value = state.read_value(GaugeStateProperty.CURRENT_VALUE)
|
||||
|
||||
def _find_matching_unit(
|
||||
self,
|
||||
unit: str | None,
|
||||
allowed_units: set[type[StrEnum] | str | None] | None,
|
||||
) -> str | None:
|
||||
"""Do a case-insensitive search in the allowed units. Returns the properly cased unit if found, else None."""
|
||||
if unit is None or allowed_units is None:
|
||||
return None
|
||||
|
||||
lookup = {str(u).casefold(): str(u) for u in allowed_units if u is not None}
|
||||
return lookup.get(unit.casefold())
|
||||
|
||||
|
||||
class QbusHumiditySensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for humidity modules."""
|
||||
|
||||
Generated
+1
@@ -548,6 +548,7 @@ FLOWS = {
|
||||
"ourgroceries",
|
||||
"overkiz",
|
||||
"overseerr",
|
||||
"ovhcloud_ai_endpoints",
|
||||
"ovo_energy",
|
||||
"owntracks",
|
||||
"p1_monitor",
|
||||
|
||||
@@ -5193,6 +5193,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -1748,6 +1748,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
|
||||
|
||||
Generated
+1
-1
@@ -3,4 +3,4 @@
|
||||
codespell==2.4.2
|
||||
ruff==0.15.13
|
||||
yamllint==1.38.0
|
||||
zizmor==1.24.1
|
||||
zizmor==1.25.2
|
||||
|
||||
@@ -217,7 +217,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"canary",
|
||||
"cast",
|
||||
"ccm15",
|
||||
"cert_expiry",
|
||||
"chacon_dio",
|
||||
"channels",
|
||||
"circuit",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""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()
|
||||
@@ -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", {})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'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': <ANY>,
|
||||
'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': <ConversationEntityFeature: 1>,
|
||||
'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': <ConversationEntityFeature: 1>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'conversation.meta_llama_3_3_70b_instruct',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'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': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'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': <ANY>,
|
||||
'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': <ConversationEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'conversation.meta_llama_3_3_70b_instruct',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'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,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
"""``<think>…</think>`` 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="<think>Let me think.</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 ``<think>…</think>`` 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="<think>Reasoning…</think>",
|
||||
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 ``<think>`` 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(
|
||||
"<think>from tag</think>actual", 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 == "<think>from tag</think>actual"
|
||||
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
@@ -223,7 +223,6 @@
|
||||
"read": true,
|
||||
"step": 0.1,
|
||||
"type": "number",
|
||||
"unit": "ppm",
|
||||
"write": false
|
||||
}
|
||||
}
|
||||
@@ -245,7 +244,7 @@
|
||||
"read": true,
|
||||
"step": 0.1,
|
||||
"type": "number",
|
||||
"unit": "kWh",
|
||||
"unit": "A",
|
||||
"write": false
|
||||
}
|
||||
}
|
||||
@@ -265,7 +264,7 @@
|
||||
"read": true,
|
||||
"step": 0.1,
|
||||
"type": "number",
|
||||
"unit": "A",
|
||||
"unit": "Wh",
|
||||
"write": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ctd_000001_84',
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
'unit_of_measurement': 'Wh',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.garage_energie-state]
|
||||
@@ -47,7 +47,7 @@
|
||||
'device_class': 'energy',
|
||||
'friendly_name': 'Energie',
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
'unit_of_measurement': 'Wh',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.garage_energie',
|
||||
@@ -96,7 +96,7 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ctd_000001_85',
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
|
||||
'unit_of_measurement': 'm³/h',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.garage_gas-state]
|
||||
@@ -105,7 +105,7 @@
|
||||
'device_class': 'volume_flow_rate',
|
||||
'friendly_name': 'Gas',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
|
||||
'unit_of_measurement': 'm³/h',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.garage_gas',
|
||||
@@ -154,7 +154,7 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ctd_000001_86',
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
|
||||
'unit_of_measurement': 'm³/h',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.garage_gas_flow-state]
|
||||
@@ -163,7 +163,7 @@
|
||||
'device_class': 'volume_flow_rate',
|
||||
'friendly_name': 'Gas Flow',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
|
||||
'unit_of_measurement': 'm³/h',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.garage_gas_flow',
|
||||
@@ -212,7 +212,7 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ctd_000001_83',
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
'unit_of_measurement': 'A',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.garage_stroom-state]
|
||||
@@ -221,7 +221,7 @@
|
||||
'device_class': 'current',
|
||||
'friendly_name': 'Stroom',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
'unit_of_measurement': 'A',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.garage_stroom',
|
||||
@@ -557,7 +557,7 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ctd_000001_81',
|
||||
'unit_of_measurement': <UnitOfPressure.MBAR: 'mbar'>,
|
||||
'unit_of_measurement': 'mbar',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.tuin_luchtdruk-state]
|
||||
@@ -566,7 +566,7 @@
|
||||
'device_class': 'pressure',
|
||||
'friendly_name': 'Luchtdruk',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfPressure.MBAR: 'mbar'>,
|
||||
'unit_of_measurement': 'mbar',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.tuin_luchtdruk',
|
||||
@@ -673,7 +673,7 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ctd_000001_40',
|
||||
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
|
||||
'unit_of_measurement': 'm',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.tuin_regenput-state]
|
||||
@@ -682,7 +682,7 @@
|
||||
'device_class': 'distance',
|
||||
'friendly_name': 'Regenput',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
|
||||
'unit_of_measurement': 'm',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.tuin_regenput',
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import patch
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
@@ -25,3 +25,23 @@ async def test_sensor(
|
||||
await setup_integration_deferred()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_sensor_gauge_unit_applied(
|
||||
hass: HomeAssistant, setup_integration: None
|
||||
) -> None:
|
||||
"""Test gauge sensor uses reported unit."""
|
||||
|
||||
entity = hass.states.get("sensor.garage_energie")
|
||||
assert entity
|
||||
assert entity.attributes[ATTR_UNIT_OF_MEASUREMENT] == "Wh"
|
||||
|
||||
|
||||
async def test_sensor_gauge_unit_missing(
|
||||
hass: HomeAssistant, setup_integration: None
|
||||
) -> None:
|
||||
"""Test gauge sensor falls back to description default."""
|
||||
|
||||
entity = hass.states.get("sensor.tuin_luchtkwaliteit")
|
||||
assert entity
|
||||
assert entity.attributes[ATTR_UNIT_OF_MEASUREMENT] == "ppm"
|
||||
|
||||
Reference in New Issue
Block a user