diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 9011ad21e42..5e43b2f1c75 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -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) diff --git a/homeassistant/components/anthropic/coordinator.py b/homeassistant/components/anthropic/coordinator.py new file mode 100644 index 00000000000..3a7a209d7c6 --- /dev/null +++ b/homeassistant/components/anthropic/coordinator.py @@ -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() diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 021fc727a75..400cbe1626d 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -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 diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 1cf9008fc41..6279e5eb3d6 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -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: | diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index ac78e690eba..a00a7f977e8 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -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) diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index eb696b3c953..285b894309b 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -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, diff --git a/tests/components/anthropic/test_coordinator.py b/tests/components/anthropic/test_coordinator.py new file mode 100644 index 00000000000..d48bf0a69af --- /dev/null +++ b/tests/components/anthropic/test_coordinator.py @@ -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