diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 51cc99f428648d..e76ad706f129d4 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -28,8 +28,7 @@ CONF_NAME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import llm -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, llm from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( NumberSelector, @@ -52,6 +51,8 @@ CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, CONF_TOOL_SEARCH, + CONF_WEB_FETCH, + CONF_WEB_FETCH_MAX_USES, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -454,11 +455,19 @@ async def async_step_model( vol.Optional( CONF_WEB_SEARCH_MAX_USES, default=DEFAULT[CONF_WEB_SEARCH_MAX_USES], - ): int, + ): cv.positive_int, vol.Optional( CONF_WEB_SEARCH_USER_LOCATION, default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION], ): bool, + vol.Optional( + CONF_WEB_FETCH, + default=DEFAULT[CONF_WEB_FETCH], + ): bool, + vol.Optional( + CONF_WEB_FETCH_MAX_USES, + default=DEFAULT[CONF_WEB_FETCH_MAX_USES], + ): cv.positive_int, } ) diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index a1fa522ffae6a7..85f7ba0d88e1d6 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -18,6 +18,8 @@ CONF_THINKING_BUDGET = "thinking_budget" CONF_THINKING_EFFORT = "thinking_effort" CONF_TOOL_SEARCH = "tool_search" +CONF_WEB_FETCH = "web_fetch" +CONF_WEB_FETCH_MAX_USES = "web_fetch_max_uses" CONF_WEB_SEARCH = "web_search" CONF_WEB_SEARCH_USER_LOCATION = "user_location" CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses" @@ -45,6 +47,8 @@ class PromptCaching(StrEnum): CONF_THINKING_BUDGET: MIN_THINKING_BUDGET, CONF_THINKING_EFFORT: "low", CONF_TOOL_SEARCH: False, + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 5, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_MAX_USES: 5, diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index bd2782c3eb195f..c8e2718fc36f4f 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -17,8 +17,6 @@ Base64PDFSourceParam, BashCodeExecutionToolResultBlock, CitationsDelta, - CitationsWebSearchResultLocation, - CitationWebSearchResultLocationParam, CodeExecutionTool20250825Param, CodeExecutionToolResultBlock, CodeExecutionToolResultBlockContent, @@ -70,6 +68,9 @@ ToolUseBlock, ToolUseBlockParam, Usage, + WebFetchTool20250910Param, + WebFetchTool20260209Param, + WebFetchToolResultBlock, WebSearchTool20250305Param, WebSearchTool20260209Param, WebSearchToolResultBlock, @@ -97,6 +98,12 @@ Content as ToolSearchToolResultBlockParamContentParam, ) from anthropic.types.tool_use_block import Caller +from anthropic.types.web_fetch_tool_result_block import ( + Content as WebFetchToolResultBlockContent, +) +from anthropic.types.web_fetch_tool_result_block_param import ( + Content as WebFetchToolResultBlockParamContentParam, +) import voluptuous as vol from voluptuous_openapi import convert @@ -118,6 +125,8 @@ CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, CONF_TOOL_SEARCH, + CONF_WEB_FETCH, + CONF_WEB_FETCH_MAX_USES, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -208,17 +217,9 @@ def add_citation(self, citation: TextCitation) -> None: """Add a citation to the current detail.""" if not self.citation_details: self.citation_details.append(CitationDetails()) - citation_param: TextCitationParam | None = None - if isinstance(citation, CitationsWebSearchResultLocation): - citation_param = CitationWebSearchResultLocationParam( - type="web_search_result_location", - title=citation.title, - url=citation.url, - cited_text=citation.cited_text, - encrypted_index=citation.encrypted_index, - ) - if citation_param: - self.citation_details[-1].citations.append(citation_param) + self.citation_details[-1].citations.append( + cast(TextCitationParam, citation.to_dict()) + ) def delete_empty(self) -> None: """Delete empty citation details.""" @@ -289,6 +290,15 @@ def _convert_content( # noqa: C901 content.tool_result, ), } + elif content.tool_name == "web_fetch": + tool_result_block = { + "type": "web_fetch_tool_result", + "tool_use_id": content.tool_call_id, + "content": cast( + WebFetchToolResultBlockParamContentParam, + content.tool_result, + ), + } else: tool_result_block = { "type": "tool_result", @@ -415,6 +425,7 @@ def _convert_content( # noqa: C901 id=tool_call.id, name=cast( Literal[ + "web_fetch", "web_search", "code_execution", "bash_code_execution", @@ -428,6 +439,7 @@ def _convert_content( # noqa: C901 if tool_call.external and tool_call.tool_name in [ + "web_fetch", "web_search", "code_execution", "bash_code_execution", @@ -607,6 +619,7 @@ def on_content_block_start_event( if isinstance( content_block, ( + WebFetchToolResultBlock, WebSearchToolResultBlock, CodeExecutionToolResultBlock, BashCodeExecutionToolResultBlock, @@ -721,13 +734,15 @@ def on_server_tool_result_block( self, tool_use_id: str, tool_name: Literal[ + "web_fetch_tool_result", "web_search_tool_result", "code_execution_tool_result", "bash_code_execution_tool_result", "text_editor_code_execution_tool_result", "tool_search_tool_result", ], - content: WebSearchToolResultBlockContent + content: WebFetchToolResultBlockContent + | WebSearchToolResultBlockContent | CodeExecutionToolResultBlockContent | BashCodeExecutionToolResultBlockContent | TextEditorCodeExecutionToolResultBlockContent @@ -904,6 +919,7 @@ async def _get_model_args( # noqa: C901 "GetLiveContext", "code_execution", "web_search", + "web_fetch", ] system = chat_log.content[0] @@ -977,11 +993,12 @@ async def _get_model_args( # noqa: C901 ] if options[CONF_CODE_EXECUTION]: - # The `web_search_20260209` tool automatically enables `code_execution_20260120` tool + # The `web_search_20260209` and `web_fetch_20260209` tools + # automatically enable `code_execution_20260120` tool if ( not self.model_info.capabilities or not self.model_info.capabilities.code_execution.supported - or not options[CONF_WEB_SEARCH] + or (not options[CONF_WEB_SEARCH] and not options[CONF_WEB_FETCH]) ): tools.append( CodeExecutionTool20250825Param( @@ -1019,6 +1036,28 @@ async def _get_model_args( # noqa: C901 } tools.append(web_search) + if options[CONF_WEB_FETCH]: + if ( + not self.model_info.capabilities + or not self.model_info.capabilities.code_execution.supported + or not options[CONF_CODE_EXECUTION] + ): + tools.append( + WebFetchTool20250910Param( + name="web_fetch", + type="web_fetch_20250910", + max_uses=options[CONF_WEB_FETCH_MAX_USES], + ) + ) + else: + tools.append( + WebFetchTool20260209Param( + name="web_fetch", + type="web_fetch_20260209", + max_uses=options[CONF_WEB_FETCH_MAX_USES], + ) + ) + # Handle attachments by adding them to the last user message last_content = chat_log.content[-1] if last_content.role == "user" and last_content.attachments: diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index b74314bb5372fe..6086e02286fb2a 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -80,6 +80,8 @@ "thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]", "tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]", "user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]", + "web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch%]", + "web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch_max_uses%]", "web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]", "web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]" }, @@ -90,6 +92,8 @@ "thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]", "tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]", "user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]", + "web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch%]", + "web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch_max_uses%]", "web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]", "web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]" }, @@ -149,6 +153,8 @@ "thinking_effort": "Thinking effort", "tool_search": "Enable tool search tool", "user_location": "Include home location", + "web_fetch": "Enable web fetch", + "web_fetch_max_uses": "Maximum web fetches", "web_search": "Enable web search", "web_search_max_uses": "Maximum web searches" }, @@ -159,6 +165,8 @@ "thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency", "tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context", "user_location": "Localize search results based on home location", + "web_fetch": "The web fetch tool allows Claude to retrieve full content from specified web pages and PDF documents to augment Claude's context with live web content", + "web_fetch_max_uses": "Limit the number of web fetches performed per response", "web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff", "web_search_max_uses": "Limit the number of searches performed per response" }, diff --git a/tests/components/anthropic/__init__.py b/tests/components/anthropic/__init__.py index 0048e78bb5eda5..89025b3d15fe84 100644 --- a/tests/components/anthropic/__init__.py +++ b/tests/components/anthropic/__init__.py @@ -36,6 +36,7 @@ ThinkingTypes, ToolSearchToolResultBlock, ToolUseBlock, + WebFetchToolResultBlock, WebSearchResultBlock, WebSearchToolResultBlock, WebSearchToolResultError, @@ -47,6 +48,9 @@ from anthropic.types.tool_search_tool_result_block import ( Content as ToolSearchToolResultBlockContent, ) +from anthropic.types.web_fetch_tool_result_block import ( + Content as WebFetchToolResultBlockContent, +) model_list = [ ModelInfo( @@ -641,3 +645,28 @@ def create_tool_search_result_block( ), RawContentBlockStopEvent(index=index, type="content_block_stop"), ] + + +def create_web_fetch_result_block( + index: int, + id: str, + results: WebFetchToolResultBlockContent, + caller: Caller | None = None, +) -> list[RawMessageStreamEvent]: + """Create a server tool result block for web fetch results.""" + if caller is None: + caller = DirectCaller(type="direct") + + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=WebFetchToolResultBlock( + type="web_fetch_tool_result", + tool_use_id=id, + content=results, + caller=caller, + ), + index=index, + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 216751315d8058..fae45df65f9909 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -795,6 +795,99 @@ ]) # --- # name: test_history_conversion[content6] + list([ + dict({ + 'content': "What's new in Home Assistant?", + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErU/V+ayA==', + 'thinking': 'I need to use the web_fetch tool to fetch the latest release notes from the Home Assistant website.', + 'type': 'thinking', + }), + dict({ + 'text': 'Sure, let me check that for you!', + 'type': 'text', + }), + dict({ + 'id': 'srvtoolu_12345ABC', + 'input': dict({ + 'url': 'https://www.home-assistant.io/latest-release-notes/', + }), + 'name': 'web_fetch', + 'type': 'server_tool_use', + }), + dict({ + 'content': dict({ + 'content': dict({ + 'citations': dict({ + 'enabled': True, + }), + 'source': dict({ + 'data': ''' + Home Assistant new version is out! + Many new features. + Anthropic integration now supports web fetch tool. + Enjoy the release! + ''', + 'media_type': 'text/plain', + 'type': 'text', + }), + 'title': 'Latest Home Assistant Release Notes', + 'type': 'document', + }), + 'retrieved_at': '2026-04-04T10:30:00Z', + 'type': 'web_fetch_result', + 'url': 'https://www.home-assistant.io/latest-release-notes/', + }), + 'tool_use_id': 'srvtoolu_12345ABC', + 'type': 'web_fetch_tool_result', + }), + dict({ + 'text': ''' + Here's what's great about the new release: + 1. Lots of new features + 2. + ''', + 'type': 'text', + }), + dict({ + 'citations': list([ + dict({ + '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, + 'type': 'char_location', + }), + ]), + 'text': 'New web fetch tool for Anthropic integration', + 'type': 'text', + }), + dict({ + 'text': ''' + + Enjoy! + ''', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': 'Yes, I am sure!', + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content7] list([ dict({ 'content': 'What time is it?', @@ -840,7 +933,7 @@ }), ]) # --- -# name: test_history_conversion[content7] +# name: test_history_conversion[content8] list([ dict({ 'content': 'Set humidity to 50%', @@ -1668,6 +1761,308 @@ ), }) # --- +# name: test_web_fetch + list([ + dict({ + 'attachments': None, + 'content': "What's new in Home Assistant? Please check on https://www.home-assistant.io/latest-release-notes/", + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Sure, let me check that for you!', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + }), + 'role': 'assistant', + 'thinking_content': 'I will fetch the latest release notes', + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'srvtoolu_12345ABC', + 'tool_args': dict({ + 'url': 'https://www.home-assistant.io/latest-release-notes/', + }), + 'tool_name': 'web_fetch', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_12345ABC', + 'tool_name': 'web_fetch', + 'tool_result': dict({ + 'content': dict({ + 'citations': None, + 'source': dict({ + 'data': ''' + Home Assistant new version is out! + Many new features. + Anthropic integration now supports web fetch tool. + Enjoy the release! + ''', + 'media_type': 'text/plain', + 'type': 'text', + }), + 'title': 'Latest Home Assistant Release Notes', + 'type': 'document', + }), + 'retrieved_at': '2025-10-31T12:00:00.637242Z', + 'type': 'web_fetch_result', + 'url': 'https://www.home-assistant.io/latest-release-notes/', + }), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': ''' + Here's what's great about the new release: + + ''', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + }), + 'role': 'assistant', + 'thinking_content': "Great! All clear, let's reply to the user!", + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': ''' + 1. Many new features + 2. New web fetch tool for Anthropic integration + What a release! + ''', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + dict({ + 'citations': list([ + dict({ + '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, + 'type': 'char_location', + }), + ]), + 'index': 24, + 'length': 44, + }), + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': None, + }), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_web_fetch.1 + list([ + dict({ + 'content': "What's new in Home Assistant? Please check on https://www.home-assistant.io/latest-release-notes/", + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': 'I will fetch the latest release notes', + 'type': 'thinking', + }), + dict({ + 'text': 'Sure, let me check that for you!', + 'type': 'text', + }), + dict({ + 'id': 'srvtoolu_12345ABC', + 'input': dict({ + 'url': 'https://www.home-assistant.io/latest-release-notes/', + }), + 'name': 'web_fetch', + 'type': 'server_tool_use', + }), + dict({ + 'content': dict({ + 'content': dict({ + 'citations': None, + 'source': dict({ + 'data': ''' + Home Assistant new version is out! + Many new features. + Anthropic integration now supports web fetch tool. + Enjoy the release! + ''', + 'media_type': 'text/plain', + 'type': 'text', + }), + 'title': 'Latest Home Assistant Release Notes', + 'type': 'document', + }), + 'retrieved_at': '2025-10-31T12:00:00.637242Z', + 'type': 'web_fetch_result', + 'url': 'https://www.home-assistant.io/latest-release-notes/', + }), + 'tool_use_id': 'srvtoolu_12345ABC', + 'type': 'web_fetch_tool_result', + }), + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': "Great! All clear, let's reply to the user!", + 'type': 'thinking', + }), + dict({ + 'text': ''' + Here's what's great about the new release: + + ''', + 'type': 'text', + }), + dict({ + 'text': ''' + 1. Many new features + 2. + ''', + 'type': 'text', + }), + dict({ + 'citations': list([ + dict({ + '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, + 'type': 'char_location', + }), + ]), + 'text': 'New web fetch tool for Anthropic integration', + 'type': 'text', + }), + dict({ + 'text': ''' + + What a release! + ''', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_web_fetch_error + list([ + dict({ + 'attachments': None, + 'content': "What's new in Home Assistant?", + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Sure, let me check that for you!', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + }), + 'role': 'assistant', + 'thinking_content': 'I will fetch the latest release notes', + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'srvtoolu_12345ABC', + 'tool_args': dict({ + 'url': 'https://www.home-assistant.io/latest-release-notes/', + }), + 'tool_name': 'web_fetch', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_12345ABC', + 'tool_name': 'web_fetch', + 'tool_result': dict({ + 'error_code': 'url_not_allowed', + 'type': 'web_fetch_tool_result_error', + }), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'I am unable to perform the web fetch at this time.', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_web_fetch_error.1 + list([ + dict({ + 'content': "What's new in Home Assistant?", + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': 'I will fetch the latest release notes', + 'type': 'thinking', + }), + dict({ + 'text': 'Sure, let me check that for you!', + 'type': 'text', + }), + dict({ + 'id': 'srvtoolu_12345ABC', + 'input': dict({ + 'url': 'https://www.home-assistant.io/latest-release-notes/', + }), + 'name': 'web_fetch', + 'type': 'server_tool_use', + }), + dict({ + 'content': dict({ + 'error_code': 'url_not_allowed', + 'type': 'web_fetch_tool_result_error', + }), + 'tool_use_id': 'srvtoolu_12345ABC', + 'type': 'web_fetch_tool_result', + }), + dict({ + 'text': 'I am unable to perform the web fetch at this time.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- # name: test_web_search list([ dict({ diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index edeeff9e81f445..5fb380042be7d6 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -33,6 +33,8 @@ CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, CONF_TOOL_SEARCH, + CONF_WEB_FETCH, + CONF_WEB_FETCH_MAX_USES, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -390,6 +392,8 @@ async def test_subentry_web_search_user_location( "timezone": "America/Los_Angeles", "tool_search": False, "user_location": True, + "web_fetch": False, + "web_fetch_max_uses": 5, "web_search": True, "web_search_max_uses": 5, "code_execution": False, @@ -553,6 +557,8 @@ async def test_invalid_model( CONF_PROMPT: "bla", CONF_PROMPT_CACHING: "prompt", CONF_TOOL_SEARCH: False, + CONF_WEB_FETCH: True, + CONF_WEB_FETCH_MAX_USES: 6, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_MAX_USES: 4, CONF_WEB_SEARCH_USER_LOCATION: True, @@ -572,6 +578,8 @@ async def test_invalid_model( CONF_PROMPT_CACHING: "off", }, { + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 7, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -585,6 +593,8 @@ async def test_invalid_model( CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], CONF_THINKING_BUDGET: DEFAULT[CONF_THINKING_BUDGET], + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 7, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -599,6 +609,8 @@ async def test_invalid_model( CONF_LLM_HASS_API: ["assist"], CONF_PROMPT_CACHING: "off", CONF_TOOL_SEARCH: False, + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 5, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -615,6 +627,8 @@ async def test_invalid_model( CONF_PROMPT_CACHING: "automatic", }, { + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 8, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -631,6 +645,8 @@ async def test_invalid_model( CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], CONF_THINKING_BUDGET: 2048, CONF_TOOL_SEARCH: True, + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 8, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -644,6 +660,8 @@ async def test_invalid_model( CONF_PROMPT: "bla", CONF_PROMPT_CACHING: "automatic", CONF_TOOL_SEARCH: True, + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 5, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -661,6 +679,8 @@ async def test_invalid_model( CONF_PROMPT_CACHING: "prompt", }, { + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 9, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -677,6 +697,8 @@ async def test_invalid_model( CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], CONF_THINKING_EFFORT: "xhigh", CONF_TOOL_SEARCH: False, + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 9, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -710,6 +732,8 @@ async def test_invalid_model( CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 5, CONF_CODE_EXECUTION: False, }, ), @@ -722,6 +746,8 @@ async def test_invalid_model( CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], CONF_THINKING_BUDGET: DEFAULT[CONF_THINKING_BUDGET], CONF_TOOL_SEARCH: True, + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 5, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -901,6 +927,8 @@ async def test_creating_ai_task_subentry_advanced( CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_MAX_TOKENS: 1200, CONF_TOOL_SEARCH: False, + CONF_WEB_FETCH: False, + CONF_WEB_FETCH_MAX_USES: 5, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 422201b926e5a4..3d3c16defa1b76 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -6,10 +6,14 @@ from anthropic import RateLimitError from anthropic.types import ( + CitationCharLocation, + CitationCharLocationParam, CitationsWebSearchResultLocation, CitationWebSearchResultLocationParam, + DocumentBlock, EncryptedCodeExecutionResultBlock, Message, + PlainTextSource, ServerToolCaller20260120, TextBlock, TextEditorCodeExecutionCreateResultBlock, @@ -19,6 +23,8 @@ ToolSearchToolResultError, ToolSearchToolSearchResultBlock, Usage, + WebFetchBlock, + WebFetchToolResultErrorBlock, WebSearchResultBlock, WebSearchToolResultError, ) @@ -39,6 +45,8 @@ CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, CONF_TOOL_SEARCH, + CONF_WEB_FETCH, + CONF_WEB_FETCH_MAX_USES, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -74,6 +82,7 @@ create_thinking_block, create_tool_search_result_block, create_tool_use_block, + create_web_fetch_result_block, create_web_search_result_block, ) @@ -1750,6 +1759,182 @@ async def test_tool_search_error( assert mock_create_stream.call_args.kwargs["messages"] == snapshot +@freeze_time("2025-10-31 12:00:00") +async def test_web_fetch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test web fetch.""" + 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_WEB_FETCH: True, + CONF_WEB_FETCH_MAX_USES: 5, + }, + ) + + web_fetch_result = WebFetchBlock( + type="web_fetch_result", + url="https://www.home-assistant.io/latest-release-notes/", + content=DocumentBlock( + type="document", + citations=None, + source=PlainTextSource( + type="text", + data="Home Assistant new version is out!\nMany new features.\n" + "Anthropic integration now supports web fetch tool.\nEnjoy the release!", + media_type="text/plain", + ), + title="Latest Home Assistant Release Notes", + ), + retrieved_at="2025-10-31T12:00:00.637242Z", + ) + + mock_create_stream.return_value = [ + ( + *create_thinking_block( + 0, + ["I will fetch the latest", " release notes"], + ), + *create_content_block( + 1, + ["Sure, let me check that for you!"], + ), + *create_server_tool_use_block( + 2, + "srvtoolu_12345ABC", + "web_fetch", + [ + '{"url": "https', + "://www.home-", + "assistant.io/latest", + '-release-notes/"}', + ], + ), + *create_web_fetch_result_block(3, "srvtoolu_12345ABC", web_fetch_result), + *create_thinking_block( + 4, + ["Great! All clear, let's reply to the user!"], + ), + *create_content_block( + 5, + ["Here's what's great about the new release:\n"], + ), + *create_content_block( + 6, + ["1. Many new features\n2. "], + ), + *create_content_block( + 7, + ["New web fetch tool for Anthropic integration"], + citations=[ + CitationCharLocation( + type="char_location", + document_index=0, + document_title="Latest Home Assistant Release Notes", + start_char_index=56, + end_char_index=105, + cited_text="Anthropic integration now supports web fetch tool.", + ), + ], + ), + *create_content_block(8, ["\nWhat a release!"]), + ) + ] + + result = await conversation.async_converse( + hass, + "What's new in Home Assistant? Please check on " + "https://www.home-assistant.io/latest-release-notes/", + None, + Context(), + agent_id="conversation.claude_conversation", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot + assert mock_create_stream.call_args.kwargs["messages"] == snapshot + + +@freeze_time("2025-10-31 12:00:00") +async def test_web_fetch_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test web fetch error.""" + 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_CODE_EXECUTION: True, + CONF_CHAT_MODEL: "claude-opus-4-6", + CONF_WEB_FETCH: True, + CONF_WEB_FETCH_MAX_USES: 5, + }, + ) + + web_fetch_result = WebFetchToolResultErrorBlock( + type="web_fetch_tool_result_error", + error_code="url_not_allowed", + ) + mock_create_stream.return_value = [ + ( + *create_thinking_block( + 0, + ["I will fetch the latest", " release notes"], + ), + *create_content_block( + 1, + ["Sure, let me check that for you!"], + ), + *create_server_tool_use_block( + 2, + "srvtoolu_12345ABC", + "web_fetch", + [ + '{"url": "https', + "://www.home-", + "assistant.io/latest", + '-release-notes/"}', + ], + ), + *create_web_fetch_result_block(3, "srvtoolu_12345ABC", web_fetch_result), + *create_content_block( + 4, + ["I am unable to perform the web fetch at this time."], + ), + ) + ] + + result = await conversation.async_converse( + hass, + "What's new in Home Assistant?", + None, + Context(), + agent_id="conversation.claude_conversation", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot + assert mock_create_stream.call_args.kwargs["messages"] == snapshot + + async def test_container_reused( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, @@ -1967,6 +2152,71 @@ async def test_container_reused( ), ), ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What's new in Home Assistant?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + content="Sure, let me check that for you!", + thinking_content="I need to use the web_fetch tool to fetch the latest release notes from the Home Assistant website.", + native=ContentDetails(thinking_signature="ErU/V+ayA=="), + tool_calls=[ + llm.ToolInput( + id="srvtoolu_12345ABC", + tool_name="web_fetch", + tool_args={ + "url": "https://www.home-assistant.io/latest-release-notes/" + }, + external=True, + ), + ], + ), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude_conversation", + tool_call_id="srvtoolu_12345ABC", + tool_name="web_fetch", + tool_result={ + "type": "web_fetch_result", + "url": "https://www.home-assistant.io/latest-release-notes/", + "content": { + "type": "document", + "source": { + "type": "text", + "media_type": "text/plain", + "data": "Home Assistant new version is out!\nMany new features.\nAnthropic integration now supports web fetch tool.\nEnjoy the release!", + }, + "title": "Latest Home Assistant Release Notes", + "citations": {"enabled": True}, + }, + "retrieved_at": "2026-04-04T10:30:00Z", + }, + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + content="Here's what's great about the new release:\n" + "1. Lots of new features\n" + "2. New web fetch tool for Anthropic integration\n" + "Enjoy!", + native=ContentDetails( + citation_details=[ + CitationDetails( + index=70, + length=44, + citations=[ + CitationCharLocationParam( + type="char_location", + cited_text="Anthropic integration now supports web fetch tool.", + document_index=0, + document_title="Latest Home Assistant Release Notes", + start_char_index=56, + end_char_index=105, + ), + ], + ), + ], + ), + ), + ], [ conversation.chat_log.SystemContent("You are a helpful assistant."), conversation.chat_log.UserContent("What time is it?"),