diff --git a/homeassistant/components/anthropic/ai_task.py b/homeassistant/components/anthropic/ai_task.py index eac6f6c4fd31..7cac20f0e08c 100644 --- a/homeassistant/components/anthropic/ai_task.py +++ b/homeassistant/components/anthropic/ai_task.py @@ -56,9 +56,7 @@ class AnthropicTaskEntity( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log( - chat_log, task.name, task.structure, max_iterations=1000 - ) + await self._async_handle_chat_log(chat_log, task.structure, max_iterations=1000) if not isinstance(chat_log.content[-1], conversation.AssistantContent): raise HomeAssistantError( diff --git a/homeassistant/components/anthropic/coordinator.py b/homeassistant/components/anthropic/coordinator.py index fd8d73782896..bb5d8f8608ac 100644 --- a/homeassistant/components/anthropic/coordinator.py +++ b/homeassistant/components/anthropic/coordinator.py @@ -37,10 +37,9 @@ async def async_create_client( @callback def model_alias(model_id: str) -> str: """Resolve alias from versioned model name.""" - if model_id[-2:-1] != "-" and not model_id.endswith("-preview"): - model_id = model_id[:-9] - if model_id.endswith("-4"): - return model_id + "-0" + model, _, version = model_id.rpartition("-") + if len(version) == 8 and version.isdecimal(): + return model return model_id diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 3039cdf222d4..4dacf98bb920 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -57,9 +57,6 @@ from anthropic.types import ( ThinkingConfigDisabledParam, ThinkingConfigEnabledParam, ThinkingDelta, - ToolChoiceAnyParam, - ToolChoiceAutoParam, - ToolChoiceToolParam, ToolParam, ToolSearchToolBm25_20251119Param, ToolSearchToolResultBlock, @@ -111,7 +108,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.json import json_dumps from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonArrayType, JsonObjectType from .const import ( @@ -528,12 +525,10 @@ class AnthropicDeltaStream: self, chat_log: conversation.ChatLog, stream: AsyncStream[MessageStreamEvent], - output_tool: str | None = None, ) -> None: """Initialize the delta stream.""" self._chat_log: conversation.ChatLog = chat_log self._stream: AsyncStream[MessageStreamEvent] = stream - self._output_tool: str | None = output_tool self._buffer: deque[ conversation.AssistantContentDeltaDict @@ -666,15 +661,6 @@ class AnthropicDeltaStream: input=input, ) self._current_tool_args = "" - if name == self._output_tool: - if self._first_block or self._content_details.has_content(): - if self._content_details: - self._content_details.delete_empty() - self._buffer.append({"native": self._content_details}) - self._content_details = ContentDetails() - self._content_details.add_citation_detail() - self._buffer.append({"role": "assistant"}) - self._first_block = False def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None: """Handle TextBlock.""" @@ -814,14 +800,7 @@ class AnthropicDeltaStream: def on_input_json_delta(self, partial_json: str) -> None: """Handle InputJSONDelta.""" - if ( - self._current_tool_block is not None - and self._current_tool_block["name"] == self._output_tool - ): - self._content_details.citation_details[-1].length += len(partial_json) - self._buffer.append({"content": partial_json}) - else: - self._current_tool_args += partial_json + self._current_tool_args += partial_json def on_text_delta(self, text: str) -> None: """Handle TextDelta.""" @@ -845,9 +824,6 @@ class AnthropicDeltaStream: def on_content_block_stop_event(self, index: int) -> None: """Handle RawContentBlockStopEvent.""" if self._current_tool_block is not None: - if self._current_tool_block["name"] == self._output_tool: - self._current_tool_block = None - return tool_args = ( json.loads(self._current_tool_args) if self._current_tool_args else {} ) @@ -928,12 +904,11 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): entry_type=dr.DeviceEntryType.SERVICE, ) - async def _get_model_args( # noqa: C901 + async def _get_model_args( self, chat_log: conversation.ChatLog, - structure_name: str | None = None, structure: probatio.Schema | None = None, - ) -> tuple[MessageCreateParamsStreaming, str | None]: + ) -> MessageCreateParamsStreaming: """Get the model arguments.""" options: dict[str, Any] = DEFAULT | self.subentry.data @@ -1110,77 +1085,21 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): ) ) - if structure and structure_name: - if ( - self.model_info.capabilities - and self.model_info.capabilities.structured_outputs.supported - ): - # Native structured output for those models who support it. - structure_name = None - model_args.setdefault("output_config", OutputConfigParam())[ - "format" - ] = JSONOutputFormatParam( + if structure: + model_args.setdefault("output_config", OutputConfigParam())["format"] = ( + JSONOutputFormatParam( type="json_schema", - schema={ - **probatio.to_openapi( + schema=anthropic.transform_schema( + probatio.to_openapi( structure, custom_serializer=chat_log.llm_api.custom_serializer if chat_log.llm_api else llm.selector_serializer, openapi_version="3.1.0", - ), - "additionalProperties": False, - }, + ) + ), ) - elif model_args["thinking"]["type"] == "disabled": - structure_name = slugify(structure_name) - if not tools: - # Simplest case: no tools and no extended thinking - # Add a tool and force its use - model_args["tool_choice"] = ToolChoiceToolParam( - type="tool", - name=structure_name, - ) - else: - # Second case: tools present but no extended thinking - # Allow the model to use any tool but not text response - # The model should know to use the right tool by its description - model_args["tool_choice"] = ToolChoiceAnyParam( - type="any", - ) - else: - # Extended thinking is enabled. With extended thinking, we cannot - # force tool use or disable text responses, so we add a hint to the - # system prompt instead. With extended thinking, the model should be - # smart enough to use the tool. - structure_name = slugify(structure_name) - model_args["tool_choice"] = ToolChoiceAutoParam( - type="auto", - ) - - model_args["system"].append( # type: ignore[union-attr] - TextBlockParam( - type="text", - text=f"Claude MUST use the '{structure_name}' tool to provide " - "the final answer instead of plain text.", - ) - ) - - if structure_name: - tools.append( - ToolParam( - name=structure_name, - description="Use this tool to reply to the user", - input_schema=probatio.to_openapi( - structure, - custom_serializer=chat_log.llm_api.custom_serializer - if chat_log.llm_api - else llm.selector_serializer, - openapi_version="3.1.0", - ), - ) - ) - preloaded_tools.append(structure_name) + ) if tools: if options[CONF_TOOL_SEARCH] and len(tools) > len(preloaded_tools) + 1: @@ -1196,19 +1115,16 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): model_args["tools"] = tools - return model_args, structure_name + return model_args async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, - structure_name: str | None = None, structure: probatio.Schema | None = None, max_iterations: int = MAX_TOOL_ITERATIONS, ) -> None: """Generate an answer for the chat log.""" - model_args, structure_name = await self._get_model_args( - chat_log, structure_name, structure - ) + model_args = await self._get_model_args(chat_log, structure) coordinator = self.entry.runtime_data client = coordinator.client @@ -1222,11 +1138,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): content async for content in chat_log.async_add_delta_content_stream( self.entity_id, - AnthropicDeltaStream( - chat_log, - stream, - output_tool=structure_name or None, - ), + AnthropicDeltaStream(chat_log, stream), ) ] ) diff --git a/tests/components/anthropic/__init__.py b/tests/components/anthropic/__init__.py index cacd738bb37b..3d091a91f48d 100644 --- a/tests/components/anthropic/__init__.py +++ b/tests/components/anthropic/__init__.py @@ -53,6 +53,154 @@ from anthropic.types.web_fetch_tool_result_block import ( ) model_list = [ + ModelInfo( + id="claude-opus-5-5", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=True), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=True), + medium=CapabilitySupport(supported=True), + supported=True, + xhigh=CapabilitySupport(supported=True), + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=True), + enabled=CapabilitySupport(supported=False), + ), + ), + ), + created_at=datetime.datetime(2026, 9, 21, 16, 24, tzinfo=datetime.UTC), + display_name="Claude Opus 5.5", + max_input_tokens=1000000, + max_tokens=128000, + type="model", + ), + ModelInfo( + id="claude-fable-5-1", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=True), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=True), + medium=CapabilitySupport(supported=True), + supported=True, + xhigh=CapabilitySupport(supported=True), + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=True), + enabled=CapabilitySupport(supported=False), + ), + ), + ), + created_at=datetime.datetime(2026, 8, 28, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Fable 5.1", + max_input_tokens=1000000, + max_tokens=128000, + type="model", + ), + ModelInfo( + id="claude-opus-5", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=True), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=True), + medium=CapabilitySupport(supported=True), + supported=True, + xhigh=CapabilitySupport(supported=True), + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=True), + enabled=CapabilitySupport(supported=False), + ), + ), + ), + created_at=datetime.datetime(2026, 7, 24, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 5", + max_input_tokens=1000000, + max_tokens=128000, + type="model", + ), + ModelInfo( + id="claude-sonnet-5", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=True), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=True), + medium=CapabilitySupport(supported=True), + supported=True, + xhigh=CapabilitySupport(supported=True), + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=True), + enabled=CapabilitySupport(supported=False), + ), + ), + ), + created_at=datetime.datetime(2026, 6, 29, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Sonnet 5", + max_input_tokens=1000000, + max_tokens=128000, + type="model", + ), ModelInfo( id="claude-fable-5", capabilities=ModelCapabilities( @@ -349,117 +497,6 @@ model_list = [ max_tokens=64000, type="model", ), - ModelInfo( - id="claude-opus-4-1-20250805", - capabilities=ModelCapabilities( - batch=CapabilitySupport(supported=True), - citations=CapabilitySupport(supported=True), - code_execution=CapabilitySupport(supported=False), - context_management=ContextManagementCapability( - clear_thinking_20251015=CapabilitySupport(supported=True), - clear_tool_uses_20250919=CapabilitySupport(supported=True), - compact_20260112=CapabilitySupport(supported=False), - supported=True, - ), - effort=EffortCapability( - high=CapabilitySupport(supported=False), - low=CapabilitySupport(supported=False), - max=CapabilitySupport(supported=False), - medium=CapabilitySupport(supported=False), - supported=False, - xhigh=CapabilitySupport(supported=False), - ), - image_input=CapabilitySupport(supported=True), - pdf_input=CapabilitySupport(supported=True), - structured_outputs=CapabilitySupport(supported=True), - thinking=ThinkingCapability( - supported=True, - types=ThinkingTypes( - adaptive=CapabilitySupport(supported=False), - enabled=CapabilitySupport(supported=True), - ), - ), - ), - created_at=datetime.datetime(2025, 8, 5, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Opus 4.1", - max_input_tokens=200000, - max_tokens=32000, - type="model", - ), - ModelInfo( - id="claude-opus-4-20250514", - capabilities=ModelCapabilities( - batch=CapabilitySupport(supported=True), - citations=CapabilitySupport(supported=True), - code_execution=CapabilitySupport(supported=False), - context_management=ContextManagementCapability( - clear_thinking_20251015=CapabilitySupport(supported=True), - clear_tool_uses_20250919=CapabilitySupport(supported=True), - compact_20260112=CapabilitySupport(supported=False), - supported=True, - ), - effort=EffortCapability( - high=CapabilitySupport(supported=False), - low=CapabilitySupport(supported=False), - max=CapabilitySupport(supported=False), - medium=CapabilitySupport(supported=False), - supported=False, - xhigh=CapabilitySupport(supported=False), - ), - image_input=CapabilitySupport(supported=True), - pdf_input=CapabilitySupport(supported=True), - structured_outputs=CapabilitySupport(supported=False), - thinking=ThinkingCapability( - supported=True, - types=ThinkingTypes( - adaptive=CapabilitySupport(supported=False), - enabled=CapabilitySupport(supported=True), - ), - ), - ), - created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Opus 4", - max_input_tokens=200000, - max_tokens=32000, - type="model", - ), - ModelInfo( - id="claude-sonnet-4-20250514", - capabilities=ModelCapabilities( - batch=CapabilitySupport(supported=True), - citations=CapabilitySupport(supported=True), - code_execution=CapabilitySupport(supported=False), - context_management=ContextManagementCapability( - clear_thinking_20251015=CapabilitySupport(supported=True), - clear_tool_uses_20250919=CapabilitySupport(supported=True), - compact_20260112=CapabilitySupport(supported=False), - supported=True, - ), - effort=EffortCapability( - high=CapabilitySupport(supported=False), - low=CapabilitySupport(supported=False), - max=CapabilitySupport(supported=False), - medium=CapabilitySupport(supported=False), - supported=False, - xhigh=CapabilitySupport(supported=False), - ), - image_input=CapabilitySupport(supported=True), - pdf_input=CapabilitySupport(supported=True), - structured_outputs=CapabilitySupport(supported=False), - thinking=ThinkingCapability( - supported=True, - types=ThinkingTypes( - adaptive=CapabilitySupport(supported=False), - enabled=CapabilitySupport(supported=True), - ), - ), - ), - created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Sonnet 4", - max_input_tokens=1000000, - max_tokens=64000, - type="model", - ), ] diff --git a/tests/components/anthropic/snapshots/test_ai_task.ambr b/tests/components/anthropic/snapshots/test_ai_task.ambr index a3d4d974e726..72c81c805395 100644 --- a/tests/components/anthropic/snapshots/test_ai_task.ambr +++ b/tests/components/anthropic/snapshots/test_ai_task.ambr @@ -54,273 +54,3 @@ }), }) # --- -# name: test_generate_structured_data_legacy - dict({ - 'container': None, - 'max_tokens': 3000, - 'messages': list([ - dict({ - 'content': 'Generate test data', - 'role': 'user', - }), - dict({ - 'content': '{"characters": ["Mario", "Luigi"]}', - 'role': 'assistant', - }), - ]), - 'model': 'claude-sonnet-4-0', - 'stream': True, - 'system': list([ - dict({ - 'cache_control': dict({ - 'type': 'ephemeral', - }), - 'text': ''' - You are a Home Assistant expert and help users with their tasks. - Current time is 04:00:00. Today's date is 2026-01-01. - ''', - 'type': 'text', - }), - ]), - 'thinking': dict({ - 'type': 'disabled', - }), - 'tool_choice': dict({ - 'name': 'test_task', - 'type': 'tool', - }), - 'tools': list([ - dict({ - 'description': 'Use this tool to reply to the user', - 'input_schema': dict({ - 'additionalProperties': False, - 'properties': dict({ - 'characters': dict({ - 'items': dict({ - 'type': 'string', - }), - 'type': 'array', - }), - }), - 'required': list([ - 'characters', - ]), - 'type': 'object', - }), - 'name': 'test_task', - }), - ]), - }) -# --- -# name: test_generate_structured_data_legacy_extended_thinking - dict({ - 'container': None, - 'max_tokens': 3000, - 'messages': list([ - dict({ - 'content': 'Generate test data', - 'role': 'user', - }), - dict({ - 'content': list([ - dict({ - 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', - 'thinking': "Let's use the tool to respond", - 'type': 'thinking', - }), - dict({ - 'text': '{"characters": ["Mario", "Luigi"]}', - 'type': 'text', - }), - ]), - 'role': 'assistant', - }), - ]), - 'model': 'claude-sonnet-4-0', - 'stream': True, - 'system': list([ - dict({ - 'cache_control': dict({ - 'type': 'ephemeral', - }), - 'text': ''' - You are a Home Assistant expert and help users with their tasks. - Current time is 04:00:00. Today's date is 2026-01-01. - ''', - 'type': 'text', - }), - dict({ - 'text': "Claude MUST use the 'test_task' tool to provide the final answer instead of plain text.", - 'type': 'text', - }), - ]), - 'thinking': dict({ - 'budget_tokens': 1500, - 'display': 'summarized', - 'type': 'enabled', - }), - 'tool_choice': dict({ - 'type': 'auto', - }), - 'tools': list([ - dict({ - 'description': 'Use this tool to reply to the user', - 'input_schema': dict({ - 'additionalProperties': False, - 'properties': dict({ - 'characters': dict({ - 'items': dict({ - 'type': 'string', - }), - 'type': 'array', - }), - }), - 'required': list([ - 'characters', - ]), - 'type': 'object', - }), - 'name': 'test_task', - }), - ]), - }) -# --- -# name: test_generate_structured_data_legacy_extra_text_block - dict({ - 'container': None, - 'max_tokens': 3000, - 'messages': list([ - dict({ - 'content': 'Generate test data', - 'role': 'user', - }), - dict({ - 'content': list([ - dict({ - 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', - 'thinking': "Let's use the tool to respond", - 'type': 'thinking', - }), - dict({ - 'text': 'Sure!', - 'type': 'text', - }), - dict({ - 'text': '{"characters": ["Mario", "Luigi"]}', - 'type': 'text', - }), - ]), - 'role': 'assistant', - }), - ]), - 'model': 'claude-sonnet-4-0', - 'stream': True, - 'system': list([ - dict({ - 'cache_control': dict({ - 'type': 'ephemeral', - }), - 'text': ''' - You are a Home Assistant expert and help users with their tasks. - Current time is 04:00:00. Today's date is 2026-01-01. - ''', - 'type': 'text', - }), - dict({ - 'text': "Claude MUST use the 'test_task' tool to provide the final answer instead of plain text.", - 'type': 'text', - }), - ]), - 'thinking': dict({ - 'budget_tokens': 1500, - 'display': 'summarized', - 'type': 'enabled', - }), - 'tool_choice': dict({ - 'type': 'auto', - }), - 'tools': list([ - dict({ - 'description': 'Use this tool to reply to the user', - 'input_schema': dict({ - 'additionalProperties': False, - 'properties': dict({ - 'characters': dict({ - 'items': dict({ - 'type': 'string', - }), - 'type': 'array', - }), - }), - 'required': list([ - 'characters', - ]), - 'type': 'object', - }), - 'name': 'test_task', - }), - ]), - }) -# --- -# name: test_generate_structured_data_legacy_tools - dict({ - 'container': None, - 'max_tokens': 3000, - 'messages': list([ - dict({ - 'content': 'Generate test data', - 'role': 'user', - }), - dict({ - 'content': '{"characters": ["Mario", "Luigi"]}', - 'role': 'assistant', - }), - ]), - 'model': 'claude-sonnet-4-0', - 'stream': True, - 'system': list([ - dict({ - 'cache_control': dict({ - 'type': 'ephemeral', - }), - 'text': ''' - You are a Home Assistant expert and help users with their tasks. - Current time is 04:00:00. Today's date is 2026-01-01. - ''', - 'type': 'text', - }), - ]), - 'thinking': dict({ - 'type': 'disabled', - }), - 'tool_choice': dict({ - 'type': 'any', - }), - 'tools': list([ - dict({ - 'max_uses': 5, - 'name': 'web_search', - 'type': 'web_search_20250305', - }), - dict({ - 'description': 'Use this tool to reply to the user', - 'input_schema': dict({ - 'additionalProperties': False, - 'properties': dict({ - 'characters': dict({ - 'items': dict({ - 'type': 'string', - }), - 'type': 'array', - }), - }), - 'required': list([ - 'characters', - ]), - 'type': 'object', - }), - 'name': 'test_task', - }), - ]), - }) -# --- diff --git a/tests/components/anthropic/snapshots/test_config_flow.ambr b/tests/components/anthropic/snapshots/test_config_flow.ambr index 921984a70865..009640bffccb 100644 --- a/tests/components/anthropic/snapshots/test_config_flow.ambr +++ b/tests/components/anthropic/snapshots/test_config_flow.ambr @@ -1,6 +1,22 @@ # serializer version: 1 # name: test_model_list list([ + dict({ + 'label': 'Claude Opus 5.5', + 'value': 'claude-opus-5-5', + }), + dict({ + 'label': 'Claude Fable 5.1', + 'value': 'claude-fable-5-1', + }), + dict({ + 'label': 'Claude Opus 5', + 'value': 'claude-opus-5', + }), + dict({ + 'label': 'Claude Sonnet 5', + 'value': 'claude-sonnet-5', + }), dict({ 'label': 'Claude Fable 5', 'value': 'claude-fable-5', @@ -33,17 +49,5 @@ 'label': 'Claude Sonnet 4.5', 'value': 'claude-sonnet-4-5', }), - dict({ - 'label': 'Claude Opus 4.1', - 'value': 'claude-opus-4-1', - }), - dict({ - 'label': 'Claude Opus 4', - 'value': 'claude-opus-4-0', - }), - dict({ - 'label': 'Claude Sonnet 4', - 'value': 'claude-sonnet-4-0', - }), ]) # --- diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 3e6a90c37c3b..a927d418fb91 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1831,7 +1831,9 @@ 'result': dict({ 'data': dict({ 'content': dict({ - 'citations': None, + 'citations': dict({ + 'enabled': True, + }), 'source': dict({ 'data': ''' Home Assistant new version is out! @@ -1889,8 +1891,8 @@ 'cited_text': 'Anthropic integration now supports web fetch tool.', 'document_index': 0, 'document_title': 'Latest Home Assistant Release Notes', - 'end_char_index': 105, - 'start_char_index': 56, + 'end_char_index': 104, + 'start_char_index': 54, 'type': 'char_location', }), ]), @@ -1936,7 +1938,9 @@ dict({ 'content': dict({ 'content': dict({ - 'citations': None, + 'citations': dict({ + 'enabled': True, + }), 'source': dict({ 'data': ''' Home Assistant new version is out! @@ -1982,8 +1986,8 @@ 'cited_text': 'Anthropic integration now supports web fetch tool.', 'document_index': 0, 'document_title': 'Latest Home Assistant Release Notes', - 'end_char_index': 105, - 'start_char_index': 56, + 'end_char_index': 104, + 'start_char_index': 54, 'type': 'char_location', }), ]), diff --git a/tests/components/anthropic/test_ai_task.py b/tests/components/anthropic/test_ai_task.py index 0ad3e7c2b67c..75fb112e6cb3 100644 --- a/tests/components/anthropic/test_ai_task.py +++ b/tests/components/anthropic/test_ai_task.py @@ -11,15 +11,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import ai_task, media_source -from homeassistant.components.anthropic.const import ( - CONF_CHAT_MODEL, - CONF_THINKING_BUDGET, -) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector -from . import create_content_block, create_thinking_block, create_tool_use_block +from . import create_content_block from tests.common import MockConfigEntry @@ -112,254 +108,14 @@ async def test_stream_wrong_type( ) -@freeze_time("2026-01-01 12:00:00") @pytest.mark.usefixtures("mock_init_component") -async def test_generate_structured_data_legacy( +async def test_generate_invalid_structured_data( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_create_stream: AsyncMock, - snapshot: SnapshotAssertion, -) -> None: - """Test AI Task structured data generation with legacy method.""" - for subentry in mock_config_entry.subentries.values(): - hass.config_entries.async_update_subentry( - mock_config_entry, - subentry, - data={ - CONF_CHAT_MODEL: "claude-sonnet-4-0", - CONF_THINKING_BUDGET: 0, - }, - ) - await hass.async_block_till_done() - - mock_create_stream.return_value = [ - create_tool_use_block( - 0, - "toolu_0123456789AbCdEfGhIjKlM", - "test_task", - ['{"charac', 'ters": ["Mario', '", "Luigi"]}'], - ), - ] - - result = await ai_task.async_generate_data( - hass, - task_name="Test Task", - entity_id="ai_task.claude_ai_task", - instructions="Generate test data", - structure=probatio.Schema( - { - probatio.Required("characters"): selector.selector( - { - "text": { - "multiple": True, - } - } - ) - }, - ), - ) - - assert result.data == {"characters": ["Mario", "Luigi"]} - assert mock_create_stream.call_args.kwargs.copy() == snapshot - - -@freeze_time("2026-01-01 12:00:00") -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_structured_data_legacy_tools( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_create_stream: AsyncMock, - snapshot: SnapshotAssertion, -) -> None: - """Test AI Task structured data generation with legacy method and tools enabled.""" - mock_create_stream.return_value = [ - create_tool_use_block( - 0, - "toolu_0123456789AbCdEfGhIjKlM", - "test_task", - ['{"charac', 'ters": ["Mario', '", "Luigi"]}'], - ), - ] - - for subentry in mock_config_entry.subentries.values(): - hass.config_entries.async_update_subentry( - mock_config_entry, - subentry, - data={ - "chat_model": "claude-sonnet-4-0", - "web_search": True, - "thinking_budget": 0, - }, - ) - await hass.async_block_till_done() - - result = await ai_task.async_generate_data( - hass, - task_name="Test Task", - entity_id="ai_task.claude_ai_task", - instructions="Generate test data", - structure=probatio.Schema( - { - probatio.Required("characters"): selector.selector( - { - "text": { - "multiple": True, - } - } - ) - }, - ), - ) - - assert result.data == {"characters": ["Mario", "Luigi"]} - assert mock_create_stream.call_args.kwargs.copy() == snapshot - - -@freeze_time("2026-01-01 12:00:00") -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_structured_data_legacy_extended_thinking( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_create_stream: AsyncMock, - snapshot: SnapshotAssertion, -) -> None: - """Test AI Task structured data generation. - - Uses legacy method with extended_thinking. - """ - mock_create_stream.return_value = [ - ( - *create_thinking_block( - 0, - ["Let's use the tool to respond"], - ), - *create_tool_use_block( - 1, - "toolu_0123456789AbCdEfGhIjKlM", - "test_task", - ['{"charac', 'ters": ["Mario', '", "Luigi"]}'], - ), - ), - ] - - for subentry in mock_config_entry.subentries.values(): - hass.config_entries.async_update_subentry( - mock_config_entry, - subentry, - data={ - "chat_model": "claude-sonnet-4-0", - "thinking_budget": 1500, - }, - ) - await hass.async_block_till_done() - - result = await ai_task.async_generate_data( - hass, - task_name="Test Task", - entity_id="ai_task.claude_ai_task", - instructions="Generate test data", - structure=probatio.Schema( - { - probatio.Required("characters"): selector.selector( - { - "text": { - "multiple": True, - } - } - ) - }, - ), - ) - - assert result.data == {"characters": ["Mario", "Luigi"]} - assert mock_create_stream.call_args.kwargs.copy() == snapshot - - -@freeze_time("2026-01-01 12:00:00") -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_structured_data_legacy_extra_text_block( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_create_stream: AsyncMock, - snapshot: SnapshotAssertion, -) -> None: - """Test AI Task structured data generation. - - Uses legacy method with extra text block. - """ - mock_create_stream.return_value = [ - ( - *create_thinking_block( - 0, - ["Let's use the tool to respond"], - ), - *create_content_block(1, ["Sure!"]), - *create_tool_use_block( - 2, - "toolu_0123456789AbCdEfGhIjKlM", - "test_task", - ['{"charac', 'ters": ["Mario', '", "Luigi"]}'], - ), - ), - ] - - for subentry in mock_config_entry.subentries.values(): - hass.config_entries.async_update_subentry( - mock_config_entry, - subentry, - data={ - "chat_model": "claude-sonnet-4-0", - "thinking_budget": 1500, - }, - ) - await hass.async_block_till_done() - - result = await ai_task.async_generate_data( - hass, - task_name="Test Task", - entity_id="ai_task.claude_ai_task", - instructions="Generate test data", - structure=probatio.Schema( - { - probatio.Required("characters"): selector.selector( - { - "text": { - "multiple": True, - } - } - ) - }, - ), - ) - - assert result.data == {"characters": ["Mario", "Luigi"]} - assert mock_create_stream.call_args.kwargs.copy() == snapshot - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_invalid_structured_data_legacy( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_create_stream: AsyncMock, ) -> None: - """Test AI Task with invalid JSON response with legacy method.""" - for subentry in mock_config_entry.subentries.values(): - hass.config_entries.async_update_subentry( - mock_config_entry, - subentry, - data={ - CONF_CHAT_MODEL: "claude-sonnet-4-0", - }, - ) - await hass.async_block_till_done() - + """Test AI Task with an invalid JSON response.""" mock_create_stream.return_value = [ - create_tool_use_block( - 0, - "toolu_0123456789AbCdEfGhIjKlM", - "test_task", - "INVALID JSON RESPONSE", - ) + create_content_block(0, ["INVALID JSON RESPONSE"]) ] with pytest.raises( diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 99c4f1044c48..80d9e19da3f6 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -378,7 +378,7 @@ async def test_subentry_web_search_user_location( type="message", id="mock_message_id", role="assistant", - model="claude-sonnet-4-0", + model="claude-sonnet-4-5", usage=types.Usage(input_tokens=100, output_tokens=100), content=[ types.TextBlock( diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index e0939740a143..74981d6ecbca 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -9,6 +9,7 @@ from anthropic import RateLimitError from anthropic.types import ( CitationCharLocation, CitationCharLocationParam, + CitationsConfig, CitationsWebSearchResultLocation, CitationWebSearchResultLocationParam, DocumentBlock, @@ -1018,7 +1019,7 @@ async def test_web_search( next(iter(mock_config_entry.subentries.values())), data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, - CONF_CHAT_MODEL: "claude-sonnet-4-0", + CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: True, @@ -1164,7 +1165,7 @@ async def test_web_search_error( next(iter(mock_config_entry.subentries.values())), data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, - CONF_CHAT_MODEL: "claude-sonnet-4-0", + CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: True, @@ -1878,13 +1879,13 @@ async def test_web_fetch( mock_create_stream: AsyncMock, snapshot: SnapshotAssertion, ) -> None: - """Test web fetch.""" + """Test web fetch with interleaved thinking and citation parsing.""" 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, - CONF_CHAT_MODEL: "claude-haiku-4-5", + CONF_CHAT_MODEL: "claude-sonnet-4-6", CONF_WEB_FETCH: True, CONF_WEB_FETCH_MAX_USES: 5, }, @@ -1896,7 +1897,7 @@ async def test_web_fetch( url="https://www.home-assistant.io/latest-release-notes/", content=DocumentBlock( type="document", - citations=None, + citations=CitationsConfig(enabled=True), source=PlainTextSource( type="text", data="Home Assistant new version is out!\nMany new features.\n" @@ -1950,8 +1951,8 @@ async def test_web_fetch( type="char_location", document_index=0, document_title="Latest Home Assistant Release Notes", - start_char_index=56, - end_char_index=105, + start_char_index=54, + end_char_index=104, cited_text="Anthropic integration now supports web fetch tool.", ), ], @@ -1969,6 +1970,15 @@ async def test_web_fetch( agent_id="conversation.claude_conversation", ) + request = mock_create_stream.call_args.kwargs + assert request["model"] == "claude-sonnet-4-6" + assert request["thinking"] == {"type": "adaptive", "display": "summarized"} + assert { + "name": "web_fetch", + "type": "web_fetch_20250910", + "max_uses": 5, + } in request["tools"] + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( result.conversation_id ) diff --git a/tests/components/anthropic/test_coordinator.py b/tests/components/anthropic/test_coordinator.py index d5d8d475b5fb..4ce20c4240d6 100644 --- a/tests/components/anthropic/test_coordinator.py +++ b/tests/components/anthropic/test_coordinator.py @@ -12,6 +12,7 @@ from homeassistant.components.anthropic.const import DOMAIN from homeassistant.components.anthropic.coordinator import ( UPDATE_INTERVAL_CONNECTED, UPDATE_INTERVAL_DISCONNECTED, + model_alias, ) from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import Context, HomeAssistant @@ -21,6 +22,25 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.parametrize( + ("model_id", "expected_alias"), + [ + pytest.param("claude-opus-4-5-20251101", "claude-opus-4-5", id="dated_model"), + pytest.param("claude-opus-4-7", "claude-opus-4-7", id="version_alias"), + pytest.param("claude-opus-5", "claude-opus-5", id="major_version_alias"), + pytest.param( + "claude-opus-4-10", "claude-opus-4-10", id="multi_digit_version_alias" + ), + pytest.param( + "claude-mythos-preview", "claude-mythos-preview", id="preview_alias" + ), + ], +) +def test_model_alias(model_id: str, expected_alias: str) -> None: + """Test model aliases preserve versions and remove date suffixes.""" + assert model_alias(model_id) == expected_alias + + @patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock) @pytest.mark.usefixtures("mock_init_component") async def test_auth_error_handling(