From a6faa44b2fe5dd5504201f4da60d3b7940e6aee1 Mon Sep 17 00:00:00 2001 From: Adam Smith Date: Sun, 11 Oct 2026 06:42:21 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01SUd68TsT5QZ68Dtfw2uWBU --- .../openai_compatible/__init__.py | 2 +- tests/conftest.py | 12 +++- tests/test_base_url.py | 57 ++++++++++++++++++- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/custom_components/openai_compatible/__init__.py b/custom_components/openai_compatible/__init__.py index f7de9bc..489e5fd 100644 --- a/custom_components/openai_compatible/__init__.py +++ b/custom_components/openai_compatible/__init__.py @@ -294,7 +294,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo _ = await hass.async_add_executor_job(client.platform_headers) 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: raise ConfigEntryAuthFailed(err) from err except openai.OpenAIError as err: diff --git a/tests/conftest.py b/tests/conftest.py index 7c26e17..400541f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ """Fixtures for openai_compatible tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -154,13 +154,19 @@ async def mock_config_entry_with_reasoning_model( @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Initialize integration.""" +) -> AsyncGenerator[None]: + """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( "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + yield @pytest.fixture(autouse=True) diff --git a/tests/test_base_url.py b/tests/test_base_url.py index 6824f89..5f2e5f5 100644 --- a/tests/test_base_url.py +++ b/tests/test_base_url.py @@ -2,6 +2,11 @@ from unittest.mock import AsyncMock, patch +import httpx +import openai +import pytest + +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant 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" ) as mock_client: 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.async_block_till_done() @@ -47,7 +52,7 @@ async def test_api_key_optional(hass: HomeAssistant) -> None: "custom_components.openai_compatible.openai.AsyncOpenAI" ) as mock_client: 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.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" ) as mock_client: 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) await hass.async_block_till_done() assert entry.data[CONF_BASE_URL] == DEFAULT_BASE_URL assert entry.minor_version == 8 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