1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-07 14:56:25 +01:00

Automatic caching support for Anthropic (#167436)

This commit is contained in:
Denis Shulyaka
2026-04-05 20:10:08 +03:00
committed by GitHub
parent 5d5d00f0b2
commit 2b0f2bcd12
6 changed files with 123 additions and 28 deletions
@@ -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:
@@ -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",
+21 -11
View File
@@ -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]
@@ -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%]",
@@ -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",
}
+50 -17
View File
@@ -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")