1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 08:26:41 +01:00

Add coordinator to Anthropic for availability check (#164615)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Denis Shulyaka
2026-04-01 23:20:56 +03:00
committed by GitHub
parent 279c9e71df
commit 7a77b071a2
7 changed files with 370 additions and 73 deletions

View File

@@ -2,19 +2,15 @@
from __future__ import annotations
import anthropic
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -24,12 +20,11 @@ from .const import (
DOMAIN,
LOGGER,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Anthropic."""
@@ -39,29 +34,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
"""Set up Anthropic from a config entry."""
client = anthropic.AsyncAnthropic(
api_key=entry.data[CONF_API_KEY], http_client=get_async_client(hass)
)
try:
await client.models.list(timeout=10.0)
except anthropic.AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.AnthropicError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"message": err.message
if isinstance(err, anthropic.APIError)
else str(err)
},
) from err
entry.runtime_data = client
coordinator = AnthropicCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -0,0 +1,78 @@
"""Coordinator for the Anthropic integration."""
from __future__ import annotations
from datetime import timedelta
import anthropic
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
UPDATE_INTERVAL_CONNECTED = timedelta(hours=12)
UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
class AnthropicCoordinator(DataUpdateCoordinator[None]):
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
client: anthropic.AsyncAnthropic
def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=config_entry.title,
update_interval=UPDATE_INTERVAL_CONNECTED,
update_method=self.async_update_data,
always_update=False,
)
self.client = anthropic.AsyncAnthropic(
api_key=config_entry.data[CONF_API_KEY], http_client=get_async_client(hass)
)
@callback
def async_set_updated_data(self, data: None) -> None:
"""Manually update data, notify listeners and update refresh interval."""
self.update_interval = UPDATE_INTERVAL_CONNECTED
super().async_set_updated_data(data)
async def async_update_data(self) -> None:
"""Fetch data from the API."""
try:
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
await self.client.models.list(timeout=10.0)
self.update_interval = UPDATE_INTERVAL_CONNECTED
except anthropic.APITimeoutError as err:
raise TimeoutError(err.message or str(err)) from err
except anthropic.AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.APIError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": err.message},
) from err
def mark_connection_error(self) -> None:
"""Mark the connection as having an error and reschedule background check."""
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
if self.last_update_success:
self.last_update_success = False
self.async_update_listeners()
if self._listeners and not self.hass.is_stopping:
self._schedule_refresh()

View File

@@ -82,12 +82,11 @@ from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
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 homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from homeassistant.util.json import JsonObjectType
from . import AnthropicConfigEntry
from .const import (
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
@@ -111,6 +110,7 @@ from .const import (
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
@@ -658,7 +658,7 @@ def _create_token_stats(
}
class AnthropicBaseLLMEntity(Entity):
class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"""Anthropic base LLM entity."""
_attr_has_entity_name = True
@@ -666,6 +666,7 @@ class AnthropicBaseLLMEntity(Entity):
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity."""
super().__init__(entry.runtime_data)
self.entry = entry
self.subentry = subentry
self._attr_unique_id = subentry.subentry_id
@@ -877,7 +878,8 @@ class AnthropicBaseLLMEntity(Entity):
if tools:
model_args["tools"] = tools
client = self.entry.runtime_data
coordinator = self.entry.runtime_data
client = coordinator.client
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(max_iterations):
@@ -899,13 +901,24 @@ class AnthropicBaseLLMEntity(Entity):
)
messages.extend(new_messages)
except anthropic.AuthenticationError as err:
self.entry.async_start_reauth(self.hass)
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
await coordinator.async_request_refresh()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_authentication_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.APIConnectionError as err:
LOGGER.info("Connection error while talking to Anthropic: %s", err)
coordinator.mark_connection_error()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"message": err.message},
) from err
except anthropic.AnthropicError as err:
# Non-connection error, mark connection as healthy
coordinator.async_set_updated_data(None)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
@@ -917,6 +930,7 @@ class AnthropicBaseLLMEntity(Entity):
) from err
if not chat_log.unresponded_tool_results:
coordinator.async_set_updated_data(None)
break

View File

@@ -35,9 +35,9 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |

View File

@@ -58,7 +58,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
if entry.entry_id in self._model_list_cache:
model_list = self._model_list_cache[entry.entry_id]
else:
client = entry.runtime_data
client = entry.runtime_data.client
model_list = [
model_option
for model_option in await get_model_list(client)

View File

@@ -4,7 +4,7 @@ import datetime
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from anthropic import AuthenticationError, RateLimitError
from anthropic import RateLimitError
from anthropic.types import (
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
@@ -42,10 +42,8 @@ from homeassistant.components.anthropic.const import (
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DOMAIN,
)
from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -129,38 +127,6 @@ async def test_error_handling(
assert result.response.error_code == "unknown", result
async def test_auth_error_handling(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
) -> None:
"""Test reauth after authentication error during conversation."""
mock_create_stream.side_effect = AuthenticationError(
message="Invalid API key",
response=Response(status_code=403, request=Request(method="POST", url=URL())),
body=None,
)
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert result.response.error_code == "unknown", result
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow["step_id"] == "reauth_confirm"
assert flow["handler"] == DOMAIN
assert "context" in flow
assert flow["context"]["source"] == SOURCE_REAUTH
assert flow["context"]["entry_id"] == mock_config_entry.entry_id
async def test_template_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,

View File

@@ -0,0 +1,264 @@
"""Tests for the Anthropic integration."""
import datetime
from unittest.mock import AsyncMock, patch
from anthropic import APITimeoutError, AuthenticationError, RateLimitError
from freezegun import freeze_time
from httpx import URL, Request, Response
from homeassistant.components import conversation
from homeassistant.components.anthropic.const import DOMAIN
from homeassistant.components.anthropic.coordinator import (
UPDATE_INTERVAL_CONNECTED,
UPDATE_INTERVAL_DISCONNECTED,
)
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import intent
from tests.common import MockConfigEntry, async_fire_time_changed
@patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock)
async def test_auth_error_handling(
mock_model_list: AsyncMock,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
) -> None:
"""Test reauth after authentication error during conversation."""
# This is an assumption of the tests, not the main code:
assert UPDATE_INTERVAL_DISCONNECTED < UPDATE_INTERVAL_CONNECTED
mock_create_stream.side_effect = mock_model_list.side_effect = AuthenticationError(
message="Invalid API key",
response=Response(status_code=403, request=Request(method="POST", url=URL())),
body=None,
)
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert result.response.error_code == "unknown", result
await hass.async_block_till_done()
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unavailable"
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow["step_id"] == "reauth_confirm"
assert flow["handler"] == DOMAIN
assert "context" in flow
assert flow["context"]["source"] == SOURCE_REAUTH
assert flow["context"]["entry_id"] == mock_config_entry.entry_id
@freeze_time("2026-02-27 12:00:00")
@patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock)
async def test_connection_error_handling(
mock_model_list: AsyncMock,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
) -> None:
"""Test making entity unavailable on connection error."""
mock_create_stream.side_effect = APITimeoutError(
request=Request(method="POST", url=URL()),
)
# Check initial state
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unknown"
# Get timeout
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert result.response.error_code == "unknown", result
# Check new state
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unavailable"
# Try again
await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
)
# Check state is still unavailable
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unavailable"
mock_create_stream.side_effect = RateLimitError(
message=None,
response=Response(status_code=429, request=Request(method="POST", url=URL())),
body=None,
)
# Get a different error meaning the connection is restored
await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
)
# Check state is back to normal
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "2026-02-27T12:00:00+00:00"
# Verify the background check period
test_time = datetime.datetime.now(datetime.UTC) + UPDATE_INTERVAL_DISCONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
mock_model_list.assert_not_awaited()
test_time += UPDATE_INTERVAL_CONNECTED - UPDATE_INTERVAL_DISCONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
mock_model_list.assert_awaited_once()
@patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock)
async def test_connection_check_reauth(
mock_model_list: AsyncMock,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
) -> None:
"""Test authentication error during background availability check."""
mock_model_list.side_effect = APITimeoutError(
request=Request(method="POST", url=URL()),
)
# Check initial state
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unknown"
# Get timeout
assert mock_model_list.await_count == 0
test_time = datetime.datetime.now(datetime.UTC) + UPDATE_INTERVAL_CONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
assert mock_model_list.await_count == 1
# Check new state
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unavailable"
mock_model_list.side_effect = AuthenticationError(
message="Invalid API key",
response=Response(status_code=403, request=Request(method="POST", url=URL())),
body=None,
)
# Wait for background check to run and fail
test_time += UPDATE_INTERVAL_DISCONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
assert mock_model_list.await_count == 2
# Check state is still unavailable
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unavailable"
# Verify that the background check is not running anymore
test_time += UPDATE_INTERVAL_DISCONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
assert mock_model_list.await_count == 2
# Check that a reauth flow has been created
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow["step_id"] == "reauth_confirm"
assert flow["handler"] == DOMAIN
assert "context" in flow
assert flow["context"]["source"] == SOURCE_REAUTH
assert flow["context"]["entry_id"] == mock_config_entry.entry_id
@patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock)
async def test_connection_restore(
mock_model_list: AsyncMock,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
) -> None:
"""Test background availability check restore on non-connectivity error."""
mock_create_stream.side_effect = APITimeoutError(
request=Request(method="POST", url=URL()),
)
# Check initial state
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unknown"
# Get timeout
await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
)
# Check new state
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unavailable"
mock_model_list.side_effect = APITimeoutError(
request=Request(method="POST", url=URL()),
)
# Wait for background check to run and fail
assert mock_model_list.await_count == 0
test_time = datetime.datetime.now(datetime.UTC) + UPDATE_INTERVAL_DISCONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
assert mock_model_list.await_count == 1
# Check state is still unavailable
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state == "unavailable"
# Now make the background check succeed
mock_model_list.side_effect = None
test_time += UPDATE_INTERVAL_DISCONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
assert mock_model_list.await_count == 2
# Check that state is back to normal since the error is not connectivity related
state = hass.states.get("conversation.claude_conversation")
assert state
assert state.state != "unavailable"
# Verify the background check period
test_time += UPDATE_INTERVAL_DISCONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
assert mock_model_list.await_count == 2
test_time += UPDATE_INTERVAL_CONNECTED - UPDATE_INTERVAL_DISCONNECTED
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
assert mock_model_list.await_count == 3