diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 36c4a80f85d4..b5e1ef5c3b26 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -48,6 +48,7 @@ from .const import ( CONF_CODE_EXECUTION, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_PROMPT_CACHING, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, @@ -66,6 +67,7 @@ from .const import ( NON_ADAPTIVE_THINKING_MODELS, NON_THINKING_MODELS, WEB_SEARCH_UNSUPPORTED_MODELS, + PromptCaching, ) if TYPE_CHECKING: @@ -356,6 +358,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): CONF_TEMPERATURE, default=DEFAULT[CONF_TEMPERATURE], ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_PROMPT_CACHING, + default=DEFAULT[CONF_PROMPT_CACHING], + ): SelectSelector( + SelectSelectorConfig( + options=[x.value for x in PromptCaching], + translation_key=CONF_PROMPT_CACHING, + mode=SelectSelectorMode.DROPDOWN, + ) + ), } if user_input is not None: diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 8c88d8f47654..bed1f5530e34 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -1,5 +1,6 @@ """Constants for the Anthropic integration.""" +from enum import StrEnum import logging DOMAIN = "anthropic" @@ -13,6 +14,7 @@ CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" CONF_CODE_EXECUTION = "code_execution" CONF_MAX_TOKENS = "max_tokens" +CONF_PROMPT_CACHING = "prompt_caching" CONF_TEMPERATURE = "temperature" CONF_THINKING_BUDGET = "thinking_budget" CONF_THINKING_EFFORT = "thinking_effort" @@ -24,10 +26,20 @@ CONF_WEB_SEARCH_REGION = "region" CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" + +class PromptCaching(StrEnum): + """Prompt caching options.""" + + OFF = "off" + PROMPT = "prompt" + AUTOMATIC = "automatic" + + DEFAULT = { CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_CODE_EXECUTION: False, CONF_MAX_TOKENS: 3000, + CONF_PROMPT_CACHING: PromptCaching.PROMPT.value, CONF_TEMPERATURE: 1.0, CONF_THINKING_BUDGET: 0, CONF_THINKING_EFFORT: "low", diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 400cbe1626d9..bc88cc58c1b4 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -91,6 +91,7 @@ from .const import ( CONF_CHAT_MODEL, CONF_CODE_EXECUTION, CONF_MAX_TOKENS, + CONF_PROMPT_CACHING, CONF_TEMPERATURE, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, @@ -109,6 +110,7 @@ from .const import ( NON_THINKING_MODELS, PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS, UNSUPPORTED_STRUCTURED_OUTPUT_MODELS, + PromptCaching, ) from .coordinator import AnthropicConfigEntry, AnthropicCoordinator @@ -678,7 +680,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log( + async def _async_handle_chat_log( # noqa: C901 self, chat_log: conversation.ChatLog, structure_name: str | None = None, @@ -694,15 +696,6 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): translation_domain=DOMAIN, translation_key="system_message_not_found" ) - # System prompt with caching enabled - system_prompt: list[TextBlockParam] = [ - TextBlockParam( - type="text", - text=system.content, - cache_control={"type": "ephemeral"}, - ) - ] - messages, container_id = _convert_content(chat_log.content[1:]) model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) @@ -711,11 +704,28 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): model=model, messages=messages, max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]), - system=system_prompt, + system=system.content, stream=True, container=container_id, ) + if ( + options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING]) + == PromptCaching.PROMPT + ): + model_args["system"] = [ + { + "type": "text", + "text": system.content, + "cache_control": {"type": "ephemeral"}, + } + ] + elif ( + options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING]) + == PromptCaching.AUTOMATIC + ): + model_args["cache_control"] = {"type": "ephemeral"} + if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)): thinking_effort = options.get( CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT] diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 72b15fbe2dd7..12ec2115ae43 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -47,11 +47,13 @@ "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]", + "prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]", "temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]" }, "data_description": { "chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]", "max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::max_tokens%]", + "prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]", "temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]" }, "title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]" @@ -103,11 +105,13 @@ "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", + "prompt_caching": "Caching strategy", "temperature": "Temperature" }, "data_description": { "chat_model": "The model to serve the responses.", "max_tokens": "Limit the number of response tokens.", + "prompt_caching": "Optimize your API cost and response times based on your usage.", "temperature": "Control the randomness of the response, trading off between creativity and coherence." }, "title": "Advanced settings" @@ -210,6 +214,13 @@ } }, "selector": { + "prompt_caching": { + "options": { + "automatic": "Full", + "off": "Disabled", + "prompt": "System prompt" + } + }, "thinking_effort": { "options": { "high": "[%key:common::state::high%]", diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 9d8345113cdb..8779a569e09c 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -25,6 +25,7 @@ from homeassistant.components.anthropic.const import ( CONF_CODE_EXECUTION, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_PROMPT_CACHING, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, @@ -324,6 +325,7 @@ async def test_subentry_web_search_user_location( "country": "US", "max_tokens": 8192, "prompt": "You are a helpful assistant", + "prompt_caching": "prompt", "recommended": False, "region": "California", "temperature": 1.0, @@ -431,6 +433,7 @@ async def test_model_list_error( { CONF_CHAT_MODEL: "claude-3-haiku-20240307", CONF_TEMPERATURE: 1.0, + CONF_PROMPT_CACHING: "prompt", }, ), { @@ -439,6 +442,7 @@ async def test_model_list_error( CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-3-haiku-20240307", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], + CONF_PROMPT_CACHING: "prompt", }, ), ( # Model with web search options @@ -446,6 +450,7 @@ async def test_model_list_error( CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_PROMPT: "bla", + CONF_PROMPT_CACHING: "prompt", CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_MAX_USES: 4, CONF_WEB_SEARCH_USER_LOCATION: True, @@ -463,6 +468,7 @@ async def test_model_list_error( { CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_TEMPERATURE: 1.0, + CONF_PROMPT_CACHING: "off", }, { CONF_WEB_SEARCH: False, @@ -474,6 +480,7 @@ async def test_model_list_error( { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "off", CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -489,6 +496,7 @@ async def test_model_list_error( CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_PROMPT: "bla", + CONF_PROMPT_CACHING: "off", CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -504,6 +512,7 @@ async def test_model_list_error( { CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_TEMPERATURE: 1.0, + CONF_PROMPT_CACHING: "automatic", }, { CONF_WEB_SEARCH: False, @@ -516,6 +525,7 @@ async def test_model_list_error( { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "automatic", CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -531,6 +541,7 @@ async def test_model_list_error( CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "claude-opus-4-6", CONF_PROMPT: "bla", + CONF_PROMPT_CACHING: "automatic", CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -546,6 +557,7 @@ async def test_model_list_error( { CONF_CHAT_MODEL: "claude-opus-4-6", CONF_TEMPERATURE: 1.0, + CONF_PROMPT_CACHING: "prompt", }, { CONF_WEB_SEARCH: False, @@ -558,6 +570,7 @@ async def test_model_list_error( { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "prompt", CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-opus-4-6", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -581,12 +594,14 @@ async def test_model_list_error( }, { CONF_TEMPERATURE: 0.3, + CONF_PROMPT_CACHING: "automatic", }, {}, ), { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "automatic", CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL], CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -601,6 +616,7 @@ async def test_model_list_error( { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "off", CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL], CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -790,6 +806,7 @@ async def test_creating_ai_task_subentry_advanced( CONF_WEB_SEARCH_USER_LOCATION: False, CONF_THINKING_BUDGET: 0, CONF_CODE_EXECUTION: False, + CONF_PROMPT_CACHING: "prompt", } diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 285b894309b0..3addbd870199 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -33,6 +33,7 @@ from homeassistant.components import conversation from homeassistant.components.anthropic.const import ( CONF_CHAT_MODEL, CONF_CODE_EXECUTION, + CONF_PROMPT_CACHING, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, CONF_WEB_SEARCH, @@ -168,6 +169,7 @@ async def test_template_variables( mock_config_entry, subentry, data={ + "prompt_caching": "off", "prompt": ( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." @@ -194,12 +196,10 @@ async def test_template_variables( == "Okay, let me take care of that for you." ) - system = mock_create_stream.call_args.kwargs["system"] - assert isinstance(system, list) - system_text = " ".join(block["text"] for block in system if "text" in block) - - assert "The user name is Test User." in system_text - assert "The user id is 12345." in system_text + assert ( + "The user name is Test User." in mock_create_stream.call_args.kwargs["system"] + ) + assert "The user id is 12345." in mock_create_stream.call_args.kwargs["system"] async def test_conversation_agent( @@ -212,9 +212,10 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -async def test_system_prompt_uses_text_block_with_cache_control( +async def test_prompt_caching_system_prompt( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_init_component: None, mock_create_stream: AsyncMock, ) -> None: """Ensure system prompt is sent as TextBlockParam with cache_control.""" @@ -224,16 +225,13 @@ async def test_system_prompt_uses_text_block_with_cache_control( create_content_block(0, ["ok"]), ] - with patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - await conversation.async_converse( - hass, - "hello", - None, - context, - agent_id="conversation.claude_conversation", - ) + await conversation.async_converse( + hass, + "hello", + None, + context, + agent_id="conversation.claude_conversation", + ) system = mock_create_stream.call_args.kwargs["system"] assert isinstance(system, list) @@ -242,6 +240,41 @@ async def test_system_prompt_uses_text_block_with_cache_control( assert block["type"] == "text" assert "Home Assistant" in block["text"] assert block["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in mock_create_stream.call_args.kwargs + + +async def test_prompt_caching_automatic( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component: None, + mock_create_stream: AsyncMock, +) -> None: + """Ensure model args include cache_control.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={ + CONF_PROMPT_CACHING: "automatic", + }, + ) + + context = Context() + + mock_create_stream.return_value = [ + create_content_block(0, ["ok"]), + ] + + await conversation.async_converse( + hass, + "hello", + None, + context, + agent_id="conversation.claude_conversation", + ) + + assert mock_create_stream.call_args.kwargs["cache_control"] == {"type": "ephemeral"} + system = mock_create_stream.call_args.kwargs["system"] + assert isinstance(system, str) @patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools")