diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index dc29978ce54e..225f49f2f91c 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -60,6 +60,7 @@ from .const import ( DEFAULT_CONVERSATION_NAME, DOMAIN, MIN_THINKING_BUDGET, + THINKING_EFFORT_NONE_SUPPORTED_MODELS, TOOL_SEARCH_UNSUPPORTED_MODELS, PromptCaching, ) @@ -411,7 +412,13 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): ): effort_options: list[str] = [] if self.model_info.capabilities.thinking.types.adaptive.supported: - effort_options.append("none") + if ( + model_alias(self.model_info.id) + in THINKING_EFFORT_NONE_SUPPORTED_MODELS + ): + effort_options.append("none") + elif self.options.get(CONF_THINKING_EFFORT) == "none": + self.options.pop(CONF_THINKING_EFFORT) if effort_capability.low.supported: effort_options.append("low") if effort_capability.medium.supported: diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index f4229a81a188..f2d6ab44b634 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -56,3 +56,12 @@ DEFAULT = { TOOL_SEARCH_UNSUPPORTED_MODELS = [ "claude-haiku", ] + +THINKING_EFFORT_NONE_SUPPORTED_MODELS = { + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-sonnet-4-6", + "claude-sonnet-5", +} diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index a7dc921eab1a..67fa9871ea2d 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -1,7 +1,7 @@ """Issue repair flow for Anthropic.""" from collections.abc import Iterator -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import anthropic from anthropic.resources.messages.messages import DEPRECATED_MODELS @@ -17,7 +17,12 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import CONF_CHAT_MODEL, DOMAIN +from .const import ( + CONF_CHAT_MODEL, + CONF_THINKING_EFFORT, + DOMAIN, + THINKING_EFFORT_NONE_SUPPORTED_MODELS, +) from .coordinator import model_alias if TYPE_CHECKING: @@ -30,7 +35,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow): _subentry_iter: Iterator[tuple[str, str]] | None _current_entry_id: str | None _current_subentry_id: str | None - _model_list_cache: dict[str, list[SelectOptionDict]] | None + _model_list_cache: dict[str, list[anthropic.types.ModelInfo]] | None def __init__(self) -> None: """Initialize the flow.""" @@ -45,7 +50,24 @@ class ModelDeprecatedRepairFlow(RepairsFlow): ) -> RepairsFlowResult: """Handle the steps of a fix flow.""" if user_input and user_input.get(CONF_CHAT_MODEL): - self._async_update_current_subentry(user_input) + try: + await self._async_update_current_subentry(user_input) + except anthropic.AnthropicError as err: + if self.cur_step is None: + raise + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + cast(probatio.Schema, self.cur_step["data_schema"]), user_input + ), + errors={CONF_CHAT_MODEL: "api_error"}, + description_placeholders={ + **(self.cur_step["description_placeholders"] or {}), + "message": err.message + if isinstance(err, anthropic.APIError) + else str(err), + }, + ) target = await self._async_next_target() if target is None: @@ -54,16 +76,21 @@ class ModelDeprecatedRepairFlow(RepairsFlow): entry, subentry, model = target if self._model_list_cache is None: self._model_list_cache = {} - if entry.entry_id in self._model_list_cache: - model_list = self._model_list_cache[entry.entry_id] - else: + if entry.entry_id not in self._model_list_cache: client = entry.runtime_data.client - model_list = [ - model_option - for model_option in await self.get_model_list(client) - if model_option["value"] not in DEPRECATED_MODELS - ] - self._model_list_cache[entry.entry_id] = model_list + try: + models = (await client.models.list(timeout=10.0)).data + except anthropic.AnthropicError: + models = [] + self._model_list_cache[entry.entry_id] = models + model_list = [ + SelectOptionDict( + label=model_info.display_name, + value=model_alias(model_info.id), + ) + for model_info in self._model_list_cache[entry.entry_id] + if model_alias(model_info.id) not in DEPRECATED_MODELS + ] family = ( model.removeprefix("claude-") @@ -107,22 +134,6 @@ class ModelDeprecatedRepairFlow(RepairsFlow): }, ) - async def get_model_list( - self, client: anthropic.AsyncAnthropic - ) -> list[SelectOptionDict]: - """Get list of available models.""" - try: - models = (await client.models.list(timeout=10.0)).data - except anthropic.AnthropicError: - models = [] - return [ - SelectOptionDict( - label=model_info.display_name, - value=model_alias(model_info.id), - ) - for model_info in models - ] - def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]: """Yield entry/subentry pairs that use deprecated models.""" for entry in self.hass.config_entries.async_entries(DOMAIN): @@ -164,7 +175,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow): self._current_subentry_id = subentry_id return entry, subentry, model - def _async_update_current_subentry(self, user_input: dict[str, str]) -> None: + async def _async_update_current_subentry(self, user_input: dict[str, str]) -> None: """Update the currently selected subentry.""" if ( self._current_entry_id is None @@ -185,6 +196,34 @@ class ModelDeprecatedRepairFlow(RepairsFlow): **subentry.data, CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL], } + if ( + subentry.data.get(CONF_THINKING_EFFORT) == "none" + and (alias := model_alias(user_input[CONF_CHAT_MODEL])) + not in THINKING_EFFORT_NONE_SUPPORTED_MODELS + ): + model_info = next( + ( + model + for model in (self._model_list_cache or {}).get(entry.entry_id, []) + if model_alias(model.id) == alias + ), + None, + ) + if model_info is None: + model_info = await entry.runtime_data.client.models.retrieve( + user_input[CONF_CHAT_MODEL], timeout=10.0 + ) + if ( + model_info.capabilities + and ( + model_info.capabilities.thinking.types.adaptive.supported + or model_info.capabilities.effort.supported + ) + and model_alias(model_info.id) + not in THINKING_EFFORT_NONE_SUPPORTED_MODELS + ): + updated_data.pop(CONF_THINKING_EFFORT) + self.hass.config_entries.async_update_subentry( entry, subentry, diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 692173c28690..012f6ef2f2e2 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -212,6 +212,9 @@ "issues": { "model_deprecated": { "fix_flow": { + "error": { + "api_error": "[%key:component::anthropic::config_subentries::conversation::error::api_error%]" + }, "step": { "init": { "data": { diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 80d9e19da3f6..ce0e74453c0e 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -49,7 +49,7 @@ from homeassistant.components.anthropic.const import ( ) from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from tests.common import MockConfigEntry @@ -333,6 +333,174 @@ async def test_subentry_options_thinking_budget_more_than_max( assert subentry.data["thinking_budget"] == 8192 +@pytest.mark.parametrize( + "model", + [ + pytest.param("claude-opus-5-5", id="opus_5_5"), + pytest.param("claude-fable-5", id="fable_5"), + pytest.param("claude-fable-5-1", id="fable_5_1"), + ], +) +@pytest.mark.parametrize( + "subentry_type", + [ + pytest.param("conversation", id="conversation"), + pytest.param("ai_task_data", id="ai_task"), + ], +) +@pytest.mark.usefixtures("mock_init_component") +async def test_creating_subentry_with_required_thinking( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + model: str, + subentry_type: str, +) -> None: + """Test always-adaptive models cannot be configured without thinking.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, subentry_type), + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Adaptive thinking", CONF_RECOMMENDED: False}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_CHAT_MODEL: model} + ) + assert result["step_id"] == "model" + assert ( + "none" + not in (result["data_schema"].schema[CONF_THINKING_EFFORT].config["options"]) + ) + assert CONF_THINKING_BUDGET not in result["data_schema"].schema + + with pytest.raises(InvalidData): + await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_THINKING_EFFORT: "none"} + ) + + result = await hass.config_entries.subentries.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_THINKING_EFFORT] == "low" + + +@pytest.mark.parametrize( + "model", + [ + pytest.param("claude-opus-4-6", id="opus_4_6"), + pytest.param("claude-opus-4-7", id="opus_4_7"), + pytest.param("claude-opus-4-8", id="opus_4_8"), + pytest.param("claude-opus-5", id="opus_5"), + pytest.param("claude-sonnet-4-6", id="sonnet_4_6"), + pytest.param("claude-sonnet-5", id="sonnet_5"), + ], +) +@pytest.mark.usefixtures("mock_init_component") +async def test_subentry_with_optional_thinking( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + model: str, +) -> None: + """Test thinking can still be disabled on supported adaptive models.""" + subentry = next(iter(mock_config_entry.subentries.values())) + result = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_RECOMMENDED: False} + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_CHAT_MODEL: model} + ) + assert result["step_id"] == "model" + assert ( + "none" in (result["data_schema"].schema[CONF_THINKING_EFFORT].config["options"]) + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_THINKING_EFFORT: "none"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert subentry.data[CONF_THINKING_EFFORT] == "none" + + +@pytest.mark.parametrize( + "model", + [ + pytest.param("claude-opus-5-5", id="opus_5_5"), + pytest.param("claude-fable-5", id="fable_5"), + pytest.param("claude-fable-5-1", id="fable_5_1"), + ], +) +@pytest.mark.parametrize( + "subentry_type", + [ + pytest.param("conversation", id="conversation"), + pytest.param("ai_task_data", id="ai_task"), + ], +) +@pytest.mark.parametrize( + ("current_effort", "user_input", "expected_effort"), + [ + pytest.param("none", {}, "low", id="enable_thinking"), + pytest.param( + "high", {CONF_THINKING_EFFORT: "high"}, "high", id="preserve_thinking" + ), + ], +) +@pytest.mark.usefixtures("mock_init_component") +async def test_reconfigure_subentry_with_required_thinking( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + model: str, + subentry_type: str, + current_effort: str, + user_input: dict[str, str], + expected_effort: str, +) -> None: + """Test migrating to an always-adaptive model clears disabled thinking.""" + subentry = next( + subentry + for subentry in mock_config_entry.subentries.values() + if subentry.subentry_type == subentry_type + ) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={ + CONF_CHAT_MODEL: "claude-opus-4-6", + CONF_THINKING_EFFORT: current_effort, + }, + ) + await hass.async_block_till_done() + + result = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_RECOMMENDED: False} + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_CHAT_MODEL: model} + ) + assert result["step_id"] == "model" + effort_key = next( + key for key in result["data_schema"].schema if key == CONF_THINKING_EFFORT + ) + assert (effort_key.description or {}).get( + "suggested_value", effort_key.default() + ) == expected_effort + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert subentry.data[CONF_CHAT_MODEL] == model + assert subentry.data[CONF_THINKING_EFFORT] == expected_effort + + @pytest.mark.usefixtures("mock_init_component") async def test_subentry_web_search_user_location( hass: HomeAssistant, diff --git a/tests/components/anthropic/test_repairs.py b/tests/components/anthropic/test_repairs.py index 7aa697845f8a..268b3b82149f 100644 --- a/tests/components/anthropic/test_repairs.py +++ b/tests/components/anthropic/test_repairs.py @@ -3,9 +3,25 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +from anthropic import ( + APIConnectionError, + APIError, + APITimeoutError, + InternalServerError, + NotFoundError, +) from anthropic.pagination import AsyncPage +from anthropic.types import ModelInfo +from httpx import Request, Response +import pytest -from homeassistant.components.anthropic.const import CONF_CHAT_MODEL, DOMAIN +from homeassistant.components.anthropic.const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_THINKING_EFFORT, + DOMAIN, +) +from homeassistant.components.anthropic.coordinator import model_alias from homeassistant.config_entries import ( ConfigEntryState, ConfigSubentry, @@ -217,3 +233,264 @@ async def test_repair_flow_no_deprecated_models( assert result["type"] == FlowResultType.CREATE_ENTRY assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is None + + +@pytest.mark.parametrize( + "available_models", + [ + pytest.param(model_list, id="listed_model"), + pytest.param([], id="custom_model"), + ], +) +@pytest.mark.parametrize( + ("model", "thinking_effort", "expected_options"), + [ + pytest.param("claude-opus-5-5", "none", {}, id="opus_5_5"), + pytest.param("claude-fable-5", "none", {}, id="fable_5"), + pytest.param("claude-fable-5-1", "none", {}, id="fable_5_1"), + pytest.param("claude-opus-5-5-20260921", "none", {}, id="versioned_opus_5_5"), + pytest.param( + "claude-opus-5-5", + "high", + {CONF_THINKING_EFFORT: "high"}, + id="adaptive_effort", + ), + pytest.param( + "claude-opus-4-6", + "none", + {CONF_THINKING_EFFORT: "none"}, + id="optional_thinking", + ), + pytest.param( + "claude-opus-4-6-20260204", + "none", + {CONF_THINKING_EFFORT: "none"}, + id="versioned_optional_thinking", + ), + pytest.param("claude-opus-4-5", "none", {}, id="nonadaptive_effort"), + pytest.param( + "claude-opus-4-5-20251101", "none", {}, id="versioned_nonadaptive_effort" + ), + pytest.param( + "claude-opus-4-5", + "high", + {CONF_THINKING_EFFORT: "high"}, + id="valid_nonadaptive_effort", + ), + pytest.param( + "claude-haiku-4-5", + "none", + {CONF_THINKING_EFFORT: "none"}, + id="no_effort_support", + ), + ], +) +async def test_repair_flow_thinking_effort( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + available_models: list[ModelInfo], + model: str, + thinking_effort: str, + expected_options: dict[str, str], +) -> None: + """Remove incompatible thinking effort while preserving valid effort settings.""" + entry = _make_entry( + hass, + title="Claude", + api_key="key", + subentries_data=[ + { + "data": { + CONF_CHAT_MODEL: "claude-3-7-sonnet-20250219", + CONF_THINKING_EFFORT: thinking_effort, + CONF_MAX_TOKENS: 4096, + }, + "subentry_type": "conversation", + "title": "Conversation", + "unique_id": None, + } + ], + ) + entry.runtime_data.client.models.list.return_value = AsyncPage( + data=available_models + ) + entry.runtime_data.client.models.retrieve = AsyncMock( + return_value=next( + model_info + for model_info in model_list + if model_alias(model_info.id) == model_alias(model) + ) + ) + ir.async_create_issue( + hass, + DOMAIN, + "model_deprecated", + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="model_deprecated", + ) + await _setup_repairs(hass) + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated") + result = await process_repair_fix_flow( + client, + result["flow_id"], + json={CONF_CHAT_MODEL: model}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert _get_subentry(entry, "conversation").data == { + CONF_CHAT_MODEL: model, + CONF_MAX_TOKENS: 4096, + **expected_options, + } + + +@pytest.mark.parametrize( + "error", + [ + pytest.param( + APITimeoutError(request=Request("GET", "https://api.anthropic.com")), + id="timeout", + ), + pytest.param( + APIConnectionError(request=Request("GET", "https://api.anthropic.com")), + id="connection_error", + ), + pytest.param( + NotFoundError( + "Model not found", + response=Response( + 404, request=Request("GET", "https://api.anthropic.com") + ), + body=None, + ), + id="not_found", + ), + pytest.param( + InternalServerError( + "Server unavailable", + response=Response( + 500, request=Request("GET", "https://api.anthropic.com") + ), + body=None, + ), + id="server_error", + ), + ], +) +async def test_repair_flow_model_lookup_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + error: APIError, +) -> None: + """Keep the current repair available for retry when model lookup fails.""" + conversation_data = { + CONF_CHAT_MODEL: "claude-3-7-sonnet-20250219", + CONF_THINKING_EFFORT: "none", + CONF_MAX_TOKENS: 4096, + } + task_data = {CONF_CHAT_MODEL: "claude-3-5-haiku-20241022"} + entry = _make_entry( + hass, + title="Claude", + api_key="key", + subentries_data=[ + { + "data": conversation_data, + "subentry_type": "conversation", + "title": "Conversation", + "unique_id": None, + }, + { + "data": task_data, + "subentry_type": "ai_task_data", + "title": "AI task", + "unique_id": None, + }, + ], + ) + entry.runtime_data.client.models.list.return_value = AsyncPage(data=[]) + retrieve = entry.runtime_data.client.models.retrieve = AsyncMock( + side_effect=[ + error, + next(model for model in model_list if model.id == "claude-opus-5-5"), + ] + ) + ir.async_create_issue( + hass, + DOMAIN, + "model_deprecated", + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="model_deprecated", + ) + await _setup_repairs(hass) + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated") + flow_id = result["flow_id"] + placeholders = result["description_placeholders"] + result = await process_repair_fix_flow( + client, + flow_id, + json={CONF_CHAT_MODEL: "claude-opus-5-5"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["flow_id"] == flow_id + assert result["step_id"] == "init" + assert result["errors"] == {CONF_CHAT_MODEL: "api_error"} + assert result["description_placeholders"] == { + **placeholders, + "message": error.message, + } + model_field = next( + field for field in result["data_schema"] if field["name"] == CONF_CHAT_MODEL + ) + assert model_field["description"]["suggested_value"] == "claude-opus-5-5" + assert _get_subentry(entry, "conversation").data == conversation_data + assert _get_subentry(entry, "ai_task_data").data == task_data + assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is not None + retrieve.assert_awaited_once_with("claude-opus-5-5", timeout=10.0) + + result = await process_repair_fix_flow( + client, + flow_id, + json={CONF_CHAT_MODEL: "claude-opus-5-5"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["flow_id"] == flow_id + assert not result["errors"] + assert result["description_placeholders"] == { + "entry_name": entry.title, + "model": "claude-3-5-haiku-20241022", + "subentry_name": "AI task", + "subentry_type": "AI task", + "retirement_date": "February 19th, 2026", + } + assert _get_subentry(entry, "conversation").data == { + CONF_CHAT_MODEL: "claude-opus-5-5", + CONF_MAX_TOKENS: 4096, + } + assert _get_subentry(entry, "ai_task_data").data == task_data + assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is not None + assert retrieve.await_count == 2 + entry.runtime_data.client.models.list.assert_awaited_once_with(timeout=10.0) + + result = await process_repair_fix_flow( + client, + flow_id, + json={CONF_CHAT_MODEL: "claude-haiku-4-5"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert _get_subentry(entry, "ai_task_data").data == { + CONF_CHAT_MODEL: "claude-haiku-4-5" + } + assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is None