fix: actually check the endpoint during setup

async_setup_entry handed client.with_options(timeout=10.0).models.list to the
executor. On AsyncOpenAI that returns an awaitable paginator, so it came back
unawaited, no request was made, and a bad key or an unreachable endpoint still
set up. It now awaits client.models.list(timeout=10.0), matching core's
openai_conversation, so a 401 raises ConfigEntryAuthFailed and a connection
error ConfigEntryNotReady, as the README already says.

The shared mock_init_component fixture now patches models.list with an
AsyncMock and keeps the patch for the whole test, because tests that change a
subentry reload the entry and run setup again.

Reported by @MartinNuc in #1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUd68TsT5QZ68Dtfw2uWBU
This commit is contained in:
Adam SmithandClaude Opus 5.5 committed 2026-10-11 06:45:52 -04:00
1 parent 151d741a71
commit a6faa44b2f
3 files changed
+64 -7

No files matched your search

@@ -294,7 +294,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
_ = await hass.async_add_executor_job(client.platform_headers) _ = await hass.async_add_executor_job(client.platform_headers)
try: try:
await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) await client.models.list(timeout=10.0)
except openai.AuthenticationError as err: except openai.AuthenticationError as err:
raise ConfigEntryAuthFailed(err) from err raise ConfigEntryAuthFailed(err) from err
except openai.OpenAIError as err: except openai.OpenAIError as err:
+9 -3
View File
@@ -1,6 +1,6 @@
"""Fixtures for openai_compatible tests.""" """Fixtures for openai_compatible tests."""
from collections.abc import Generator from collections.abc import AsyncGenerator, Generator
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -154,13 +154,19 @@ async def mock_config_entry_with_reasoning_model(
@pytest.fixture @pytest.fixture
async def mock_init_component( async def mock_init_component(
hass: HomeAssistant, mock_config_entry: MockConfigEntry hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None: ) -> AsyncGenerator[None]:
"""Initialize integration.""" """Initialize integration.
The patch stays active for the whole test: setup awaits GET /models, and a
test that changes a subentry reloads the entry, which runs setup again.
"""
with patch( with patch(
"openai.resources.models.AsyncModels.list", "openai.resources.models.AsyncModels.list",
new_callable=AsyncMock,
): ):
assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done() await hass.async_block_till_done()
yield
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
+54 -3
View File
@@ -2,6 +2,11 @@
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import httpx
import openai
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -26,7 +31,7 @@ async def test_client_built_with_base_url(hass: HomeAssistant) -> None:
"custom_components.openai_compatible.openai.AsyncOpenAI" "custom_components.openai_compatible.openai.AsyncOpenAI"
) as mock_client: ) as mock_client:
mock_client.return_value.platform_headers = lambda: {} mock_client.return_value.platform_headers = lambda: {}
mock_client.return_value.with_options.return_value.models.list = AsyncMock() mock_client.return_value.models.list = AsyncMock()
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -47,7 +52,7 @@ async def test_api_key_optional(hass: HomeAssistant) -> None:
"custom_components.openai_compatible.openai.AsyncOpenAI" "custom_components.openai_compatible.openai.AsyncOpenAI"
) as mock_client: ) as mock_client:
mock_client.return_value.platform_headers = lambda: {} mock_client.return_value.platform_headers = lambda: {}
mock_client.return_value.with_options.return_value.models.list = AsyncMock() mock_client.return_value.models.list = AsyncMock()
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -73,10 +78,56 @@ async def test_migration_backfills_missing_base_url(hass: HomeAssistant) -> None
"custom_components.openai_compatible.openai.AsyncOpenAI" "custom_components.openai_compatible.openai.AsyncOpenAI"
) as mock_client: ) as mock_client:
mock_client.return_value.platform_headers = lambda: {} mock_client.return_value.platform_headers = lambda: {}
mock_client.return_value.with_options.return_value.models.list = AsyncMock() mock_client.return_value.models.list = AsyncMock()
assert await hass.config_entries.async_setup(entry.entry_id) assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert entry.data[CONF_BASE_URL] == DEFAULT_BASE_URL assert entry.data[CONF_BASE_URL] == DEFAULT_BASE_URL
assert entry.minor_version == 8 assert entry.minor_version == 8
assert mock_client.call_args.kwargs["base_url"] == DEFAULT_BASE_URL assert mock_client.call_args.kwargs["base_url"] == DEFAULT_BASE_URL
@pytest.mark.parametrize(
("error", "state"),
[
(
openai.AuthenticationError(
"bad key",
response=httpx.Response(401, request=httpx.Request("GET", "http://x")),
body=None,
),
ConfigEntryState.SETUP_ERROR,
),
(
openai.APIConnectionError(request=httpx.Request("GET", "http://x")),
ConfigEntryState.SETUP_RETRY,
),
],
)
async def test_setup_checks_connection(
hass: HomeAssistant, error: Exception, state: ConfigEntryState
) -> None:
"""Setup must actually call GET /models and act on its failure.
AsyncModels.list returns an awaitable paginator; handing the bound method to
the executor returned it unawaited, so no request was made and a bad key or
unreachable endpoint still set up.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "sk-test", CONF_BASE_URL: "http://192.0.2.10:4000/v1"},
version=2,
minor_version=8,
)
entry.add_to_hass(hass)
with patch(
"custom_components.openai_compatible.openai.AsyncOpenAI"
) as mock_client:
mock_client.return_value.platform_headers = lambda: {}
mock_client.return_value.models.list = AsyncMock(side_effect=error)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
mock_client.return_value.models.list.assert_awaited_once_with(timeout=10.0)
assert entry.state is state