Always add thinking effort for newer Anthropic models (#183236)

This commit is contained in:
Denis Shulyaka
2026-09-27 08:03:14 +02:00
committed by GitHub
parent 568a2544cd
commit 58e896d2ed
6 changed files with 536 additions and 33 deletions
@@ -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:
@@ -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",
}
+69 -30
View File
@@ -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,
@@ -212,6 +212,9 @@
"issues": {
"model_deprecated": {
"fix_flow": {
"error": {
"api_error": "[%key:component::anthropic::config_subentries::conversation::error::api_error%]"
},
"step": {
"init": {
"data": {
+169 -1
View File
@@ -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,
+278 -1
View File
@@ -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