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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user