diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index a9745d1a6a5..fba70a18395 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -6,13 +6,19 @@ from functools import partial import anthropic -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er -from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL +from .const import ( + CONF_CHAT_MODEL, + DEFAULT_CONVERSATION_NAME, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, +) PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -26,7 +32,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) ) try: - model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Use model from first conversation subentry for validation + subentries = list(entry.subentries.values()) + if subentries: + model_id = subentries[0].data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + else: + model_id = RECOMMENDED_CHAT_MODEL model = await client.models.retrieve(model_id=model_id, timeout=10.0) LOGGER.debug("Anthropic model: %s", model.display_name) except anthropic.AuthenticationError as err: @@ -45,3 +56,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Anthropic.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version == 1: + # Migrate from version 1 to version 2 + # Move conversation-specific options to a subentry + subentry = ConfigSubentry( + data=entry.options, + subentry_type="conversation", + title=DEFAULT_CONVERSATION_NAME, + unique_id=None, + ) + hass.config_entries.async_add_subentry( + entry, + subentry, + ) + + # Migrate conversation entity to be linked to subentry + ent_reg = er.async_get(hass) + conversation_entity = ent_reg.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + if conversation_entity is not None: + ent_reg.async_update_entity( + conversation_entity, + config_subentry_id=subentry.subentry_id, + new_unique_id=subentry.subentry_id, + ) + + # Remove options from the main entry + hass.config_entries.async_update_entry( + entry, + options={}, + version=2, + ) + + return True diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index ebad206af61..8b281afd1ba 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -5,8 +5,7 @@ from __future__ import annotations from collections.abc import Mapping from functools import partial import logging -from types import MappingProxyType -from typing import Any +from typing import Any, cast import anthropic import voluptuous as vol @@ -15,10 +14,11 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( NumberSelector, @@ -36,6 +36,7 @@ from .const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, @@ -72,7 +73,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -102,57 +103,89 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="Claude", data=user_input, - options=RECOMMENDED_OPTIONS, + subentries=[ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None ) - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return AnthropicOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} -class AnthropicOptionsFlow(OptionsFlow): - """Anthropic config flow options handler.""" +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.last_rendered_recommended = config_entry.options.get( - CONF_RECOMMENDED, False - ) + last_rendered_recommended = False - async def async_step_init( + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + async def async_step_set_options( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + ) -> SubentryFlowResult: + """Set conversation options.""" errors: dict[str, str] = {} - if user_input is not None: - if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if not user_input.get(CONF_LLM_HASS_API): - user_input.pop(CONF_LLM_HASS_API, None) - if user_input.get( - CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET - ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): - errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" - - if not errors: - return self.async_create_entry(title="", data=user_input) + if user_input is None: + if self._is_new: + options = RECOMMENDED_OPTIONS.copy() else: - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + # If this is a reconfiguration, we need to copy the existing options + # so that we can show the current values in the form. + options = self._get_reconfigure_subentry().data.copy() - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), - } + self.last_rendered_recommended = cast( + bool, options.get(CONF_RECOMMENDED, False) + ) + + elif user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if user_input.get( + CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET + ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): + errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" + + if not errors: + if self._is_new: + return self.async_create_entry( + title=user_input.pop(CONF_NAME), + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + ) + + options = user_input + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + else: + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), + } suggested_values = options.copy() if not suggested_values.get(CONF_PROMPT): @@ -163,19 +196,25 @@ class AnthropicOptionsFlow(OptionsFlow): suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis] schema = self.add_suggested_values_to_schema( - vol.Schema(anthropic_config_option_schema(self.hass, options)), + vol.Schema( + anthropic_config_option_schema(self.hass, self._is_new, options) + ), suggested_values, ) return self.async_show_form( - step_id="init", + step_id="set_options", data_schema=schema, errors=errors or None, ) + async_step_user = async_step_set_options + async_step_reconfigure = async_step_set_options + def anthropic_config_option_schema( hass: HomeAssistant, + is_new: bool, options: Mapping[str, Any], ) -> dict: """Return a schema for Anthropic completion options.""" @@ -187,15 +226,24 @@ def anthropic_config_option_schema( for api in llm.async_get_apis(hass) ] - schema = { - vol.Optional(CONF_PROMPT): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } + if is_new: + schema: dict[vol.Required | vol.Optional, Any] = { + vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + } + else: + schema = {} + + schema.update( + { + vol.Optional(CONF_PROMPT): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) if options.get(CONF_RECOMMENDED): return schema diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 69789b9a64a..d7e10dd7af2 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -5,6 +5,8 @@ import logging DOMAIN = "anthropic" LOGGER = logging.getLogger(__package__) +DEFAULT_CONVERSATION_NAME = "Claude conversation" + CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index f17294fe0e7..60d0a1e4282 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -38,7 +38,7 @@ from anthropic.types import ( from voluptuous_openapi import convert from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -52,6 +52,7 @@ from .const import ( CONF_PROMPT, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER, MIN_THINKING_BUDGET, @@ -72,8 +73,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = AnthropicConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue + + async_add_entities( + [AnthropicConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) def _format_tool( @@ -326,21 +333,21 @@ class AnthropicConversationEntity( ): """Anthropic conversation agent.""" - _attr_has_entity_name = True - _attr_name = None _attr_supports_streaming = True - def __init__(self, entry: AnthropicConfigEntry) -> None: + def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" self.entry = entry - self._attr_unique_id = entry.entry_id + self.subentry = subentry + self._attr_name = subentry.title or DEFAULT_CONVERSATION_NAME + self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Anthropic", model="Claude", entry_type=dr.DeviceEntryType.SERVICE, ) - if self.entry.options.get(CONF_LLM_HASS_API): + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -363,7 +370,7 @@ class AnthropicConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - options = self.entry.options + options = self.subentry.data try: await chat_log.async_provide_llm_data( @@ -393,7 +400,7 @@ class AnthropicConversationEntity( chat_log: conversation.ChatLog, ) -> None: """Generate an answer for the chat log.""" - options = self.entry.options + options = self.subentry.data tools: list[ToolParam] | None = None if chat_log.llm_api: diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index c2caf3a6666..773153c8bf7 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -14,26 +14,38 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Instructions", - "chat_model": "[%key:common::generic::model%]", - "max_tokens": "Maximum tokens to return in response", - "temperature": "Temperature", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings", - "thinking_budget_tokens": "Thinking budget" - }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "Maximum tokens to return in response", + "temperature": "Temperature", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings", + "thinking_budget_tokens": "Thinking budget" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template.", + "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." + } } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } - }, - "error": { - "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } } } diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 7419ea6c28f..53e00447a2e 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components.anthropic import CONF_CHAT_MODEL +from homeassistant.components.anthropic.const import DEFAULT_CONVERSATION_NAME from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -23,6 +24,15 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: data={ "api_key": "bla", }, + version=2, + subentries_data=[ + { + "data": {}, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) entry.add_to_hass(hass) return entry @@ -33,8 +43,10 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) return mock_config_entry @@ -44,9 +56,10 @@ def mock_config_entry_with_extended_thinking( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "claude-3-7-sonnet-latest", }, diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index ea4ce5a980d..09618b135db 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -16,7 +16,7 @@ 'role': 'user', }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'content': 'Certainly, calling it now!', 'role': 'assistant', 'tool_calls': list([ @@ -30,14 +30,14 @@ ]), }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'role': 'tool_result', 'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM', 'tool_name': 'test_tool', 'tool_result': 'Test response', }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'content': 'I have successfully called the function', 'role': 'assistant', 'tool_calls': None, diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 1f41b7df2c7..6007788097d 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -22,12 +22,13 @@ from homeassistant.components.anthropic.const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_THINKING_BUDGET, ) -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -71,39 +72,55 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } - assert result2["options"] == RECOMMENDED_OPTIONS + assert result2["options"] == {} + assert result2["subentries"] == [ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ] assert len(mock_setup_entry.mock_calls) == 1 -async def test_options( +async def test_creating_conversation_subentry( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options form.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test creating a conversation subentry.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - "prompt": "Speak like a pirate", - "max_tokens": 200, - }, + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "set_options" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"]["prompt"] == "Speak like a pirate" - assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock name" + + processed_options = RECOMMENDED_OPTIONS.copy() + processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + + assert result2["data"] == processed_options -async def test_options_thinking_budget_more_than_max( +async def test_subentry_options_thinking_budget_more_than_max( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: """Test error about thinking budget being more than max tokens.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + subentry = next(iter(mock_config_entry.subentries.values())) + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_type, subentry.subentry_id ) - options = await hass.config_entries.options.async_configure( + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], { "prompt": "Speak like a pirate", @@ -252,7 +269,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ), ], ) -async def test_options_switching( +async def test_subentry_options_switching( hass: HomeAssistant, mock_config_entry, mock_init_component, @@ -260,23 +277,29 @@ async def test_options_switching( new_options, expected_options, ) -> None: - """Test the options form.""" - hass.config_entries.async_update_entry(mock_config_entry, options=current_options) - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test the subentry options form.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data=current_options + ) + await hass.async_block_till_done() + + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_type, subentry.subentry_id ) if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( + options_flow = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], { **current_options, CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], }, ) - options = await hass.config_entries.options.async_configure( + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], new_options, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data == expected_options diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 6aadcf3eeb4..3ae44e552cc 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -180,21 +180,23 @@ async def test_entity( mock_init_component, ) -> None: """Test entity properties.""" - state = hass.states.get("conversation.claude") + state = hass.states.get("conversation.claude_conversation") assert state assert state.attributes["supported_features"] == 0 - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_LLM_HASS_API: "assist", }, ) with patch("anthropic.resources.models.AsyncModels.retrieve"): await hass.config_entries.async_reload(mock_config_entry.entry_id) - state = hass.states.get("conversation.claude") + state = hass.states.get("conversation.claude_conversation") assert state assert ( state.attributes["supported_features"] @@ -218,7 +220,7 @@ async def test_error_handling( ), ): result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -229,9 +231,11 @@ async def test_template_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test that template error handling works.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) @@ -244,7 +248,7 @@ async def test_template_error( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -260,9 +264,11 @@ async def test_template_variables( mock_user.id = "12345" mock_user.name = "Test User" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": ( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." @@ -286,7 +292,7 @@ async def test_template_variables( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, context, agent_id="conversation.claude" + hass, "hello", None, context, agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE @@ -304,7 +310,9 @@ async def test_conversation_agent( mock_init_component, ) -> None: """Test Anthropic Agent.""" - agent = conversation.agent_manager.async_get_agent(hass, "conversation.claude") + agent = conversation.agent_manager.async_get_agent( + hass, "conversation.claude_conversation" + ) assert agent.supported_languages == "*" @@ -332,7 +340,7 @@ async def test_function_call( expected_call_tool_args: dict[str, Any], ) -> None: """Test function call from the assistant.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -430,7 +438,7 @@ async def test_function_exception( mock_init_component, ) -> None: """Test function call with exception.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -536,7 +544,7 @@ async def test_assist_api_tools_conversion( ): assert await async_setup_component(hass, component, {}) - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" with patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock, @@ -561,17 +569,19 @@ async def test_unknown_hass_api( mock_init_component, ) -> None: """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_LLM_HASS_API: "non-existing", }, ) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", "1234", Context(), agent_id="conversation.claude" + hass, "hello", "1234", Context(), agent_id="conversation.claude_conversation" ) assert result == snapshot @@ -597,17 +607,25 @@ async def test_conversation_id( side_effect=create_stream_generator, ): result = await conversation.async_converse( - hass, "hello", "1234", Context(), agent_id="conversation.claude" + hass, + "hello", + "1234", + Context(), + agent_id="conversation.claude_conversation", ) result = await conversation.async_converse( - hass, "hello", None, None, agent_id="conversation.claude" + hass, "hello", None, None, agent_id="conversation.claude_conversation" ) conversation_id = result.conversation_id result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id="conversation.claude" + hass, + "hello", + conversation_id, + None, + agent_id="conversation.claude_conversation", ) assert result.conversation_id == conversation_id @@ -615,13 +633,13 @@ async def test_conversation_id( unknown_id = ulid_util.ulid() result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id="conversation.claude" + hass, "hello", unknown_id, None, agent_id="conversation.claude_conversation" ) assert result.conversation_id != unknown_id result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id="conversation.claude" + hass, "hello", "koala", None, agent_id="conversation.claude_conversation" ) assert result.conversation_id == "koala" @@ -654,7 +672,7 @@ async def test_refusal( "2631EDCF22E8CCC1FB35B501C9C86", None, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -695,7 +713,7 @@ async def test_extended_thinking( ), ): result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( @@ -732,7 +750,7 @@ async def test_redacted_thinking( "8432ECCCE4C1253D5E2D82641AC0E52CC2876CB", None, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( @@ -751,7 +769,7 @@ async def test_extended_thinking_tool_call( snapshot: SnapshotAssertion, ) -> None: """Test that thinking blocks and their order are preserved in with tool calls.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -841,7 +859,8 @@ async def test_extended_thinking_tool_call( conversation.chat_log.SystemContent("You are a helpful assistant."), conversation.chat_log.UserContent("What shape is a donut?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), ], [ @@ -849,10 +868,11 @@ async def test_extended_thinking_tool_call( conversation.chat_log.UserContent("What shape is a donut?"), conversation.chat_log.UserContent("Can you tell me?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="Hope this helps." + agent_id="conversation.claude_conversation", content="Hope this helps." ), ], [ @@ -861,20 +881,21 @@ async def test_extended_thinking_tool_call( conversation.chat_log.UserContent("Can you tell me?"), conversation.chat_log.UserContent("Please?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="Hope this helps." + agent_id="conversation.claude_conversation", content="Hope this helps." ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="You are welcome." + agent_id="conversation.claude_conversation", content="You are welcome." ), ], [ conversation.chat_log.SystemContent("You are a helpful assistant."), conversation.chat_log.UserContent("Turn off the lights and make me coffee"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", content="Sure.", tool_calls=[ llm.ToolInput( @@ -891,19 +912,19 @@ async def test_extended_thinking_tool_call( ), conversation.chat_log.UserContent("Thank you"), conversation.chat_log.ToolResultContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id", tool_name="HassTurnOff", tool_result={"success": True, "response": "Lights are off."}, ), conversation.chat_log.ToolResultContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id-2", tool_name="MakeCoffee", tool_result={"success": False, "response": "Not enough milk."}, ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", content="Should I add milk to the shopping list?", ), ], @@ -940,7 +961,7 @@ async def test_history_conversion( "Are you sure?", conversation_id, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) assert mock_create.mock_calls[0][2]["messages"] == snapshot diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 305e442f52d..6c7c3ff169a 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -11,7 +11,9 @@ from anthropic import ( from httpx import URL, Request, Response import pytest +from homeassistant.components.anthropic.const import DEFAULT_CONVERSATION_NAME, DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -61,3 +63,68 @@ async def test_init_error( assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() assert error in caplog.text + + +async def test_migration_from_v1_to_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + OPTIONS = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=OPTIONS, + version=1, + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Claude", + model="Anthropic", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="claude", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.version == 2 + assert mock_config_entry.data == {"api_key": "1234"} + assert mock_config_entry.options == {} + + assert len(mock_config_entry.subentries) == 1 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.unique_id is None + assert subentry.title == DEFAULT_CONVERSATION_NAME + assert subentry.subentry_type == "conversation" + assert subentry.data == OPTIONS + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.config_subentry_id == subentry.subentry_id + assert migrated_entity.device_id == device.id