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:
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
@@ -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
@@ -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
|
||||||
Reference in new issue
Block a user