add ovhcloud_ai_endpoints integration#171402
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a new Home Assistant integration for OVHcloud AI Endpoints, providing a conversation agent backed by OVHcloud's OpenAI-compatible API. Includes config flow with conversation subentries, model selection, and support for both <think> tag and reasoning/reasoning_content field extraction.
Changes:
- New
ovhcloud_ai_endpointsintegration with config flow, conversation entity, and subentry-based model configuration - Reasoning/thinking content extraction supporting OpenRouter, vLLM and DeepSeek-style fields plus inline
<think>tags - Comprehensive tests for setup, config flow, and conversation behavior
Reviewed changes
Copilot reviewed 20 out of 22 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| homeassistant/components/ovhcloud_ai_endpoints/init.py | Integration setup, OpenAI client init, API key validation |
| homeassistant/components/ovhcloud_ai_endpoints/config_flow.py | User and conversation subentry config flow |
| homeassistant/components/ovhcloud_ai_endpoints/conversation.py | Conversation entity wiring |
| homeassistant/components/ovhcloud_ai_endpoints/entity.py | Base entity, chat log handling, thinking extraction |
| homeassistant/components/ovhcloud_ai_endpoints/const.py | Constants and recommended defaults |
| homeassistant/components/ovhcloud_ai_endpoints/strings.json | Translation strings |
| homeassistant/components/ovhcloud_ai_endpoints/manifest.json | Integration manifest |
| homeassistant/components/ovhcloud_ai_endpoints/quality_scale.yaml | Quality scale tracking |
| tests/components/ovhcloud_ai_endpoints/* | Test fixtures, conftest, and tests for init/config flow/conversation |
| CODEOWNERS, mypy.ini, .strict-typing, requirements_*, generated files | Registration of the new integration |
25faa45 to
d1f1c79
Compare
d1f1c79 to
da3bf3f
Compare
| mock_openai_client.models.list.side_effect = None | ||
|
|
||
| result = await hass.config_entries.flow.async_configure( | ||
| result["flow_id"], | ||
| {CONF_API_KEY: "bla"}, | ||
| ) |
| 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") |
There was a problem hiding this comment.
same implem as openrouter
| ] | ||
| ) | ||
| if not chat_log.unresponded_tool_results: | ||
| break |
There was a problem hiding this comment.
same implem as openrouter
da3bf3f to
d8d9f2f
Compare
d8d9f2f to
ac575e3
Compare
| client = _create_client(hass, entry.data[CONF_API_KEY]) | ||
|
|
||
| try: | ||
| # Unfortunately I couldn't find an endpoint that would authenticate the key | ||
| # without calling an LLM. This always succeeds regardless of auth. | ||
| async for _ in client.with_options(timeout=10.0).models.list(): | ||
| break | ||
| except OpenAIError as err: | ||
| raise ConfigEntryNotReady(err) from err | ||
|
|
| try: | ||
| # Unfortunately I couldn't find an endpoint that would authenticate the key | ||
| # without calling an LLM. This always succeeds regardless of auth. | ||
| async for _ in client.with_options(timeout=10.0).models.list(): | ||
| break |
| ] | ||
| ) | ||
| if not chat_log.unresponded_tool_results: | ||
| break |
| content: conversation.Content, | ||
| ) -> ChatCompletionMessageParam | None: | ||
| """Convert chat message for this agent to the native format.""" | ||
| LOGGER.debug("_convert_content_to_chat_message=%s", content) |
| 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() | ||
| ] |
ac575e3 to
4f22457
Compare
4f22457 to
30e1650
Compare
30e1650 to
cd65fae
Compare
| 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, | ||
| ) |
| self.models = [ | ||
| model.id async for model in client.with_options(timeout=10.0).models.list() | ||
| ] |
|
|
||
| async def _transform_response( | ||
| message: ChatCompletionMessage, | ||
| ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: |
| ] | ||
| ) | ||
| if not chat_log.unresponded_tool_results: | ||
| break |
| content: conversation.Content, | ||
| ) -> ChatCompletionMessageParam | None: | ||
| """Convert chat message for this agent to the native format.""" | ||
| LOGGER.debug("_convert_content_to_chat_message=%s", content) |
| 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 |
| 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 |
There was a problem hiding this comment.
What about just fetching the models?
There was a problem hiding this comment.
Models list is not authenticated. Calling it with an invalid key results in a HTTP 200. I had a chat with the product team and this is what they suggested, for lack of a better option.
| from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT | ||
| from homeassistant.helpers import llm | ||
|
|
||
| DOMAIN = "ovhcloud_ai_endpoints" |
There was a problem hiding this comment.
why do we call this _endpoints?
There was a problem hiding this comment.
That’s the name of the product, see https://www.ovhcloud.com/fr/public-cloud/ai-endpoints/
| async def test_dynamic_subentry_creates_entity_and_device( | ||
| hass: HomeAssistant, | ||
| mock_openai_client: AsyncMock, | ||
| entity_registry: er.EntityRegistry, | ||
| device_registry: dr.DeviceRegistry, | ||
| ) -> None: | ||
| """A subentry added after setup must spawn its conversation entity and device.""" | ||
| entry = MockConfigEntry( | ||
| title="OVHcloud AI Endpoints", | ||
| domain=DOMAIN, | ||
| data={CONF_API_KEY: "bla"}, | ||
| ) |
There was a problem hiding this comment.
dynamic subentry? What does that mean?
There was a problem hiding this comment.
like an entry added after the initial setup
maybe new_subentry?
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
| ] | ||
| ) | ||
| if not chat_log.unresponded_tool_results: | ||
| break |
| content: conversation.Content, | ||
| ) -> ChatCompletionMessageParam | None: | ||
| """Convert chat message for this agent to the native format.""" | ||
| LOGGER.debug("_convert_content_to_chat_message=%s", content) |
| 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 |
| 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 |
| comment: No actions are implemented | ||
| appropriate-polling: | ||
| status: exempt | ||
| comment: the integration does not poll |
| from . import OVHcloudAIEndpointsConfigEntry | ||
| from .const import DOMAIN, LOGGER | ||
|
|
||
| MAX_TOOL_ITERATIONS = 10 |
|
|
||
| client = self.entry.runtime_data | ||
|
|
||
| for _iteration in range(MAX_TOOL_ITERATIONS): |
Proposed change
Add integration for OVH AI endpoints, starting with conversation only.
Type of change
Additional information
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.To help with the load of incoming pull requests: